max / makenotwork
13 files changed,
+171 insertions,
-43 deletions
| @@ -19,6 +19,10 @@ pub enum OAuthScope { | |||
| 19 | 19 | PerksRead, | |
| 20 | 20 | /// Issue a refresh token on the authorization-code grant (OIDC name). | |
| 21 | 21 | Offline, | |
| 22 | + | /// Full SyncKit-API access — the desktop-app pairing flow. Distinct from the | |
| 23 | + | /// read-only userinfo scopes: a token carrying `sync` authenticates the | |
| 24 | + | /// entire sync API, so it is the explicit opt-in for the 7-day sync token. | |
| 25 | + | Sync, | |
| 22 | 26 | } | |
| 23 | 27 | ||
| 24 | 28 | impl OAuthScope { | |
| @@ -27,6 +31,7 @@ impl OAuthScope { | |||
| 27 | 31 | OAuthScope::ProfileRead => "profile:read", | |
| 28 | 32 | OAuthScope::PerksRead => "perks:read", | |
| 29 | 33 | OAuthScope::Offline => "offline_access", | |
| 34 | + | OAuthScope::Sync => "sync", | |
| 30 | 35 | } | |
| 31 | 36 | } | |
| 32 | 37 | } | |
| @@ -38,6 +43,7 @@ impl FromStr for OAuthScope { | |||
| 38 | 43 | "profile:read" => Ok(OAuthScope::ProfileRead), | |
| 39 | 44 | "perks:read" => Ok(OAuthScope::PerksRead), | |
| 40 | 45 | "offline_access" => Ok(OAuthScope::Offline), | |
| 46 | + | "sync" => Ok(OAuthScope::Sync), | |
| 41 | 47 | _ => Err(()), | |
| 42 | 48 | } | |
| 43 | 49 | } | |
| @@ -78,6 +84,20 @@ impl GrantedScopes { | |||
| 78 | 84 | self.0.is_empty() | |
| 79 | 85 | } | |
| 80 | 86 | ||
| 87 | + | /// True when this is a full-sync request rather than the read-only userinfo | |
| 88 | + | /// RP flow. A request is "sync" if it explicitly carries the `sync` scope, | |
| 89 | + | /// or — for backward compatibility with desktop clients that predate the | |
| 90 | + | /// explicit scope — if it carries no recognized scope at all. | |
| 91 | + | /// | |
| 92 | + | /// The empty-scope path is deprecated: it exists only so already-shipped | |
| 93 | + | /// clients keep working during the transition. Once the first-party clients | |
| 94 | + | /// (GoingsOn, AudioFiles, mnw-cli) send `scope=sync`, the empty path should | |
| 95 | + | /// be rejected outright so a userinfo client that merely forgets its `scope` | |
| 96 | + | /// param can no longer be silently escalated to a sync-capable token. | |
| 97 | + | pub fn is_sync_request(&self) -> bool { | |
| 98 | + | self.0.is_empty() || self.contains(OAuthScope::Sync) | |
| 99 | + | } | |
| 100 | + | ||
| 81 | 101 | /// True if every scope in `self` is also in `other`. The downgrade-only | |
| 82 | 102 | /// invariant for refresh: a refresh request may narrow but never widen the | |
| 83 | 103 | /// scope it was originally granted. Also the prompt=none consent gate: a | |
| @@ -165,4 +185,18 @@ mod tests { | |||
| 165 | 185 | assert!(GrantedScopes::parse("perks:read").subset_of(&a)); | |
| 166 | 186 | assert!(!GrantedScopes::parse("offline_access").subset_of(&a)); | |
| 167 | 187 | } | |
| 188 | + | ||
| 189 | + | #[test] | |
| 190 | + | fn sync_request_detection() { | |
| 191 | + | // Explicit opt-in and the deprecated empty scope both mint a sync token. | |
| 192 | + | assert!(GrantedScopes::parse("sync").is_sync_request()); | |
| 193 | + | assert!(GrantedScopes::parse("").is_sync_request()); | |
| 194 | + | assert!(GrantedScopes::parse(" ").is_sync_request()); | |
| 195 | + | // A userinfo request (which is what a client that *forgot* nothing sends) | |
| 196 | + | // must NOT be treated as a sync request — this is the escalation guard. | |
| 197 | + | assert!(!GrantedScopes::parse("profile:read").is_sync_request()); | |
| 198 | + | assert!(!GrantedScopes::parse("profile:read perks:read").is_sync_request()); | |
| 199 | + | // `sync` round-trips through the canonical string form. | |
| 200 | + | assert_eq!(GrantedScopes::parse("sync").to_string(), "sync"); | |
| 201 | + | } | |
| 168 | 202 | } |
| @@ -137,13 +137,15 @@ pub(super) async fn git_authorize( | |||
| 137 | 137 | .as_deref() | |
| 138 | 138 | .ok_or_else(|| AppError::ServiceUnavailable("Git hosting is not configured".to_string()))?; | |
| 139 | 139 | ||
| 140 | - | // Look up the namespace owner | |
| 141 | - | let owner_user = db::users::get_user_by_username( | |
| 142 | - | &state.db, | |
| 143 | - | &Username::from_trusted(req.owner.clone()), | |
| 144 | - | ) | |
| 145 | - | .await? | |
| 146 | - | .ok_or(AppError::NotFound)?; | |
| 140 | + | // Look up the namespace owner. `req.owner` is a client-supplied field, so | |
| 141 | + | // validate it through `Username::new` rather than `from_trusted` — the | |
| 142 | + | // newtype's contract is "this string already passed validation", and an | |
| 143 | + | // unvalidated owner defeats it (a malformed owner simply can't name a real | |
| 144 | + | // user, so it maps to the same NotFound). | |
| 145 | + | let owner = Username::new(&req.owner).map_err(|_| AppError::NotFound)?; | |
| 146 | + | let owner_user = db::users::get_user_by_username(&state.db, &owner) | |
| 147 | + | .await? | |
| 148 | + | .ok_or(AppError::NotFound)?; | |
| 147 | 149 | ||
| 148 | 150 | let repo = match db::git_repos::get_repo_by_user_and_name( | |
| 149 | 151 | &state.db, |
| @@ -178,7 +178,15 @@ pub(crate) async fn resolve_repo( | |||
| 178 | 178 | let db_repo = match db::git_repos::get_repo_by_user_and_name(&state.db, db_user.id, repo_name).await? { | |
| 179 | 179 | Some(r) => r, | |
| 180 | 180 | None => { | |
| 181 | - | // Auto-register: repo exists on disk but not in DB | |
| 181 | + | // Auto-register: repo exists on disk but not in DB. Only the owner | |
| 182 | + | // may trigger this. Otherwise an anonymous or third-party GET would | |
| 183 | + | // (a) issue a DB write on an unauthenticated request, and (b) | |
| 184 | + | // materialize the row at `create_repo`'s default visibility — | |
| 185 | + | // re-publishing a private repo that lost its row. Non-owners get the | |
| 186 | + | // same 404 as a genuinely missing repo. | |
| 187 | + | if session_user_id != Some(db_user.id) { | |
| 188 | + | return Err(AppError::NotFound); | |
| 189 | + | } | |
| 182 | 190 | let disk_dir = owner; | |
| 183 | 191 | if git::open_repo(&root, disk_dir, repo_name).is_err() { | |
| 184 | 192 | return Err(AppError::NotFound); |
| @@ -71,6 +71,11 @@ pub(super) async fn raw_file( | |||
| 71 | 71 | ||
| 72 | 72 | Response::builder() | |
| 73 | 73 | .header(header::CONTENT_TYPE, content_type) | |
| 74 | + | // Repo contents are attacker-controlled and served from the primary | |
| 75 | + | // origin. `nosniff` stops a browser from MIME-sniffing an | |
| 76 | + | // `application/octet-stream` (or otherwise unlisted) file into active | |
| 77 | + | // HTML/script — a stored-XSS vector when raw files share the app origin. | |
| 78 | + | .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") | |
| 74 | 79 | .header( | |
| 75 | 80 | header::CONTENT_DISPOSITION, | |
| 76 | 81 | format!( |
| @@ -342,13 +342,16 @@ async fn authorize_get( | |||
| 342 | 342 | Some(user) => { | |
| 343 | 343 | // Silent re-auth may only mint a code for scopes the user has | |
| 344 | 344 | // ALREADY consented to for this app; anything broader requires | |
| 345 | - | // interactive approval (ultra-fuzz Run 6 R6-Sec-L5). Empty scope | |
| 346 | - | // (legacy sync client) is a subset of any grant, so it still | |
| 347 | - | // passes. The check reads consent purely from the DB, so a | |
| 348 | - | // client-supplied scope can never silently widen a grant. | |
| 345 | + | // interactive approval (ultra-fuzz Run 6 R6-Sec-L5). The sync | |
| 346 | + | // pairing flow (explicit `scope=sync`, or the deprecated empty | |
| 347 | + | // scope) is gated by PKCE + interactive pairing rather than the | |
| 348 | + | // userinfo consent ledger, so it bypasses the subset check | |
| 349 | + | // exactly as empty scope always has. The check reads consent | |
| 350 | + | // purely from the DB, so a client-supplied userinfo scope can | |
| 351 | + | // never silently widen a grant. | |
| 349 | 352 | let granted = | |
| 350 | 353 | db::oauth::get_granted_scopes(&state.db, user.id, app.id).await?; | |
| 351 | - | if !scope.subset_of(&granted) { | |
| 354 | + | if !scope.is_sync_request() && !scope.subset_of(&granted) { | |
| 352 | 355 | return Ok(redirect_with_error( | |
| 353 | 356 | redirect_uri, | |
| 354 | 357 | state_param, | |
| @@ -716,10 +719,21 @@ async fn token_authorization_code( | |||
| 716 | 719 | ||
| 717 | 720 | let scope = GrantedScopes::parse(&oauth_code.scope); | |
| 718 | 721 | ||
| 719 | - | // Empty scope = a legacy sync client (desktop apps via synckit-client): mint | |
| 720 | - | // the full 7-day sync token exactly as before. This is the only path that | |
| 721 | - | // still issues a sync-API-capable token from /oauth/token. | |
| 722 | - | if scope.is_empty() { | |
| 722 | + | // A sync request (explicit `scope=sync`, or — deprecated — no scope at all) | |
| 723 | + | // mints the full 7-day sync token. This is the only path that issues a | |
| 724 | + | // sync-API-capable token from /oauth/token. | |
| 725 | + | if scope.is_sync_request() { | |
| 726 | + | // The empty-scope path is a backward-compatibility bridge only: a | |
| 727 | + | // userinfo client that merely forgets its `scope` param lands here and | |
| 728 | + | // is silently upgraded to a sync-capable token. Log every use so the | |
| 729 | + | // remaining empty-scope clients can be identified and migrated to | |
| 730 | + | // `scope=sync`, after which this branch should reject empty scope. | |
| 731 | + | if scope.is_empty() { | |
| 732 | + | tracing::warn!( | |
| 733 | + | app_id = %oauth_code.app_id, | |
| 734 | + | "oauth: full-sync token issued for empty scope (deprecated; client should send scope=sync)" | |
| 735 | + | ); | |
| 736 | + | } | |
| 723 | 737 | let token = synckit_auth::create_sync_token( | |
| 724 | 738 | secret, | |
| 725 | 739 | oauth_code.user_id, |
| @@ -205,8 +205,20 @@ async fn create_release( | |||
| 205 | 205 | verify_app_owner(&state, &sync_user, app_id).await?; | |
| 206 | 206 | validate_semver(&req.version)?; | |
| 207 | 207 | ||
| 208 | + | // The signature is served verbatim to the Tauri updater, which verifies the | |
| 209 | + | // artifact against it with minisign. An empty/implausible signature (the | |
| 210 | + | // serde default) produces a release the updater advertises but can never | |
| 211 | + | // install — reject it here rather than shipping an un-installable update. A | |
| 212 | + | // real base64-encoded minisign signature is well over 40 chars. | |
| 213 | + | let signature = req.signature.trim(); | |
| 214 | + | if signature.len() < 40 { | |
| 215 | + | return Err(AppError::BadRequest( | |
| 216 | + | "signature is required (base64-encoded minisign signature)".to_string(), | |
| 217 | + | )); | |
| 218 | + | } | |
| 219 | + | ||
| 208 | 220 | let release = | |
| 209 | - | db::ota::create_release(&state.db, app_id, &req.version, &req.notes, &req.signature) | |
| 221 | + | db::ota::create_release(&state.db, app_id, &req.version, &req.notes, signature) | |
| 210 | 222 | .await?; | |
| 211 | 223 | ||
| 212 | 224 | Ok(( |
| @@ -70,25 +70,30 @@ pub(super) async fn verify_email_handler( | |||
| 70 | 70 | } | |
| 71 | 71 | }; | |
| 72 | 72 | ||
| 73 | - | // Get user | |
| 73 | + | // Generic failure response reused for every pre-signature outcome so this | |
| 74 | + | // endpoint is not an account-enumeration oracle: "user not found", "already | |
| 75 | + | // verified", and "bad signature" must be indistinguishable to a caller | |
| 76 | + | // without a valid HMAC. UserIds are visible in `/feed/{id}` URLs, so any | |
| 77 | + | // observable difference would leak account existence + verified-state. | |
| 78 | + | let invalid_link = || { | |
| 79 | + | error_page( | |
| 80 | + | "Email Verification Failed", | |
| 81 | + | "Verification link has expired or is invalid. Please request a new one.", | |
| 82 | + | "/dashboard", | |
| 83 | + | "Go to dashboard", | |
| 84 | + | ) | |
| 85 | + | }; | |
| 86 | + | ||
| 87 | + | // Get user. A missing user yields the same generic response as a bad | |
| 88 | + | // signature (the signature is keyed on the user's email, so a genuine link | |
| 89 | + | // can only exist for a real user anyway). | |
| 74 | 90 | let user = match db::users::get_user_by_id(&state.db, user_id).await { | |
| 75 | 91 | Ok(Some(u)) => u, | |
| 76 | - | _ => { | |
| 77 | - | return Ok(error_page( | |
| 78 | - | "Email Verification Failed", | |
| 79 | - | "User not found", | |
| 80 | - | "/dashboard", | |
| 81 | - | "Go to dashboard", | |
| 82 | - | )) | |
| 83 | - | } | |
| 92 | + | _ => return Ok(invalid_link()), | |
| 84 | 93 | }; | |
| 85 | 94 | ||
| 86 | - | // Check if already verified | |
| 87 | - | if user.email_verified { | |
| 88 | - | return Ok(Redirect::to("/dashboard").into_response()); | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | // Verify HMAC signature | |
| 95 | + | // Verify the HMAC signature (and embedded expiry) BEFORE revealing any | |
| 96 | + | // account state such as the already-verified redirect below. | |
| 92 | 97 | if !email::verify_email_signature( | |
| 93 | 98 | user_id, | |
| 94 | 99 | expires, | |
| @@ -96,12 +101,12 @@ pub(super) async fn verify_email_handler( | |||
| 96 | 101 | &sig, | |
| 97 | 102 | &state.config.signing_secret, | |
| 98 | 103 | ) { | |
| 99 | - | return Ok(error_page( | |
| 100 | - | "Email Verification Failed", | |
| 101 | - | "Verification link has expired or is invalid. Please request a new one.", | |
| 102 | - | "/dashboard", | |
| 103 | - | "Go to dashboard", | |
| 104 | - | )); | |
| 104 | + | return Ok(invalid_link()); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // Signature is valid — only now is it safe to reveal verified state. | |
| 108 | + | if user.email_verified { | |
| 109 | + | return Ok(Redirect::to("/dashboard").into_response()); | |
| 105 | 110 | } | |
| 106 | 111 | ||
| 107 | 112 | // Mark email as verified |
| @@ -248,10 +248,16 @@ pub(super) async fn receipt_page( | |||
| 248 | 248 | .await? | |
| 249 | 249 | .ok_or(AppError::NotFound)?; | |
| 250 | 250 | ||
| 251 | - | // Only the buyer or the seller can view a receipt | |
| 252 | - | let viewer_id = maybe_user.as_ref().map(|u| u.id); | |
| 253 | - | let is_buyer = viewer_id == tx.buyer_id; | |
| 254 | - | let is_seller = viewer_id == tx.seller_id; | |
| 251 | + | // Only the buyer or the seller can view a receipt. An anonymous viewer must | |
| 252 | + | // never match: guest transactions persist `buyer_id = NULL`, so comparing an | |
| 253 | + | // `Option` viewer directly (`None == tx.buyer_id`) would let any anonymous | |
| 254 | + | // caller read a guest receipt. Require an authenticated viewer, then compare | |
| 255 | + | // against the concrete `Some(id)`. | |
| 256 | + | let Some(viewer_id) = maybe_user.as_ref().map(|u| u.id) else { | |
| 257 | + | return Err(AppError::Forbidden); | |
| 258 | + | }; | |
| 259 | + | let is_buyer = tx.buyer_id == Some(viewer_id); | |
| 260 | + | let is_seller = tx.seller_id == Some(viewer_id); | |
| 255 | 261 | if !is_buyer && !is_seller { | |
| 256 | 262 | return Err(AppError::Forbidden); | |
| 257 | 263 | } |
| @@ -26,6 +26,7 @@ pub fn validate_item_description(description: &str) -> Result<(), AppError> { | |||
| 26 | 26 | limits::ITEM_DESCRIPTION_MAX | |
| 27 | 27 | ))); | |
| 28 | 28 | } | |
| 29 | + | super::reject_control_chars_multiline("Description", description)?; | |
| 29 | 30 | Ok(()) | |
| 30 | 31 | } | |
| 31 | 32 | ||
| @@ -52,6 +53,7 @@ pub fn validate_item_text_body(body: &str) -> Result<(), AppError> { | |||
| 52 | 53 | limits::ITEM_TEXT_BODY_MAX | |
| 53 | 54 | ))); | |
| 54 | 55 | } | |
| 56 | + | super::reject_control_chars_multiline("Text body", body)?; | |
| 55 | 57 | Ok(()) | |
| 56 | 58 | } | |
| 57 | 59 | ||
| @@ -105,6 +107,7 @@ pub fn validate_version_number(version: &str) -> Result<(), AppError> { | |||
| 105 | 107 | limits::VERSION_NUMBER_MAX | |
| 106 | 108 | ))); | |
| 107 | 109 | } | |
| 110 | + | super::reject_control_chars("Version number", version)?; | |
| 108 | 111 | Ok(()) | |
| 109 | 112 | } | |
| 110 | 113 | ||
| @@ -116,6 +119,7 @@ pub fn validate_changelog(changelog: &str) -> Result<(), AppError> { | |||
| 116 | 119 | limits::CHANGELOG_MAX | |
| 117 | 120 | ))); | |
| 118 | 121 | } | |
| 122 | + | super::reject_control_chars_multiline("Changelog", changelog)?; | |
| 119 | 123 | Ok(()) | |
| 120 | 124 | } | |
| 121 | 125 | ||
| @@ -133,6 +137,7 @@ pub fn validate_waitlist_pitch(pitch: &str) -> Result<(), AppError> { | |||
| 133 | 137 | limits::WAITLIST_PITCH_MAX | |
| 134 | 138 | ))); | |
| 135 | 139 | } | |
| 140 | + | super::reject_control_chars_multiline("Pitch", pitch)?; | |
| 136 | 141 | Ok(()) | |
| 137 | 142 | } | |
| 138 | 143 | ||
| @@ -164,6 +169,7 @@ pub fn validate_blog_post_body(body: &str) -> Result<(), AppError> { | |||
| 164 | 169 | limits::BLOG_POST_BODY_MAX | |
| 165 | 170 | ))); | |
| 166 | 171 | } | |
| 172 | + | super::reject_control_chars_multiline("Blog post body", body)?; | |
| 167 | 173 | Ok(()) | |
| 168 | 174 | } | |
| 169 | 175 | ||
| @@ -216,6 +222,7 @@ pub fn validate_collection_description(description: &str) -> Result<(), AppError | |||
| 216 | 222 | limits::COLLECTION_DESCRIPTION_MAX | |
| 217 | 223 | ))); | |
| 218 | 224 | } | |
| 225 | + | super::reject_control_chars_multiline("Collection description", description)?; | |
| 219 | 226 | Ok(()) | |
| 220 | 227 | } | |
| 221 | 228 | ||
| @@ -243,6 +250,7 @@ pub fn validate_section_body(body: &str) -> Result<(), AppError> { | |||
| 243 | 250 | limits::SECTION_BODY_MAX | |
| 244 | 251 | ))); | |
| 245 | 252 | } | |
| 253 | + | super::reject_control_chars_multiline("Section body", body)?; | |
| 246 | 254 | Ok(()) | |
| 247 | 255 | } | |
| 248 | 256 | ||
| @@ -255,6 +263,17 @@ mod tests { | |||
| 255 | 263 | use super::*; | |
| 256 | 264 | ||
| 257 | 265 | #[test] | |
| 266 | + | fn multiline_bodies_reject_control_bytes_but_allow_newlines() { | |
| 267 | + | // Multi-line fields keep newlines/tabs (legitimate formatting)... | |
| 268 | + | assert!(validate_item_description("line one\nline two\twith tab").is_ok()); | |
| 269 | + | assert!(validate_item_text_body("para one\n\npara two").is_ok()); | |
| 270 | + | // ...but reject NUL and other non-whitespace control bytes. | |
| 271 | + | assert!(validate_item_description("bad\0null").is_err()); | |
| 272 | + | assert!(validate_item_text_body("esc\u{1b}[31m").is_err()); | |
| 273 | + | assert!(validate_changelog("fixed\u{7f}del").is_err()); | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | #[test] | |
| 258 | 277 | fn test_validate_item_title() { | |
| 259 | 278 | assert!(validate_item_title("My Song").is_ok()); | |
| 260 | 279 | assert!(validate_item_title("").is_err()); // empty |
| @@ -31,6 +31,20 @@ pub fn reject_control_chars(field: &str, value: &str) -> Result<(), AppError> { | |||
| 31 | 31 | Ok(()) | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | + | /// Reject control characters in a multi-line field while permitting normal | |
| 35 | + | /// whitespace (`\n`, `\r`, `\t`). Descriptions, bodies, and changelogs are | |
| 36 | + | /// legitimately multi-line but should never carry NUL/escape/etc. — those | |
| 37 | + | /// corrupt logs and tooling and can smuggle bytes into downstream contexts. | |
| 38 | + | /// Mirrors `validate_bio`. | |
| 39 | + | pub fn reject_control_chars_multiline(field: &str, value: &str) -> Result<(), AppError> { | |
| 40 | + | if value.chars().any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t')) { | |
| 41 | + | return Err(AppError::validation(format!( | |
| 42 | + | "{field} cannot contain control characters" | |
| 43 | + | ))); | |
| 44 | + | } | |
| 45 | + | Ok(()) | |
| 46 | + | } | |
| 47 | + | ||
| 34 | 48 | /// Maximum lengths for various fields | |
| 35 | 49 | pub mod limits { | |
| 36 | 50 | pub const DISPLAY_NAME_MAX: usize = 100; |
| @@ -35,6 +35,9 @@ pub fn validate_tier_name(name: &str) -> Result<(), AppError> { | |||
| 35 | 35 | limits::TIER_NAME_MAX | |
| 36 | 36 | ))); | |
| 37 | 37 | } | |
| 38 | + | // Single-line: a tier name can reach an email subject (Postmark rejects | |
| 39 | + | // CRLF, but reject at source), so no line breaks or control chars. | |
| 40 | + | super::reject_control_chars("Tier name", name)?; | |
| 38 | 41 | Ok(()) | |
| 39 | 42 | } | |
| 40 | 43 | ||
| @@ -46,6 +49,7 @@ pub fn validate_tier_description(description: &str) -> Result<(), AppError> { | |||
| 46 | 49 | limits::TIER_DESCRIPTION_MAX | |
| 47 | 50 | ))); | |
| 48 | 51 | } | |
| 52 | + | super::reject_control_chars_multiline("Tier description", description)?; | |
| 49 | 53 | Ok(()) | |
| 50 | 54 | } | |
| 51 | 55 |
| @@ -26,6 +26,7 @@ pub fn validate_project_description(description: &str) -> Result<(), AppError> { | |||
| 26 | 26 | limits::PROJECT_DESCRIPTION_MAX | |
| 27 | 27 | ))); | |
| 28 | 28 | } | |
| 29 | + | super::reject_control_chars_multiline("Project description", description)?; | |
| 29 | 30 | Ok(()) | |
| 30 | 31 | } | |
| 31 | 32 | ||
| @@ -57,6 +58,7 @@ pub fn validate_issue_body(body: &str) -> Result<(), AppError> { | |||
| 57 | 58 | limits::ISSUE_BODY_MAX | |
| 58 | 59 | ))); | |
| 59 | 60 | } | |
| 61 | + | super::reject_control_chars_multiline("Issue body", body)?; | |
| 60 | 62 | Ok(()) | |
| 61 | 63 | } | |
| 62 | 64 | ||
| @@ -71,6 +73,7 @@ pub fn validate_issue_comment_body(body: &str) -> Result<(), AppError> { | |||
| 71 | 73 | limits::ISSUE_COMMENT_BODY_MAX | |
| 72 | 74 | ))); | |
| 73 | 75 | } | |
| 76 | + | super::reject_control_chars_multiline("Comment", body)?; | |
| 74 | 77 | Ok(()) | |
| 75 | 78 | } | |
| 76 | 79 | ||
| @@ -118,6 +121,7 @@ pub fn validate_repo_description(desc: &str) -> Result<(), AppError> { | |||
| 118 | 121 | limits::REPO_DESCRIPTION_MAX | |
| 119 | 122 | ))); | |
| 120 | 123 | } | |
| 124 | + | super::reject_control_chars_multiline("Repository description", desc)?; | |
| 121 | 125 | Ok(()) | |
| 122 | 126 | } | |
| 123 | 127 |
| @@ -148,6 +148,7 @@ pub fn validate_machine_id(machine_id: &str) -> Result<(), AppError> { | |||
| 148 | 148 | limits::MACHINE_ID_MAX | |
| 149 | 149 | ))); | |
| 150 | 150 | } | |
| 151 | + | super::reject_control_chars("Machine ID", machine_id)?; | |
| 151 | 152 | Ok(()) | |
| 152 | 153 | } | |
| 153 | 154 |