Skip to main content

max / makenotwork

security: seal git-push CSRF, close 7z/RAR polyglot evasion, exhaustive sanitizer allowlists Drive Security to A (ultra-fuzz Run 7). UX-S1: git receive-pack (push) is merged outside the CsrfRouter/origin_gate tree; authorize_push now requires a push-scoped PAT (token_push == Some(true)) and rejects session-cookie auth, so a cross-origin cookie POST can't drive a write. Carve-out comment corrected to record the enforcement. Regression test: git_session_cookie_cannot_push. Sec-S1: the R6 7z/RAR rejection only matched magic at offset 0, so a [image header][7z payload] polyglot sniffed as an image and fell through to ClamAV FailOpen. detect_unsupported_container now window-scans the whole buffer (mirroring has_zip_eocd), gated on file_type != Download so it never scans a large download buffer. Regression tests for prefixed 7z + RAR. UX-S2: the CssRule at-rule match had a `_ => None` fail-open arm that emitted an unknown at-rule unfiltered into <style>. The match is now exhaustive (no wildcard), so a lightningcss upgrade adding a variant fails to compile until triaged. UX-S3: permissive markdown sanitization rested implicitly on ammonia defaults. Route it through a named permissive_builder() and add regression tests pinning that iframe/srcdoc/style/event-handler/javascript:/object/embed are stripped. Sec-M1: record_granted_scopes did a non-atomic read-modify-write; run it under a per-(user,app) pg_advisory_xact_lock in one transaction so a concurrent consent can't lose a scope. Sec-M2: media_files::delete folds ownership into SQL (WHERE id AND user_id); get_license_key_by_id renamed _unchecked with a contract doc; set_item_listed given an explicit caller-must-own contract. Sec-M3: replace the four hand-rolled constant-time loops in email/tokens.rs with helpers::constant_time_compare (also removes their length-truncation foot-gun). guest_download now gates on scan_status == Clean, like the authenticated download path, instead of skipping the quarantine check.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-24 22:16 UTC
Signed with PGP, not checked
Commit: 240a4caa15bb806f3d5c9e175fa489a5c5dae4ab
Parent: d69642c
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