max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 <noreply@anthropic.com>
16 files changed,
+265 insertions,
-67 deletions
| @@ -588,10 +588,14 @@ | |||
| 588 | 588 | /// | |
| 589 | 589 | /// Carve-out (ultra-fuzz Run 4): routers merged into the app OUTSIDE the | |
| 590 | 590 | /// `CsrfRouter` tree (git smart-HTTP, SSO, embed) are not wrapped by this | |
| 591 | - | /// `origin_gate`. That is sound today because those surfaces are GET-only or | |
| 591 | + | /// `origin_gate`. That is sound because those surfaces are GET-only or | |
| 592 | 592 | /// authenticated by a PAT/bearer rather than a session cookie (so they are | |
| 593 | - | /// legitimately CSRF-exempt) — but any future cookie-authed POST added to one | |
| 594 | - | /// of them must route through a `CsrfRouter`, not be merged raw. | |
| 593 | + | /// legitimately CSRF-exempt). The one mutating route among them — git | |
| 594 | + | /// `receive-pack` (push) — ENFORCES this: `authorize_push` requires a | |
| 595 | + | /// push-scoped PAT (`token_push == Some(true)`) and rejects session-cookie | |
| 596 | + | /// auth, so a cross-origin cookie POST cannot drive a write (UX-S1, Run 7). | |
| 597 | + | /// Any future cookie-authed POST added to one of these surfaces must route | |
| 598 | + | /// through a `CsrfRouter`, not be merged raw. | |
| 595 | 599 | pub fn finalize(self) -> Router<S> { | |
| 596 | 600 | self.0.layer(from_fn(origin_gate)) | |
| 597 | 601 | } |
| @@ -275,7 +275,24 @@ | |||
| 275 | 275 | CssRule::StartingStyle(_) => Some("@starting-style"), | |
| 276 | 276 | CssRule::ViewTransition(_) => Some("@view-transition"), | |
| 277 | 277 | CssRule::Unknown(_) => Some("unknown at-rule"), | |
| 278 | - | _ => None, | |
| 278 | + | // Explicitly allowed: plain style rules and the safe grouping/at-rules. | |
| 279 | + | // Enumerated with NO wildcard arm (UX-S2, Run 7) so a future | |
| 280 | + | // lightningcss upgrade that adds a `CssRule` variant fails to COMPILE | |
| 281 | + | // here until it is triaged into allow-or-block — instead of the old | |
| 282 | + | // `_ => None` silently emitting an unknown at-rule unfiltered into the | |
| 283 | + | // `<style>` block. | |
| 284 | + | CssRule::Media(_) | |
| 285 | + | | CssRule::Style(_) | |
| 286 | + | | CssRule::Keyframes(_) | |
| 287 | + | | CssRule::FontFace(_) | |
| 288 | + | | CssRule::Page(_) | |
| 289 | + | | CssRule::Supports(_) | |
| 290 | + | | CssRule::Nesting(_) | |
| 291 | + | | CssRule::NestedDeclarations(_) | |
| 292 | + | | CssRule::LayerStatement(_) | |
| 293 | + | | CssRule::LayerBlock(_) | |
| 294 | + | | CssRule::Ignored | |
| 295 | + | | CssRule::Custom(_) => None, | |
| 279 | 296 | }; | |
| 280 | 297 | ||
| 281 | 298 | if let Some(name) = blocked_name { |
| @@ -301,7 +301,10 @@ | |||
| 301 | 301 | Ok(exists) | |
| 302 | 302 | } | |
| 303 | 303 | ||
| 304 | - | /// Set the `listed` flag on an item. | |
| 304 | + | /// Set the `listed` flag on an item. UNSCOPED: takes no owner and updates by id | |
| 305 | + | /// alone, so every caller MUST have already verified the item belongs to the | |
| 306 | + | /// acting user (the bundle/project ownership check upstream) before calling — the | |
| 307 | + | /// flag write itself enforces no ownership (Sec-M2). | |
| 305 | 308 | #[tracing::instrument(skip_all, fields(%item_id, listed))] | |
| 306 | 309 | pub async fn set_item_listed(pool: &PgPool, item_id: ItemId, listed: bool) -> Result<()> { | |
| 307 | 310 | sqlx::query("UPDATE items SET listed = $2 WHERE id = $1") |
| @@ -140,9 +140,11 @@ | |||
| 140 | 140 | Ok(key) | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | - | /// Get a license key by ID. | |
| 143 | + | /// Get a license key by ID. UNSCOPED: returns any user's key, so the caller MUST | |
| 144 | + | /// authorize against the returned `owner_id` / `item_id` before acting on it | |
| 145 | + | /// (the `_unchecked` suffix makes that contract legible at the call site — Sec-M2). | |
| 144 | 146 | #[tracing::instrument(skip_all)] | |
| 145 | - | pub async fn get_license_key_by_id(pool: &PgPool, id: LicenseKeyId) -> Result<Option<DbLicenseKey>> { | |
| 147 | + | pub async fn get_license_key_by_id_unchecked(pool: &PgPool, id: LicenseKeyId) -> Result<Option<DbLicenseKey>> { | |
| 146 | 148 | let key = sqlx::query_as!( | |
| 147 | 149 | DbLicenseKey, | |
| 148 | 150 | r#" |
| @@ -127,19 +127,24 @@ | |||
| 127 | 127 | Ok(row) | |
| 128 | 128 | } | |
| 129 | 129 | ||
| 130 | - | /// Delete a media file by ID. | |
| 130 | + | /// Delete a media file owned by `user_id`. Ownership is scoped IN the SQL | |
| 131 | + | /// (`WHERE id = $1 AND user_id = $2`) so the delete can't touch another user's | |
| 132 | + | /// row even if a caller forgot to pre-check (Sec-M2); returns `None` when the row | |
| 133 | + | /// doesn't exist or isn't owned by `user_id`. | |
| 131 | 134 | #[tracing::instrument(skip_all)] | |
| 132 | 135 | pub async fn delete<'e>( | |
| 133 | 136 | executor: impl sqlx::PgExecutor<'e>, | |
| 134 | 137 | id: MediaFileId, | |
| 138 | + | user_id: UserId, | |
| 135 | 139 | ) -> Result<Option<DbMediaFile>> { | |
| 136 | 140 | let row = sqlx::query_as!( | |
| 137 | 141 | DbMediaFile, | |
| 138 | - | r#"DELETE FROM media_files WHERE id = $1 | |
| 142 | + | r#"DELETE FROM media_files WHERE id = $1 AND user_id = $2 | |
| 139 | 143 | RETURNING id AS "id: MediaFileId", user_id AS "user_id: UserId", folder, filename, | |
| 140 | 144 | s3_key, content_type, file_size_bytes, media_type, scan_status, | |
| 141 | 145 | created_at AS "created_at: chrono::DateTime<chrono::Utc>""#, | |
| 142 | 146 | id as MediaFileId, | |
| 147 | + | user_id as UserId, | |
| 143 | 148 | ) | |
| 144 | 149 | .fetch_optional(executor) | |
| 145 | 150 | .await?; |
| @@ -285,9 +285,33 @@ | |||
| 285 | 285 | app_id: SyncAppId, | |
| 286 | 286 | scope: &crate::oauth_scope::GrantedScopes, | |
| 287 | 287 | ) -> Result<()> { | |
| 288 | - | let mut merged = get_granted_scopes(pool, user_id, app_id).await?; | |
| 288 | + | let mut tx = pool.begin().await?; | |
| 289 | + | ||
| 290 | + | // Serialize concurrent consent recordings for this (user, app) so the | |
| 291 | + | // read-modify-write union below can't lose a just-granted scope to a lost | |
| 292 | + | // update (Sec-M1). Keyed on the pair, so it only contends with this user's | |
| 293 | + | // own concurrent consents for this app and auto-releases at commit. Mirrors | |
| 294 | + | // the per-reporter lock in db::reports::create_report_within_daily_limit. | |
| 295 | + | sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1::text || ':' || $2::text, 0))") | |
| 296 | + | .bind(user_id) | |
| 297 | + | .bind(app_id) | |
| 298 | + | .execute(&mut *tx) | |
| 299 | + | .await?; | |
| 300 | + | ||
| 301 | + | // Read the standing grant INSIDE the lock + transaction. | |
| 302 | + | let existing: Option<String> = sqlx::query_scalar( | |
| 303 | + | "SELECT scopes FROM oauth_granted_scopes WHERE user_id = $1 AND app_id = $2", | |
| 304 | + | ) | |
| 305 | + | .bind(user_id) | |
| 306 | + | .bind(app_id) | |
| 307 | + | .fetch_optional(&mut *tx) | |
| 308 | + | .await?; | |
| 309 | + | let mut merged = existing | |
| 310 | + | .map(|s| crate::oauth_scope::GrantedScopes::parse(&s)) | |
| 311 | + | .unwrap_or_default(); | |
| 289 | 312 | merged.union_with(scope); | |
| 290 | 313 | let scopes = merged.to_string(); | |
| 314 | + | ||
| 291 | 315 | sqlx::query!( | |
| 292 | 316 | r#" | |
| 293 | 317 | INSERT INTO oauth_granted_scopes (user_id, app_id, scopes) | |
| @@ -299,7 +323,8 @@ | |||
| 299 | 323 | app_id as SyncAppId, | |
| 300 | 324 | scopes, | |
| 301 | 325 | ) | |
| 302 | - | .execute(pool) | |
| 326 | + | .execute(&mut *tx) | |
| 303 | 327 | .await?; | |
| 328 | + | tx.commit().await?; | |
| 304 | 329 | Ok(()) | |
| 305 | 330 | } |
| @@ -138,16 +138,9 @@ | |||
| 138 | 138 | hasher.update(token.as_bytes()); | |
| 139 | 139 | let computed_hash = hex::encode(hasher.finalize()); | |
| 140 | 140 | ||
| 141 | - | // Constant-time comparison | |
| 142 | - | if computed_hash.len() != stored_hash.len() { | |
| 143 | - | return false; | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | let mut result = 0u8; | |
| 147 | - | for (a, b) in computed_hash.bytes().zip(stored_hash.bytes()) { | |
| 148 | - | result |= a ^ b; | |
| 149 | - | } | |
| 150 | - | result == 0 | |
| 141 | + | // Constant-time comparison via the shared helper (Sec-M3); it also handles the | |
| 142 | + | // length-mismatch case, so no separate early-return length check is needed. | |
| 143 | + | crate::helpers::constant_time_compare(&computed_hash, stored_hash) | |
| 151 | 144 | } | |
| 152 | 145 | ||
| 153 | 146 | /// Verify email verification signature | |
| @@ -173,15 +166,7 @@ | |||
| 173 | 166 | mac.update(message.as_bytes()); | |
| 174 | 167 | ||
| 175 | 168 | let expected = hex::encode(mac.finalize().into_bytes()); | |
| 176 | - | if expected.len() != signature.len() { | |
| 177 | - | return false; | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | let mut result = 0u8; | |
| 181 | - | for (a, b) in expected.bytes().zip(signature.bytes()) { | |
| 182 | - | result |= a ^ b; | |
| 183 | - | } | |
| 184 | - | result == 0 | |
| 169 | + | crate::helpers::constant_time_compare(&expected, signature) | |
| 185 | 170 | } | |
| 186 | 171 | ||
| 187 | 172 | /// Generate an HMAC-signed unsubscribe URL. | |
| @@ -231,16 +216,7 @@ | |||
| 231 | 216 | mac.update(message.as_bytes()); | |
| 232 | 217 | ||
| 233 | 218 | let expected = hex::encode(mac.finalize().into_bytes()); | |
| 234 | - | ||
| 235 | - | // Constant-time comparison | |
| 236 | - | if expected.len() != signature.len() { | |
| 237 | - | return false; | |
| 238 | - | } | |
| 239 | - | let mut result = 0u8; | |
| 240 | - | for (a, b) in expected.bytes().zip(signature.bytes()) { | |
| 241 | - | result |= a ^ b; | |
| 242 | - | } | |
| 243 | - | result == 0 | |
| 219 | + | crate::helpers::constant_time_compare(&expected, signature) | |
| 244 | 220 | } | |
| 245 | 221 | ||
| 246 | 222 | /// Generate account deletion URL | |
| @@ -334,15 +310,8 @@ | |||
| 334 | 310 | let hash = mac.finalize().into_bytes(); | |
| 335 | 311 | let expected = &URL_SAFE_NO_PAD.encode(&hash[..12])[..16]; | |
| 336 | 312 | ||
| 337 | - | // Constant-time comparison | |
| 338 | - | if expected.len() != sig.len() { | |
| 339 | - | return None; | |
| 340 | - | } | |
| 341 | - | let mut result = 0u8; | |
| 342 | - | for (a, b) in expected.bytes().zip(sig.bytes()) { | |
| 343 | - | result |= a ^ b; | |
| 344 | - | } | |
| 345 | - | if result != 0 { | |
| 313 | + | // Constant-time comparison via the shared helper (Sec-M3). | |
| 314 | + | if !crate::helpers::constant_time_compare(expected, sig) { | |
| 346 | 315 | return None; | |
| 347 | 316 | } | |
| 348 | 317 |