Skip to main content

max / makenotwork

server: gallery/carousel, promo-validator, fuzz #11/#12 remediations, embed port, observability The accumulated 2026-06-05 launch-plan code wave. These changes are interleaved across shared core files (db/mod.rs, scan_jobs.rs, pending_s3_deletions.rs, scanning/worker.rs, storage.rs) and cannot be cleanly separated into per-feature commits without hunk-splitting. - Gallery + carousel (launch plan S.1 / S.7): item_images/project_images tables (migration 135), db/gallery_images.rs, routes/storage/gallery.rs, composable carousel macro + static/carousel.js, gallery manager UI, item/project page render, sealed S3_KEY_REFS entries. - Promo-validator extraction (R Phase 1): db/promo_codes.rs lookup_and_validate_promo + apply_promo_to_item collapse the 4 hand-copied promo blocks (item.rs, cart.rs x2, guest_checkout.rs) into thin call sites. - Ultra Fuzz Run #11/#12 remediations (P/Q): media-confirm data-loss, cleanup pool-exhaustion, subscription-revival terminal guard across all families, min-charge gate, embed CSP, deploy_lint host-agnostic. - Embed Askama port (R Phase 1): src/templates/embed.rs + templates/embed/. - Observability to A+ (R Phase 4): #[instrument] fields + structured logs across git/mod, storage routes, project page, cart, bundles; cart promo releases now warn on failure via release_promo_quietly. - Feed key versioning (migration 134), deploy_lint.rs. Strict clippy (--features fast-tests --all-targets -D warnings) green; 1600 lib + affected integration tests pass.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-07 15:10 UTC
Signed with PGP, not checked
Commit: 9cceb187a4e8e752d3aefd4f75a1aa50928b96c8
Parent: 9b7f2bb
133 files changed, +5789 insertions, -1814 deletions
@@ -181,7 +181,18 @@
181 181 on_demand
182 182 }
183 183
184 - reverse_proxy localhost:3000
184 + # Custom domains connect directly to the origin (no Cloudflare mTLS in front),
185 + # so any client-supplied CF-Connecting-IP / X-Forwarded-For is forgeable. The
186 + # app trusts CF-Connecting-IP for rate-limiting, lockouts, and audit logs, so
187 + # overwrite it with the real TCP peer and strip XFF before proxying — a client
188 + # can no longer mint fake source IPs to evade per-IP throttles or poison logs.
189 + reverse_proxy localhost:3000 {
190 + # Set (replace) CF-Connecting-IP to the real TCP peer — overwrites any
191 + # value the client sent. Strip X-Forwarded-For so no forged value reaches
192 + # the app (the app ignores XFF anyway; this is hygiene).
193 + header_up CF-Connecting-IP {http.request.remote.host}
194 + header_up -X-Forwarded-For
195 + }
185 196
186 197 header {
187 198 X-Content-Type-Options "nosniff"
@@ -7,10 +7,15 @@
7 7 # - Allow HTTP/HTTPS (80/443) from anywhere (custom domains need direct access)
8 8 # - Drop everything else
9 9 #
10 - # HTTP/HTTPS is open to all because custom domains bypass Cloudflare.
11 - # makenot.work subdomains remain protected by Caddy mTLS (Authenticated Origin Pulls):
12 - # requests without a valid Cloudflare client cert are rejected by Caddy before
13 - # reaching the application.
10 + # HTTP/HTTPS is open to all because custom domains connect directly (on-demand
11 + # Let's Encrypt TLS, not behind Cloudflare) and arrive from arbitrary client IPs,
12 + # so 443 cannot be CIDR-locked to Cloudflare without breaking that paid feature.
13 + # The IP-spoofing risk this would otherwise create is closed in Caddy instead:
14 + # - makenot.work subdomains: Caddy mTLS (Authenticated Origin Pulls) rejects any
15 + # request without a valid Cloudflare client cert before it reaches the app.
16 + # - custom domains (:443 block): Caddy overwrites CF-Connecting-IP with the real
17 + # TCP peer and strips X-Forwarded-For, so a client cannot forge the source IP
18 + # the app uses for rate-limiting, lockouts, and audit logs.
14 19
15 20 set -e
16 21
@@ -214,6 +214,7 @@
214 214 /// - gates paid content or downloads,
215 215 /// - issues OAuth tokens / grants,
216 216 /// - exposes account-private information,
217 + ///
217 218 /// use [`AuthUser`] (required login) or [`MaybeUserVerified`] (optional login
218 219 /// with revocation check) instead.
219 220 pub struct MaybeUserUnverified(pub Option<SessionUser>);
@@ -403,7 +403,15 @@
403 403 // Cleanup remote build dir
404 404 let _ = run_ssh_command(host, &format!("rm -rf {}", shell_escape(&build_dir))).await;
405 405
406 - scp_result.map_err(|e| format!("SCP download failed: {e}"))?;
406 + if let Err(e) = scp_result {
407 + // The main artifact failed, but the .sig sidecar may already be on disk
408 + // from its own scp above. Clean it up before bailing so a retry loop
409 + // doesn't accumulate orphaned .sig temp files (the main temp is removed
410 + // unconditionally further down, but on this early return it was never
411 + // created).
412 + let _ = tokio::fs::remove_file(&local_sig_tmp).await;
413 + return Err(format!("SCP download failed: {e}"));
414 + }
407 415
408 416 // Read signature from .sig file if it was downloaded
409 417 let signature = if scp_sig_result.is_ok() {
@@ -291,423 +291,141 @@
291 291 /// Max concurrent active sandboxes per IP.
292 292 pub const SANDBOX_MAX_PER_IP: i64 = 3;
293 293
294 + // ── Compile-time invariants on the constants above ───────────────────────────
295 + //
296 + // Encoded as `const _: () = assert!(...)` rather than `#[test]` functions: these
297 + // are checked when the crate is COMPILED, so a bad constant fails the build
298 + // (not just a test run), and the whole invariant set sits next to the values.
299 +
300 + // Price constants
301 + const _: () = assert!(MAX_PRICE_CENTS > 0);
302 + const _: () = assert!(MAX_PRICE_CENTS <= 10_000_000); // <= $100,000
303 + const _: () = assert!(MIN_SUBSCRIPTION_PRICE_CENTS > 0);
304 + const _: () = assert!(MIN_SUBSCRIPTION_PRICE_CENTS < MAX_PRICE_CENTS);
305 +
306 + // Stripe fee constants
307 + const _: () = assert!(STRIPE_FEE_PERCENTAGE > 0.0 && STRIPE_FEE_PERCENTAGE < 0.5);
308 + const _: () = assert!(STRIPE_FEE_FIXED_CENTS > 0.0);
309 +
310 + // Database pool
311 + const _: () = assert!(DB_POOL_MAX_CONNECTIONS > DB_POOL_MIN_CONNECTIONS);
312 + const _: () = assert!(DB_POOL_MIN_CONNECTIONS > 0);
313 + const _: () = assert!(DB_ACQUIRE_TIMEOUT_SECS > 0);
314 + const _: () = assert!(DB_MAX_LIFETIME_SECS > DB_IDLE_TIMEOUT_SECS);
315 +
316 + // Session constants
317 + const _: () = assert!(SESSION_EXPIRY_DAYS > 0 && SESSION_EXPIRY_DAYS <= 365);
318 + const _: () = assert!(SESSION_TOUCH_CACHE_SECS > 0 && SESSION_TOUCH_CACHE_SECS < 86400);
319 +
320 + // Login security
321 + const _: () = assert!(MAX_LOGIN_ATTEMPTS > 0);
322 + const _: () = assert!(LOCKOUT_MINUTES > 0);
323 +
324 + // Email link expiry ordering
325 + const _: () = assert!(PASSWORD_RESET_EXPIRY_SECS > 0);
326 + const _: () = assert!(EMAIL_VERIFICATION_EXPIRY_SECS > PASSWORD_RESET_EXPIRY_SECS);
327 + const _: () = assert!(ACCOUNT_DELETION_EXPIRY_SECS > 0);
328 +
329 + // Scheduler
330 + const _: () = assert!(SCHEDULER_INTERVAL_SECS > 0);
331 +
332 + // Rate-limit bursts all positive
333 + const _: () = assert!(AUTH_RATE_LIMIT_BURST > 0);
334 + const _: () = assert!(VALIDATE_RATE_LIMIT_BURST > 0);
335 + const _: () = assert!(API_WRITE_RATE_LIMIT_BURST > 0);
336 + const _: () = assert!(API_READ_RATE_LIMIT_BURST > 0);
337 + const _: () = assert!(API_EXPORT_RATE_LIMIT_BURST > 0);
338 + const _: () = assert!(LICENSE_KEY_RATE_LIMIT_BURST > 0);
339 + const _: () = assert!(UPLOAD_RATE_LIMIT_BURST > 0);
340 + const _: () = assert!(OAUTH_RATE_LIMIT_BURST > 0);
341 + const _: () = assert!(OAUTH_TOKEN_RATE_LIMIT_BURST > 0);
342 +
343 + // Rate-limit burst ordering: read > write > auth
344 + const _: () = assert!(API_READ_RATE_LIMIT_BURST > API_WRITE_RATE_LIMIT_BURST);
345 + const _: () = assert!(API_WRITE_RATE_LIMIT_BURST > AUTH_RATE_LIMIT_BURST);
346 +
347 + // Rate-limit intervals positive
348 + const _: () = assert!(AUTH_RATE_LIMIT_MS > 0);
349 + const _: () = assert!(API_WRITE_RATE_LIMIT_MS > 0);
350 + const _: () = assert!(API_READ_RATE_LIMIT_MS > 0);
351 +
352 + // File size limits
353 + const _: () = assert!(SCAN_MAX_MEMORY_BYTES > 0);
354 + const _: () = assert!(SCAN_SPOOL_FREE_RESERVE_BYTES < SCAN_SPOOL_MAX_BYTES);
355 + const _: () = assert!(SCAN_SPOOL_MAX_BYTES > SCAN_MAX_MEMORY_BYTES as u64);
356 + const _: () = assert!(SCAN_JOB_RETENTION_DAYS >= 7); // no same-day purge race
357 + const _: () = assert!(BROADCAST_PARALLELISM > 0 && BROADCAST_PARALLELISM <= 64);
358 + const _: () = assert!(SCAN_ZIP_MAX_UNCOMPRESSED > SCAN_MAX_MEMORY_BYTES as u64);
359 + const _: () = assert!(GIT_RAW_MAX_BYTES > GIT_MAX_FILE_SIZE_BYTES);
360 + const _: () = assert!(SCAN_ZIP_MAX_RATIO > 0.0);
361 + const _: () = assert!(SCAN_ZIP_MAX_DEPTH > 0);
362 +
363 + // SyncKit
364 + const _: () = assert!(SYNCKIT_PUSH_MAX_CHANGES > 0);
365 + const _: () = assert!(SYNCKIT_PULL_PAGE_SIZE > 0);
366 + const _: () = assert!(SYNCKIT_MAX_BLOB_SIZE_BYTES > 0);
367 + const _: () = assert!(SYNCKIT_JWT_EXPIRY_SECS > 0);
368 +
369 + // TOTP
370 + const _: () = assert!(TOTP_DIGITS == 6);
371 + const _: () = assert!(TOTP_STEP == 30);
372 + const _: () = assert!(BACKUP_CODE_COUNT > 0);
373 + const _: () = assert!(BACKUP_CODE_LENGTH > 0);
374 +
375 + // Pagination
376 + const _: () = assert!(DISCOVER_PAGE_SIZE > 0);
377 + const _: () = assert!(FEED_PAGE_SIZE > 0);
378 + const _: () = assert!(PAGINATION_WINDOW_SIZE > 0);
379 +
380 + // String constants non-empty
381 + const _: () = assert!(!DATE_FMT_SHORT.is_empty());
382 + const _: () = assert!(!DATE_FMT_FULL.is_empty());
383 + const _: () = assert!(!DATE_FMT_ISO.is_empty());
384 + const _: () = assert!(!DATE_FMT_DATETIME.is_empty());
385 + const _: () = assert!(!DATE_FMT_DATETIME_UTC.is_empty());
386 + const _: () = assert!(!CHANGELOG_PROJECT_SLUG.is_empty());
387 + const _: () = assert!(!BUILD_ALLOWED_TARGETS.is_empty());
388 +
389 + // Collections
390 + const _: () = assert!(MAX_COLLECTIONS_PER_USER > 0);
391 + const _: () = assert!(MAX_ITEMS_PER_COLLECTION > 0);
392 +
393 + // Build pipeline
394 + const _: () = assert!(BUILD_TIMEOUT_SECS > 0);
395 + const _: () = assert!(BUILD_MAX_LOG_BYTES > 0);
396 +
397 + // Health monitoring
398 + const _: () = assert!(HEALTH_CHECK_INTERVAL_SECS > 0);
399 + const _: () = assert!(ALERT_COOLDOWN_SECS > HEALTH_CHECK_INTERVAL_SECS);
400 +
401 + // Sandbox
402 + const _: () = assert!(SANDBOX_EXPIRY_SECS > 0);
403 + const _: () = assert!(SANDBOX_CLEANUP_INTERVAL_SECS > 0);
404 + const _: () = assert!(SANDBOX_CLEANUP_INTERVAL_SECS < SANDBOX_EXPIRY_SECS as u64);
405 + const _: () = assert!(SANDBOX_MAX_PER_IP > 0);
406 +
407 + // Webhook
408 + const _: () = assert!(WEBHOOK_TIMESTAMP_TOLERANCE_SECS > 0);
409 +
410 + // OAuth
411 + const _: () = assert!(OAUTH_CODE_EXPIRY_SECS > 0);
412 + const _: () = assert!(OAUTH_CODE_LENGTH > 0);
413 +
414 + // Buffer limits
415 + const _: () = assert!(USER_AGENT_MAX_LENGTH > 0);
416 + const _: () = assert!(SYNCKIT_MAX_KEY_ENVELOPE_BYTES > 0);
417 +
294 418 #[cfg(test)]
295 419 mod tests {
296 420 use super::*;
297 421
298 - // -- Price constants --
299 -
422 + /// The build-target FORMAT check uses `str::contains`, which isn't const —
423 + /// so this invariant stays a runtime test (the rest are compile-time above).
300 424 #[test]
301 - fn max_price_cents_is_positive() {
302 - assert!(MAX_PRICE_CENTS > 0);
303 - }
304 -
305 - #[test]
306 - fn max_price_cents_sane_upper_bound() {
307 - // Should not exceed $100,000
308 - assert!(MAX_PRICE_CENTS <= 10_000_000);
309 - }
310 -
311 - #[test]
312 - fn min_subscription_price_positive() {
313 - assert!(MIN_SUBSCRIPTION_PRICE_CENTS > 0);
314 - }
315 -
316 - #[test]
317 - fn min_subscription_price_below_max() {
318 - assert!(MIN_SUBSCRIPTION_PRICE_CENTS < MAX_PRICE_CENTS);
319 - }
320 -
321 - // -- Stripe fee constants --
322 -
323 - #[test]
324 - fn stripe_fee_percentage_reasonable() {
325 - assert!(STRIPE_FEE_PERCENTAGE > 0.0);
326 - assert!(STRIPE_FEE_PERCENTAGE < 0.5); // less than 50%
327 - }
328 -
329 - #[test]
330 - fn stripe_fee_fixed_positive() {
331 - assert!(STRIPE_FEE_FIXED_CENTS > 0.0);
332 - }
333 -
334 - // -- Database pool --
335 -
336 - #[test]
337 - fn db_pool_max_exceeds_min() {
338 - assert!(DB_POOL_MAX_CONNECTIONS > DB_POOL_MIN_CONNECTIONS);
339 - }
340 -
341 - #[test]
342 - fn db_pool_min_positive() {
343 - assert!(DB_POOL_MIN_CONNECTIONS > 0);
344 - }
345 -
346 - #[test]
347 - fn db_acquire_timeout_positive() {
348 - assert!(DB_ACQUIRE_TIMEOUT_SECS > 0);
349 - }
350 -
351 - #[test]
352 - fn db_max_lifetime_exceeds_idle_timeout() {
353 - assert!(DB_MAX_LIFETIME_SECS > DB_IDLE_TIMEOUT_SECS);
354 - }
355 -
356 - // -- Session constants --
357 -
358 - #[test]
359 - fn session_expiry_positive() {
360 - assert!(SESSION_EXPIRY_DAYS > 0);
361 - }
362 -
363 - #[test]
364 - fn session_expiry_not_absurd() {
365 - assert!(SESSION_EXPIRY_DAYS <= 365);
366 - }
367 -
368 - #[test]
369 - fn session_touch_cache_positive() {
370 - assert!(SESSION_TOUCH_CACHE_SECS > 0);
371 - }
372 -
373 - #[test]
374 - fn session_touch_cache_less_than_one_day() {
375 - assert!(SESSION_TOUCH_CACHE_SECS < 86400);
376 - }
377 -
378 - // -- Login security --
379 -
380 - #[test]
381 - fn max_login_attempts_positive() {
382 - assert!(MAX_LOGIN_ATTEMPTS > 0);
383 - }
384 -
385 - #[test]
386 - fn lockout_minutes_positive() {
387 - assert!(LOCKOUT_MINUTES > 0);
388 - }
389 -
390 - // -- Email link expiry ordering --
391 -
392 - #[test]
393 - fn password_reset_expiry_positive() {
394 - assert!(PASSWORD_RESET_EXPIRY_SECS > 0);
395 - }
396 -
397 - #[test]
398 - fn email_verification_longer_than_password_reset() {
399 - assert!(EMAIL_VERIFICATION_EXPIRY_SECS > PASSWORD_RESET_EXPIRY_SECS);
400 - }
401 -
402 - #[test]
403 - fn account_deletion_expiry_positive() {
404 - assert!(ACCOUNT_DELETION_EXPIRY_SECS > 0);
405 - }
406 -
407 - // -- Scheduler --
408 -
409 - #[test]
410 - fn scheduler_interval_positive() {
411 - assert!(SCHEDULER_INTERVAL_SECS > 0);
412 - }
413 -
414 - // -- Rate limit bursts all positive --
415 -
416 - #[test]
417 - fn auth_rate_limit_burst_positive() {
418 - assert!(AUTH_RATE_LIMIT_BURST > 0);
419 - }
420 -
421 - #[test]
422 - fn validate_rate_limit_burst_positive() {
423 - assert!(VALIDATE_RATE_LIMIT_BURST > 0);
424 - }
425 -
426 - #[test]
427 - fn api_write_rate_limit_burst_positive() {
428 - assert!(API_WRITE_RATE_LIMIT_BURST > 0);
429 - }
430 -
431 - #[test]
432 - fn api_read_rate_limit_burst_positive() {
433 - assert!(API_READ_RATE_LIMIT_BURST > 0);
434 - }
435 -
436 - #[test]
437 - fn api_export_rate_limit_burst_positive() {
438 - assert!(API_EXPORT_RATE_LIMIT_BURST > 0);
439 - }
440 -
441 - #[test]
442 - fn license_key_rate_limit_burst_positive() {
443 - assert!(LICENSE_KEY_RATE_LIMIT_BURST > 0);
444 - }
445 -
446 - #[test]
447 - fn upload_rate_limit_burst_positive() {
448 - assert!(UPLOAD_RATE_LIMIT_BURST > 0);
449 - }
450 -
451 - #[test]
452 - fn oauth_rate_limit_burst_positive() {
453 - assert!(OAUTH_RATE_LIMIT_BURST > 0);
454 - }
455 -
456 - #[test]
457 - fn oauth_token_rate_limit_burst_positive() {
458 - assert!(OAUTH_TOKEN_RATE_LIMIT_BURST > 0);
459 - }
460 -
461 - // -- Rate limit burst ordering: read > write > auth --
462 -
463 - #[test]
464 - fn api_read_burst_exceeds_write_burst() {
465 - assert!(API_READ_RATE_LIMIT_BURST > API_WRITE_RATE_LIMIT_BURST);
466 - }
467 -
468 - #[test]
469 - fn api_write_burst_exceeds_auth_burst() {
470 - assert!(API_WRITE_RATE_LIMIT_BURST > AUTH_RATE_LIMIT_BURST);
471 - }
472 -
473 - // -- Rate limit intervals positive --
474 -
475 - #[test]
476 - fn auth_rate_limit_ms_positive() {
477 - assert!(AUTH_RATE_LIMIT_MS > 0);
478 - }
479 -
480 - #[test]
481 - fn api_write_rate_limit_ms_positive() {
482 - assert!(API_WRITE_RATE_LIMIT_MS > 0);
483 - }
484 -
485 - #[test]
486 - fn api_read_rate_limit_ms_positive() {
487 - assert!(API_READ_RATE_LIMIT_MS > 0);
488 - }
489 -
490 - // -- File size limits --
491 -
492 - #[test]
493 - fn scan_max_memory_positive() {
494 - assert!(SCAN_MAX_MEMORY_BYTES > 0);
495 - }
496 -
497 - #[test]
498 - fn scan_spool_reserve_below_max() {
499 - assert!(SCAN_SPOOL_FREE_RESERVE_BYTES < SCAN_SPOOL_MAX_BYTES);
500 - }
501 -
502 - #[test]
503 - fn scan_spool_max_exceeds_memory_threshold() {
504 - assert!(SCAN_SPOOL_MAX_BYTES > SCAN_MAX_MEMORY_BYTES as u64);
505 - }
506 -
507 - #[test]
508 - fn scan_job_retention_days_safe_floor() {
509 - // Guards against an accidental same-day purge that would race the
510 - // worker stamping completed_at.
511 - assert!(SCAN_JOB_RETENTION_DAYS >= 7);
512 - }
513 -
514 - #[test]
515 - fn broadcast_parallelism_sane() {
516 - assert!(BROADCAST_PARALLELISM > 0 && BROADCAST_PARALLELISM <= 64);
517 - }
518 -
519 - #[test]
520 - fn scan_zip_max_uncompressed_exceeds_memory_threshold() {
521 - assert!(SCAN_ZIP_MAX_UNCOMPRESSED > SCAN_MAX_MEMORY_BYTES as u64);
522 - }
523 -
524 - #[test]
525 - fn git_raw_max_exceeds_file_display_limit() {
526 - assert!(GIT_RAW_MAX_BYTES > GIT_MAX_FILE_SIZE_BYTES);
527 - }
528 -
529 - #[test]
530 - fn scan_zip_max_ratio_positive() {
531 - assert!(SCAN_ZIP_MAX_RATIO > 0.0);
532 - }
533 -
534 - #[test]
535 - fn scan_zip_max_depth_positive() {
536 - assert!(SCAN_ZIP_MAX_DEPTH > 0);
537 - }
538 -
539 - // -- SyncKit --
540 -
541 - #[test]
542 - fn synckit_push_max_changes_positive() {
543 - assert!(SYNCKIT_PUSH_MAX_CHANGES > 0);
544 - }
545 -
546 - #[test]
547 - fn synckit_pull_page_size_positive() {
548 - assert!(SYNCKIT_PULL_PAGE_SIZE > 0);
549 - }
550 -
551 - #[test]
552 - fn synckit_max_blob_size_positive() {
553 - assert!(SYNCKIT_MAX_BLOB_SIZE_BYTES > 0);
554 - }
555 -
556 - #[test]
557 - fn synckit_jwt_expiry_positive() {
558 - assert!(SYNCKIT_JWT_EXPIRY_SECS > 0);
559 - }
560 -
561 - // -- TOTP --
562 -
563 - #[test]
564 - fn totp_digits_is_six() {
565 - assert_eq!(TOTP_DIGITS, 6);
566 - }
567 -
568 - #[test]
569 - fn totp_step_is_30() {
570 - assert_eq!(TOTP_STEP, 30);
571 - }
572 -
573 - #[test]
574 - fn backup_code_count_positive() {
575 - assert!(BACKUP_CODE_COUNT > 0);
576 - }
577 -
578 - #[test]
579 - fn backup_code_length_positive() {
580 - assert!(BACKUP_CODE_LENGTH > 0);
581 - }
582 -
583 - // -- Pagination --
584 -
585 - #[test]
586 - fn discover_page_size_positive() {
587 - assert!(DISCOVER_PAGE_SIZE > 0);
588 - }
589 -
590 - #[test]
591 - fn feed_page_size_positive() {
592 - assert!(FEED_PAGE_SIZE > 0);
593 - }
594 -
595 - #[test]
596 - fn pagination_window_size_positive() {
597 - assert!(PAGINATION_WINDOW_SIZE > 0);
598 - }
599 -
600 - // -- String constants non-empty --
601 -
602 - #[test]
603 - fn date_formats_non_empty() {
604 - assert!(!DATE_FMT_SHORT.is_empty());
605 - assert!(!DATE_FMT_FULL.is_empty());
606 - assert!(!DATE_FMT_ISO.is_empty());
607 - assert!(!DATE_FMT_DATETIME.is_empty());
608 - assert!(!DATE_FMT_DATETIME_UTC.is_empty());
609 - }
610 -
611 - #[test]
612 - fn changelog_project_slug_non_empty() {
613 - assert!(!CHANGELOG_PROJECT_SLUG.is_empty());
614 - }
615 -
616 - #[test]
617 - fn build_allowed_targets_non_empty() {
618 - assert!(!BUILD_ALLOWED_TARGETS.is_empty());
425 + fn build_allowed_targets_are_os_slash_arch() {
619 426 for target in BUILD_ALLOWED_TARGETS {
620 427 assert!(!target.is_empty());
621 - assert!(target.contains('/'), "target should be os/arch format: {}", target);
428 + assert!(target.contains('/'), "target should be os/arch format: {target}");
622 429 }
623 430 }
624 -
625 - // -- Collections --
626 -
627 - #[test]
628 - fn max_collections_per_user_positive() {
629 - assert!(MAX_COLLECTIONS_PER_USER > 0);
630 - }
631 -
632 - #[test]
633 - fn max_items_per_collection_positive() {
634 - assert!(MAX_ITEMS_PER_COLLECTION > 0);
635 - }
636 -
637 - // -- Build pipeline --
638 -
639 - #[test]
640 - fn build_timeout_positive() {
641 - assert!(BUILD_TIMEOUT_SECS > 0);
642 - }
643 -
644 - #[test]
645 - fn build_max_log_bytes_positive() {
646 - assert!(BUILD_MAX_LOG_BYTES > 0);
647 - }
648 -
649 - // -- Health monitoring --
650 -
651 - #[test]
652 - fn health_check_interval_positive() {
653 - assert!(HEALTH_CHECK_INTERVAL_SECS > 0);
654 - }
655 -
656 - #[test]
657 - fn alert_cooldown_exceeds_health_check() {
658 - assert!(ALERT_COOLDOWN_SECS > HEALTH_CHECK_INTERVAL_SECS);
659 - }
660 -
661 - // -- Sandbox --
662 -
Lines truncated
@@ -39,34 +39,46 @@
39 39 crate::db::KeyCode::from_trusted(words.join("-"))
40 40 }
41 41
42 - /// Generate an HMAC-signed personal RSS feed URL for a user.
43 - ///
44 - /// The URL is permanent (no expiry) and tied to the signing secret.
45 - /// If the secret rotates, old URLs become invalid.
46 - pub fn generate_feed_url(host_url: &str, user_id: crate::db::UserId, secret: &str) -> String {
42 + /// Compute the hex HMAC-SHA256 over `feed:{user_id}:{version}` with `secret`.
43 + fn feed_signature(user_id: crate::db::UserId, version: i32, secret: &str) -> String {
47 44 use hmac::{Hmac, Mac};
48 45 use sha2::Sha256;
49 46
50 - let message = format!("feed:{}", user_id);
47 + let message = format!("feed:{user_id}:{version}");
51 48 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
52 49 .expect("HMAC-SHA256 accepts any key length");
53 50 mac.update(message.as_bytes());
54 - let sig = hex::encode(mac.finalize().into_bytes());
55 -
56 - format!("{}/feed/{}?sig={}", host_url, user_id, sig)
51 + hex::encode(mac.finalize().into_bytes())
57 52 }
58 53
59 - /// Verify a personal feed URL signature.
60 - pub fn verify_feed_signature(user_id: crate::db::UserId, signature: &str, secret: &str) -> bool {
61 - use hmac::{Hmac, Mac};
62 - use sha2::Sha256;
63 -
64 - let message = format!("feed:{}", user_id);
65 - let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
66 - .expect("HMAC-SHA256 accepts any key length");
67 - mac.update(message.as_bytes());
68 - let expected = hex::encode(mac.finalize().into_bytes());
54 + /// Generate an HMAC-signed personal RSS feed URL for a user.
55 + ///
56 + /// The signature covers `feed:{user_id}:{version}`. `version` is the user's
57 + /// `feed_key_version`: bumping it (via the dashboard "Regenerate feed URL"
58 + /// action) changes the signed message and revokes the previously-issued URL
59 + /// for that one user, without rotating the global signing secret (which would
60 + /// invalidate every user's feed at once). The URL is otherwise permanent.
61 + pub fn generate_feed_url(
62 + host_url: &str,
63 + user_id: crate::db::UserId,
64 + version: i32,
65 + secret: &str,
66 + ) -> String {
67 + let sig = feed_signature(user_id, version, secret);
68 + format!("{}/feed/{}?v={}&sig={}", host_url, user_id, version, sig)
69 + }
69 70
71 + /// Verify a personal feed URL signature for a given `(user_id, version)`.
72 + ///
73 + /// The caller MUST additionally check that `version` equals the user's current
74 + /// `feed_key_version` — a valid signature for a stale version is a revoked URL.
75 + pub fn verify_feed_signature(
76 + user_id: crate::db::UserId,
77 + version: i32,
78 + signature: &str,
79 + secret: &str,
80 + ) -> bool {
81 + let expected = feed_signature(user_id, version, secret);
70 82 constant_time_compare(&expected, signature)
71 83 }
72 84
@@ -126,47 +138,60 @@
126 138
127 139 // ── feed URL signing ──
128 140
141 + /// Extract the `sig=` value from a generated feed URL.
142 + fn sig_of(url: &str) -> &str {
143 + url.split("sig=").nth(1).unwrap()
144 + }
145 +
129 146 #[test]
130 147 fn feed_url_round_trip() {
131 148 let user_id = crate::db::UserId::new();
132 - let url = generate_feed_url("https://makenot.work", user_id, "secret");
149 + let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
133 150 assert!(url.contains(&user_id.to_string()));
151 + assert!(url.contains("v=0"));
134 152 assert!(url.contains("sig="));
135 - let sig = url.split("sig=").nth(1).unwrap();
136 - assert!(verify_feed_signature(user_id, sig, "secret"));
153 + assert!(verify_feed_signature(user_id, 0, sig_of(&url), "secret"));
137 154 }
138 155
139 156 #[test]
140 157 fn feed_url_wrong_secret_rejected() {
141 158 let user_id = crate::db::UserId::new();
142 - let url = generate_feed_url("https://makenot.work", user_id, "secret");
143 - let sig = url.split("sig=").nth(1).unwrap();
144 - assert!(!verify_feed_signature(user_id, sig, "wrong-secret"));
159 + let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
160 + assert!(!verify_feed_signature(user_id, 0, sig_of(&url), "wrong-secret"));
145 161 }
146 162
147 163 #[test]
148 164 fn feed_url_wrong_user_rejected() {
149 165 let user_id = crate::db::UserId::new();
150 166 let other_id = crate::db::UserId::new();
151 - let url = generate_feed_url("https://makenot.work", user_id, "secret");
152 - let sig = url.split("sig=").nth(1).unwrap();
153 - assert!(!verify_feed_signature(other_id, sig, "secret"));
167 + let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
168 + assert!(!verify_feed_signature(other_id, 0, sig_of(&url), "secret"));
169 + }
170 +
171 + #[test]
172 + fn feed_url_stale_version_rejected() {
173 + // A signature minted for version 0 must not verify against version 1 —
174 + // this is what makes "Regenerate feed URL" revoke the old link.
175 + let user_id = crate::db::UserId::new();
176 + let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
177 + assert!(verify_feed_signature(user_id, 0, sig_of(&url), "secret"));
178 + assert!(!verify_feed_signature(user_id, 1, sig_of(&url), "secret"));
154 179 }
155 180
156 181 #[test]
157 182 fn feed_signature_empty_string_rejected() {
158 183 let user_id = crate::db::UserId::new();
159 - assert!(!verify_feed_signature(user_id, "", "secret"));
184 + assert!(!verify_feed_signature(user_id, 0, "", "secret"));
160 185 }
161 186
162 187 #[test]
163 188 fn feed_signature_tampered_rejected() {
164 189 let user_id = crate::db::UserId::new();
165 - let url = generate_feed_url("https://makenot.work", user_id, "secret");
166 - let sig = url.split("sig=").nth(1).unwrap();
190 + let url = generate_feed_url("https://makenot.work", user_id, 0, "secret");
191 + let sig = sig_of(&url);
167 192 let mut tampered = sig.to_string();
168 193 let first = tampered.remove(0);
169 194 tampered.insert(0, if first == '0' { '1' } else { '0' });
170 - assert!(!verify_feed_signature(user_id, &tampered, "secret"));
195 + assert!(!verify_feed_signature(user_id, 0, &tampered, "secret"));
171 196 }
172 197 }
@@ -454,7 +454,7 @@
454 454 #[test]
455 455 fn result_ext_with_context_wraps_error() {
456 456 let original: std::result::Result<(), std::io::Error> =
457 - Err(std::io::Error::new(std::io::ErrorKind::Other, "boom"));
457 + Err(std::io::Error::other("boom"));
458 458 let wrapped = original.with_context(|| format!("processing item {}", 42));
459 459 assert!(wrapped.is_err());
460 460 let app_err = wrapped.unwrap_err();
@@ -8,7 +8,7 @@
8 8 let bytes = s.as_bytes();
9 9 let mut out = String::with_capacity(bytes.len() + bytes.len() / 3);
10 10 for (i, &b) in bytes.iter().enumerate() {
11 - if i > 0 && (bytes.len() - i) % 3 == 0 {
11 + if i > 0 && (bytes.len() - i).is_multiple_of(3) {
12 12 out.push(',');
13 13 }
14 14 out.push(b as char);
@@ -25,10 +25,13 @@
25 25
26 26 /// Extract the client IP from request headers.
27 27 ///
28 - /// Honors `CF-Connecting-IP` only — the single header Cloudflare sets and that
29 - /// origin clients cannot reach (Hetzner firewall + Caddy strip arbitrary XFF).
30 - /// `X-Forwarded-For` is intentionally not consulted: there is no trusted-proxy
31 - /// allowlist, so any request bypassing Cloudflare could spoof the IP and evade
28 + /// Honors `CF-Connecting-IP` only — and that header is trustworthy on every
29 + /// public path: the makenot.work blocks enforce Cloudflare mTLS (only
30 + /// Cloudflare reaches the origin, and it sets the header), and the custom-domain
31 + /// `:443` block overwrites `CF-Connecting-IP` with the real TCP peer + strips
32 + /// `X-Forwarded-For` before proxying (see `deploy/Caddyfile`). `X-Forwarded-For`
33 + /// is intentionally never consulted: there is no trusted-proxy allowlist, so a
34 + /// request reaching the app with a client-set XFF could spoof the IP and evade
32 35 /// sandbox caps / poison audit logs / forge "new device" notifications.
33 36 ///
34 37 /// Operational guard: in prod, a missing `cf-connecting-ip` means Cloudflare
@@ -38,6 +38,11 @@
38 38 pub mod validation;
39 39 pub mod wordlist;
40 40
41 + // Test-only lint: enforce that every Caddy site block proxying the app declares
42 + // a safe IP-trust posture. Compiled only under `cargo test`.
43 + #[cfg(test)]
44 + mod deploy_lint;
45 +
41 46 use axum::{http::HeaderValue, middleware, Router};
42 47 use std::time::Instant;
43 48 use tower_http::limit::RequestBodyLimitLayer;
@@ -237,14 +242,30 @@
237 242 let headers = response.headers_mut();
238 243
239 244 if is_embed {
240 - // Embed routes: allow framing from any origin
245 + // Embed routes: framable from any origin, but otherwise locked down.
246 + // `frame-ancestors *` alone (the old value) left default-src/script-src
247 + // unrestricted, so an embed XSS would have had no CSP backstop. We keep
248 + // inline script/style (the audio-player embed uses an inline <script> +
249 + // onclick handlers and inline <style>) but block external scripts,
250 + // objects, frames, and connections. Cover images come from S3/CDN over
251 + // https; audio streams from same-origin /api/stream.
241 252 headers.insert(
242 253 axum::http::header::X_FRAME_OPTIONS,
243 254 HeaderValue::from_static("ALLOWALL"),
244 255 );
245 256 headers.insert(
246 257 axum::http::header::HeaderName::from_static("content-security-policy"),
247 - HeaderValue::from_static("frame-ancestors *"),
258 + HeaderValue::from_static(
259 + "default-src 'none'; \
260 + img-src 'self' data: https:; \
261 + media-src 'self'; \
262 + style-src 'unsafe-inline'; \
263 + script-src 'unsafe-inline'; \
264 + font-src 'self'; \
265 + base-uri 'none'; \
266 + form-action 'none'; \
267 + frame-ancestors *",
268 + ),
248 269 );
249 270 } else {
250 271 // Normal routes: deny framing