max / makenotwork
- Co-Authored-By
- Claude Opus 4.7 (1M context) <noreply@anthropic.com>
47 files changed,
+960 insertions,
-172 deletions
| @@ -235,6 +235,23 @@ | |||
| 235 | 235 | .await | |
| 236 | 236 | .context("session error")?; | |
| 237 | 237 | ||
| 238 | + | // Short-circuit legacy sessions (USER_SESSION_KEY present without a | |
| 239 | + | // SESSION_TRACKING_KEY) to anonymous. Without this, a pre-tracking | |
| 240 | + | // session quietly survives `/logout-everywhere` — that sweep deletes | |
| 241 | + | // user_sessions rows, but a legacy session has no row to delete and | |
| 242 | + | // would keep rendering as logged-in on every Unverified extractor | |
| 243 | + | // until the cookie naturally expires. | |
| 244 | + | if user.is_some() { | |
| 245 | + | let tracking: Option<UserSessionId> = session | |
| 246 | + | .get(SESSION_TRACKING_KEY) | |
| 247 | + | .await | |
| 248 | + | .ok() | |
| 249 | + | .flatten(); | |
| 250 | + | if tracking.is_none() { | |
| 251 | + | return Ok(MaybeUserUnverified(None)); | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 238 | 255 | Ok(MaybeUserUnverified(user)) | |
| 239 | 256 | } | |
| 240 | 257 | } |
| @@ -184,6 +184,15 @@ | |||
| 184 | 184 | } | |
| 185 | 185 | } | |
| 186 | 186 | ||
| 187 | + | // Manual-posture runtime assertion (dev/test only): attempted via a tokio | |
| 188 | + | // task-local flag set in `validate_token_consuming` and checked in a per- | |
| 189 | + | // route layer. Backed out 2026-05-27 — false-positive density was too high: | |
| 190 | + | // rendered error pages return 200, rate-limit and form-extraction | |
| 191 | + | // short-circuit before the handler, and the audit explicitly marked this | |
| 192 | + | // follow-up as "not blocking — only matters if Manual grows beyond one | |
| 193 | + | // route". Compile-time discipline (the `CsrfManuallyValidated` witness type | |
| 194 | + | // bound as `_validated`) stays the convention. | |
| 195 | + | ||
| 187 | 196 | /// Wrap a method-router with the Auto-posture validation layer. | |
| 188 | 197 | /// Runs `validate_auto` on every request that reaches the route. | |
| 189 | 198 | fn attach_auto_layer<S>(method_router: MethodRouter<S>) -> MethodRouter<S> | |
| @@ -418,6 +427,18 @@ | |||
| 418 | 427 | /// `_csrf` for authenticated users. Used by `CsrfPosture::Auto` routes | |
| 419 | 428 | /// and by the path-allowlist fallback during the L2 migration. | |
| 420 | 429 | async fn validate_auto(request: Request, next: Next, path: &str) -> Response { | |
| 430 | + | // Safe methods (RFC 9110 §9.2.1) are read-only by definition — never | |
| 431 | + | // CSRF-check them. This matters for multi-method routes wrapped by | |
| 432 | + | // `with_csrf(get(load).post(save))`: a bare GET should not require a | |
| 433 | + | // token (and the harness doesn't send one for GETs). | |
| 434 | + | if !matches!(*request.method(), axum::http::Method::POST | |
| 435 | + | | axum::http::Method::PUT | |
| 436 | + | | axum::http::Method::PATCH | |
| 437 | + | | axum::http::Method::DELETE) | |
| 438 | + | { | |
| 439 | + | return next.run(request).await; | |
| 440 | + | } | |
| 441 | + | ||
| 421 | 442 | // Get session from extensions | |
| 422 | 443 | let session = match request.extensions().get::<Session>() { | |
| 423 | 444 | Some(s) => s.clone(), |
| @@ -1,21 +1,36 @@ | |||
| 1 | 1 | //! Formatting utilities: prices, file sizes, initials, slugs, CSV cells. | |
| 2 | 2 | ||
| 3 | + | /// Group thousands with commas (US locale). Returns the input string unchanged | |
| 4 | + | /// for values ≤999. Operates on a digit-only string so callers stay in i64 | |
| 5 | + | /// arithmetic territory and don't need `f64` formatting tricks. | |
| 6 | + | fn group_thousands(n: u64) -> String { | |
| 7 | + | let s = n.to_string(); | |
| 8 | + | let bytes = s.as_bytes(); | |
| 9 | + | let mut out = String::with_capacity(bytes.len() + bytes.len() / 3); | |
| 10 | + | for (i, &b) in bytes.iter().enumerate() { | |
| 11 | + | if i > 0 && (bytes.len() - i) % 3 == 0 { | |
| 12 | + | out.push(','); | |
| 13 | + | } | |
| 14 | + | out.push(b as char); | |
| 15 | + | } | |
| 16 | + | out | |
| 17 | + | } | |
| 18 | + | ||
| 3 | 19 | /// Format a price in cents as a human-readable dollar string or "Free". | |
| 4 | 20 | pub fn format_price(cents: impl Into<i64>) -> String { | |
| 5 | 21 | let cents: i64 = cents.into(); | |
| 6 | 22 | if cents == 0 { | |
| 7 | - | "Free".to_string() | |
| 8 | - | } else if cents < 0 { | |
| 9 | - | let abs = (cents as f64).abs(); | |
| 10 | - | if cents % 100 == 0 { | |
| 11 | - | format!("-${}", (abs / 100.0) as u64) | |
| 12 | - | } else { | |
| 13 | - | format!("-${:.2}", abs / 100.0) | |
| 14 | - | } | |
| 15 | - | } else if cents % 100 == 0 { | |
| 16 | - | format!("${}", cents / 100) | |
| 23 | + | return "Free".to_string(); | |
| 24 | + | } | |
| 25 | + | let neg = cents < 0; | |
| 26 | + | let abs = cents.unsigned_abs(); | |
| 27 | + | let dollars = group_thousands(abs / 100); | |
| 28 | + | let frac = (abs % 100) as u32; | |
| 29 | + | let sign = if neg { "-" } else { "" }; | |
| 30 | + | if frac == 0 { | |
| 31 | + | format!("{sign}${dollars}") | |
| 17 | 32 | } else { | |
| 18 | - | format!("${:.2}", cents as f64 / 100.0) | |
| 33 | + | format!("{sign}${dollars}.{frac:02}") | |
| 19 | 34 | } | |
| 20 | 35 | } | |
| 21 | 36 | ||
| @@ -23,11 +38,12 @@ | |||
| 23 | 38 | /// | |
| 24 | 39 | /// Unlike [`format_price`], this never returns "Free" -- zero revenue is "$0.00". | |
| 25 | 40 | pub fn format_revenue(cents: i64) -> String { | |
| 26 | - | if cents < 0 { | |
| 27 | - | format!("-${:.2}", (cents as f64).abs() / 100.0) | |
| 28 | - | } else { | |
| 29 | - | format!("${:.2}", cents as f64 / 100.0) | |
| 30 | - | } | |
| 41 | + | let neg = cents < 0; | |
| 42 | + | let abs = cents.unsigned_abs(); | |
| 43 | + | let dollars = group_thousands(abs / 100); | |
| 44 | + | let frac = (abs % 100) as u32; | |
| 45 | + | let sign = if neg { "-" } else { "" }; | |
| 46 | + | format!("{sign}${dollars}.{frac:02}") | |
| 31 | 47 | } | |
| 32 | 48 | ||
| 33 | 49 | /// Format a byte count as a human-readable file size string. | |
| @@ -192,7 +208,23 @@ | |||
| 192 | 208 | ||
| 193 | 209 | #[test] | |
| 194 | 210 | fn format_revenue_large_amount() { | |
| 195 | - | assert_eq!(format_revenue(1_000_000), "$10000.00"); | |
| 211 | + | assert_eq!(format_revenue(1_000_000), "$10,000.00"); | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | #[test] | |
| 215 | + | fn format_revenue_million_dollars() { | |
| 216 | + | assert_eq!(format_revenue(100_000_000), "$1,000,000.00"); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | #[test] | |
| 220 | + | fn format_price_thousands() { | |
| 221 | + | assert_eq!(format_price(1_234_500), "$12,345"); | |
| 222 | + | assert_eq!(format_price(1_234_567), "$12,345.67"); | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | #[test] | |
| 226 | + | fn format_price_negative_thousands() { | |
| 227 | + | assert_eq!(format_price(-1_234_567i64), "-$12,345.67"); | |
| 196 | 228 | } | |
| 197 | 229 | ||
| 198 | 230 | #[test] | |
| @@ -449,7 +481,7 @@ | |||
| 449 | 481 | #[test] | |
| 450 | 482 | fn format_price_large_value() { | |
| 451 | 483 | // $1 billion in cents | |
| 452 | - | assert_eq!(format_price(100_000_000_000i64), "$1000000000"); | |
| 484 | + | assert_eq!(format_price(100_000_000_000i64), "$1,000,000,000"); | |
| 453 | 485 | } | |
| 454 | 486 | ||
| 455 | 487 | #[test] |
| @@ -530,6 +530,7 @@ | |||
| 530 | 530 | url: &str, | |
| 531 | 531 | cdn_base: Option<&str>, | |
| 532 | 532 | bucket: Option<&str>, | |
| 533 | + | s3_endpoint: Option<&str>, | |
| 533 | 534 | ) -> Option<String> { | |
| 534 | 535 | let no_query = url.split('?').next()?; | |
| 535 | 536 | ||
| @@ -544,17 +545,15 @@ | |||
| 544 | 545 | } | |
| 545 | 546 | } | |
| 546 | 547 | ||
| 547 | - | // Path-style S3: strip `https://{host}/{bucket}/`. HTTP URLs are | |
| 548 | - | // intentionally not handled — every CDN and S3 endpoint we use | |
| 549 | - | // serves over TLS, and an `http://` URL in `cover_image_url` would | |
| 550 | - | // be an operator-side misconfiguration we shouldn't paper over. | |
| 551 | - | if let Some(bucket) = bucket | |
| 552 | - | && let Some(rest) = no_query.strip_prefix("https://") | |
| 553 | - | { | |
| 554 | - | let path_start = rest.find('/')?; | |
| 555 | - | let path = &rest[path_start + 1..]; | |
| 556 | - | let bucket_prefix = format!("{bucket}/"); | |
| 557 | - | if let Some(key) = path.strip_prefix(&bucket_prefix) | |
| 548 | + | // Path-style S3: must match the configured `{endpoint}/{bucket}/` exactly. | |
| 549 | + | // Without the endpoint pin, the prior implementation accepted any | |
| 550 | + | // `https://{any-host}/{bucket}/{key}` — so an attacker-controlled URL like | |
| 551 | + | // `https://attacker.example/my-bucket/poisoned` would extract a real-looking | |
| 552 | + | // key and direct downstream code at attacker-chosen storage paths. | |
| 553 | + | if let (Some(bucket), Some(endpoint)) = (bucket, s3_endpoint) { | |
| 554 | + | let endpoint = endpoint.trim_end_matches('/'); | |
| 555 | + | let prefix = format!("{endpoint}/{bucket}/"); | |
| 556 | + | if let Some(key) = no_query.strip_prefix(&prefix) | |
| 558 | 557 | && !key.is_empty() | |
| 559 | 558 | { | |
| 560 | 559 | return Some(key.to_string()); | |
| @@ -643,6 +642,7 @@ | |||
| 643 | 642 | "https://cdn.makenot.work/projects/abc/image/cover.png", | |
| 644 | 643 | Some("https://cdn.makenot.work"), | |
| 645 | 644 | None, | |
| 645 | + | None, | |
| 646 | 646 | ); | |
| 647 | 647 | assert_eq!(key.as_deref(), Some("projects/abc/image/cover.png")); | |
| 648 | 648 | } | |
| @@ -653,6 +653,7 @@ | |||
| 653 | 653 | "https://cdn.makenot.work/foo/bar", | |
| 654 | 654 | Some("https://cdn.makenot.work/"), | |
| 655 | 655 | None, | |
| 656 | + | None, | |
| 656 | 657 | ); | |
| 657 | 658 | assert_eq!(key.as_deref(), Some("foo/bar")); | |
| 658 | 659 | } | |
| @@ -663,6 +664,7 @@ | |||
| 663 | 664 | "https://cdn.makenot.work/foo/bar?X-Amz-Signature=zzz", | |
| 664 | 665 | Some("https://cdn.makenot.work"), | |
| 665 | 666 | None, | |
| 667 | + | None, | |
| 666 | 668 | ); | |
| 667 | 669 | assert_eq!(key.as_deref(), Some("foo/bar")); | |
| 668 | 670 | } | |
| @@ -673,10 +675,37 @@ | |||
| 673 | 675 | "https://fsn1.your-objectstorage.com/my-bucket/u/123/image/cover.png?X-Amz=...", | |
| 674 | 676 | None, | |
| 675 | 677 | Some("my-bucket"), | |
| 678 | + | Some("https://fsn1.your-objectstorage.com"), | |
| 676 | 679 | ); | |
| 677 | 680 | assert_eq!(key.as_deref(), Some("u/123/image/cover.png")); | |
| 678 | 681 | } | |
| 679 | 682 | ||
| 683 | + | #[test] | |
| 684 | + | fn extract_key_path_style_rejects_attacker_host() { | |
| 685 | + | // Attacker-controlled host with the legitimate bucket name in the | |
| 686 | + | // path must NOT be accepted. The endpoint pin closes the gap. | |
| 687 | + | let key = extract_s3_key_from_url( | |
| 688 | + | "https://attacker.example/my-bucket/poisoned", | |
| 689 | + | None, | |
| 690 | + | Some("my-bucket"), | |
| 691 | + | Some("https://fsn1.your-objectstorage.com"), | |
| 692 | + | ); | |
| 693 | + | assert_eq!(key, None); | |
| 694 | + | } | |
| 695 | + | ||
| 696 | + | #[test] | |
| 697 | + | fn extract_key_path_style_requires_endpoint() { | |
| 698 | + | // Without the endpoint, the path-style branch must not fire — bucket | |
| 699 | + | // name alone is not enough to identify a trustworthy host. | |
| 700 | + | let key = extract_s3_key_from_url( | |
| 701 | + | "https://fsn1.your-objectstorage.com/my-bucket/u/123/key", | |
| 702 | + | None, | |
| 703 | + | Some("my-bucket"), | |
| 704 | + | None, | |
| 705 | + | ); | |
| 706 | + | assert_eq!(key, None); | |
| 707 | + | } | |
| 708 | + | ||
| 680 | 709 | #[test] | |
| 681 | 710 | fn extract_key_returns_none_when_no_prefix_matches() { | |
| 682 | 711 | // Neither the CDN base nor the bucket name is present in the URL. | |
| @@ -684,6 +713,7 @@ | |||
| 684 | 713 | "https://random.example.com/foo/bar", | |
| 685 | 714 | Some("https://cdn.makenot.work"), | |
| 686 | 715 | Some("my-bucket"), | |
| 716 | + | Some("https://fsn1.your-objectstorage.com"), | |
| 687 | 717 | ); | |
| 688 | 718 | assert_eq!(key, None); | |
| 689 | 719 | } | |
| @@ -696,6 +726,7 @@ | |||
| 696 | 726 | "https://cdn.makenot.work/u/me/projects/x", | |
| 697 | 727 | Some("https://cdn.makenot.work"), | |
| 698 | 728 | None, | |
| 729 | + | None, | |
| 699 | 730 | ); | |
| 700 | 731 | assert_eq!(key.as_deref(), Some("u/me/projects/x")); | |
| 701 | 732 | } |
| @@ -230,14 +230,24 @@ | |||
| 230 | 230 | .await?; | |
| 231 | 231 | } | |
| 232 | 232 | BuildStatus::Succeeded | BuildStatus::Failed | BuildStatus::Cancelled => { | |
| 233 | - | sqlx::query( | |
| 234 | - | "UPDATE ota_builds SET status = $2, finished_at = now(), error_message = $3 WHERE id = $1", | |
| 233 | + | // Gate on a non-terminal source status so the stale-build reaper | |
| 234 | + | // (`fail_stale_running_builds`) and a real terminal write can't | |
| 235 | + | // race: if the reaper just flipped the row to 'failed', the | |
| 236 | + | // successful builder's write must no-op rather than clobber it. | |
| 237 | + | // Pending IS a legitimate source — cancelling a build that never | |
| 238 | + | // started must still transition pending → cancelled. | |
| 239 | + | let result = sqlx::query( | |
| 240 | + | "UPDATE ota_builds SET status = $2, finished_at = now(), error_message = $3 WHERE id = $1 AND status IN ('pending', 'running')", | |
| 235 | 241 | ) | |
| 236 | 242 | .bind(build_id) | |
| 237 | 243 | .bind(status) | |
| 238 | 244 | .bind(error_message) | |
| 239 | 245 | .execute(pool) | |
| 240 | 246 | .await?; | |
| 247 | + | if result.rows_affected() == 0 { | |
| 248 | + | tracing::warn!(build_id = %build_id, target_status = ?status, | |
| 249 | + | "build status terminal write skipped — row already terminal (likely reaper-set)"); | |
| 250 | + | } | |
| 241 | 251 | } | |
| 242 | 252 | BuildStatus::Pending => { | |
| 243 | 253 | sqlx::query("UPDATE ota_builds SET status = $2 WHERE id = $1") | |
| @@ -259,7 +269,7 @@ | |||
| 259 | 269 | ///; the loser simply gets no row. | |
| 260 | 270 | #[tracing::instrument(skip_all)] | |
| 261 | 271 | pub async fn claim_pending_build(pool: &PgPool) -> Result<Option<DbBuild>> { | |
| 262 | - | let build = sqlx::query_as::<_, DbBuild>( | |
| 272 | + | let result = sqlx::query_as::<_, DbBuild>( | |
| 263 | 273 | r#" | |
| 264 | 274 | UPDATE ota_builds | |
| 265 | 275 | SET status = 'running', started_at = now() | |
| @@ -275,9 +285,20 @@ | |||
| 275 | 285 | "#, | |
| 276 | 286 | ) | |
| 277 | 287 | .fetch_optional(pool) | |
| 278 | - | .await?; | |
| 288 | + | .await; | |
| 279 | 289 | ||
| 280 | - | Ok(build) | |
| 290 | + | // Multi-replica: the NOT EXISTS subquery races between replicas. The | |
| 291 | + | // `ota_builds_single_running` partial unique index is the backstop — | |
| 292 | + | // the loser's UPDATE surfaces as a 23505 unique violation, which means | |
| 293 | + | // a peer claimed first. Treat as "nothing to claim this tick". | |
| 294 | + | match result { | |
| 295 | + | Ok(build) => Ok(build), | |
| 296 | + | Err(sqlx::Error::Database(e)) if e.code().as_deref() == Some("23505") => { | |
| 297 | + | tracing::info!("claim_pending_build lost the running-slot race; another replica claimed"); | |
| 298 | + | Ok(None) | |
| 299 | + | } | |
| 300 | + | Err(e) => Err(e.into()), | |
| 301 | + | } | |
| 281 | 302 | } | |
| 282 | 303 | ||
| 283 | 304 | /// Mark any builds that have been "running" longer than the timeout as failed. |