Skip to main content

max / makenotwork

Remediate audit Run 21 findings across server Fix all 10 highest-blast-radius findings plus a large fix-soon/cleanup tail from the Run 21 audit; full suite stays green (2,998 tests). Fix-first: - SyncKit: create the sync_app_usage_current row transactionally on app create + backfill migration 165; post-117 apps no longer 500 on billing. - Blog: auto-save no longer unpublishes live posts (publish state is a no-change Option the client omits). - Payments: reverse settled Fan+ platform credits on refund (migration 166 + TransferReversal + scheduler sweep); founder checkout never crosses billing cadence; mixed-cart promo reserves before granting free lines; tip orphan escalation; webhook retry worker routes through the per-event lock + dedup. - Content exposure: purchase_page visibility gate; followed feeds apply the full discover gate; re-subscribe restores only suspension-hidden items (migration 164), not genuine drafts. - SyncKit push: per-batch advisory lock restores the idempotency backstop migration 086 dropped. - Embed player CSP script-src 'self'. - DB statement/lock timeouts + scheduler tick watchdog. - Import pipeline: batched tag resolution + bounded bg dispatch. - Uploads: cap browser single-PUT at 5 GiB (large files go via CLI/desktop). Fix-soon/cleanup: license_text visibility gate, CSS expression() strip, pricing-seal widening, mnw-admin UTF-8/CSV hardening, admin audit-log logging, misattached tracing span, item-wizard GET draft reuse, tier parse-swallow, register_device TOCTOU, admin active-state CSS, global- script cleanup, tag-count visibility, collections owner-scope, and more. Surfaced items: email-keyed mailing-list unsubscribe (imported subscribers now reachable + CAN-SPAM compliant); POST /api/v1/keys/status (key out of URLs, GET deprecated); Postmark patch self-MessageID short-circuit + corrected external_ref comment (MT dedups via unique indexes).
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-04 16:08 UTC
Signed with PGP, not checked
Commit: 050368af60f22a358b852b8f7381ff67069df1e7
Parent: 0df7c95
76 files changed, +1382 insertions, -309 deletions
@@ -142,7 +142,7 @@
142 142 async-stripe-shared = { version = "1.0.0-rc.6", features = ["deserialize"] }
143 143 async-stripe-billing = { version = "1.0.0-rc.6", features = ["deserialize", "subscription", "billing_portal_session"] }
144 144 async-stripe-checkout = { version = "1.0.0-rc.6", features = ["deserialize", "checkout_session"] }
145 - async-stripe-connect = { version = "1.0.0-rc.6", features = ["deserialize", "account", "account_link", "transfer"] }
145 + async-stripe-connect = { version = "1.0.0-rc.6", features = ["deserialize", "account", "account_link", "transfer", "transfer_reversal"] }
146 146 async-stripe-core = { version = "1.0.0-rc.6", features = ["deserialize", "balance", "refund", "customer"] }
147 147 async-stripe-payment = { version = "1.0.0-rc.6", features = ["deserialize"] }
148 148 async-stripe-product = { version = "1.0.0-rc.6", features = ["deserialize", "product", "price"] }
@@ -224,9 +224,15 @@
224 224 let stripe = StripeConfig::from_env();
225 225
226 226 // Load admin user ID - optional, if unset admin routes return 404
227 - let admin_user_id = std::env::var("ADMIN_USER_ID")
228 - .ok()
229 - .and_then(|s| s.parse::<UserId>().ok());
227 + let admin_user_id = std::env::var("ADMIN_USER_ID").ok().and_then(|s| {
228 + s.parse::<UserId>()
229 + .map_err(|_| {
230 + tracing::warn!(
231 + "ADMIN_USER_ID is set but is not a valid UserId — ignoring it; admin routes will return 404"
232 + );
233 + })
234 + .ok()
235 + });
230 236
231 237 // SyncKit JWT secret - optional, sync endpoints return 503 if unset.
232 238 // When set it IS the HS256 symmetric signing key for SyncKit/OAuth
@@ -562,10 +568,17 @@
562 568 .unwrap_or(true),
563 569 abuse_ch_auth_key: std::env::var("ABUSE_CH_AUTH_KEY").ok().filter(|s| !s.is_empty()),
564 570 metadefender_api_key: std::env::var("METADEFENDER_API_KEY").ok().filter(|s| !s.is_empty()),
565 - yara_min_rule_files: std::env::var("YARA_MIN_RULE_FILES")
566 - .ok()
567 - .and_then(|v| v.parse().ok())
568 - .unwrap_or(DEFAULT_YARA_MIN_RULE_FILES),
571 + yara_min_rule_files: match std::env::var("YARA_MIN_RULE_FILES") {
572 + Ok(v) => v.parse().unwrap_or_else(|_| {
573 + tracing::warn!(
574 + value = %v,
575 + "YARA_MIN_RULE_FILES is set but is not a valid number — using default {}",
576 + DEFAULT_YARA_MIN_RULE_FILES
577 + );
578 + DEFAULT_YARA_MIN_RULE_FILES
579 + }),
580 + Err(_) => DEFAULT_YARA_MIN_RULE_FILES,
581 + },
569 582 })
570 583 }
571 584 }
@@ -12,6 +12,27 @@
12 12 pub const DB_MAX_LIFETIME_SECS: u64 = 1800;
13 13 /// Prune idle connections after 10 minutes.
14 14 pub const DB_IDLE_TIMEOUT_SECS: u64 = 600;
15 + /// Per-statement wall-clock ceiling, applied to every pooled connection via
16 + /// `SET statement_timeout`. `acquire_timeout` only bounds *getting* a
17 + /// connection, not *running* a query — without this a single wedged query pins
18 + /// its connection forever and 25 of them exhaust the pool with no recovery.
19 + /// Generous so legitimate maintenance sweeps and boot migrations aren't killed;
20 + /// a job that genuinely needs longer sets `SET LOCAL statement_timeout = 0` in
21 + /// its own transaction.
22 + pub const DB_STATEMENT_TIMEOUT_SECS: u64 = 120;
23 + /// Ceiling on how long a statement waits to acquire a lock (`SET lock_timeout`).
24 + /// A lock-contended query fails fast instead of blocking a connection
25 + /// indefinitely — the most common way the pool wedges.
26 + pub const DB_LOCK_TIMEOUT_SECS: u64 = 30;
27 +
28 + /// Largest object a single presigned `PutObject` can carry. S3 / Ceph (Hetzner
29 + /// Object Storage) reject a single PUT above 5 GiB — larger objects require
30 + /// multipart. The browser upload paths issue exactly one presigned PUT, so this
31 + /// bounds them regardless of the (higher) per-tier `max_file_bytes`; files above
32 + /// it upload through the CLI / desktop clients, which chunk them. Keeping the
33 + /// browser cap here means a too-big browser upload is refused up front with a
34 + /// clear pointer rather than handed a presigned URL that fails at S3.
35 + pub const S3_SINGLE_PUT_MAX_BYTES: u64 = 5 * 1024 * 1024 * 1024;
15 36
16 37 /// Lifetime of an internal-API actor assertion, minted at `ssh-key-lookup` and
17 38 /// forwarded by the CLI for the session. 24h comfortably exceeds any SSH session.
@@ -772,44 +772,66 @@
772 772
773 773 #[test]
774 774 fn no_ad_hoc_cents_to_dollars_conversion() {
775 - let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
776 - let this_file = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/formatting.rs");
775 + let root = Path::new(env!("CARGO_MANIFEST_DIR"));
776 + let this_file = root.join("src/formatting.rs");
777 777 let mut offenders = Vec::new();
778 - walk(&src_dir, &mut |path, contents| {
779 - // This module documents the banned idiom; skip it.
778 +
779 + // The float cents→dollars idiom (rounding-risk) in ANY form, in Rust —
780 + // the raw cast `as f64 / 100.0` AND the newtype accessor `.as_f64() / 100`
781 + // that slipped past the earlier `as f64` pattern (the seal's own blind
782 + // spot; `src/bin/` and the exports used it). Integer `cents / 100` manual
783 + // formatting is a separate style item, not this float-drift seal.
784 + let mut rust_check = |path: &Path, contents: &str| {
780 785 if path == this_file {
781 786 return;
782 787 }
783 788 for (i, line) in contents.lines().enumerate() {
784 - // Comment/doc lines may legitimately mention the idiom.
785 789 if line.trim_start().starts_with("//") {
786 790 continue;
787 791 }
788 - // Normalize whitespace so spacing variations all match.
789 792 let squished: String = line.chars().filter(|c| !c.is_whitespace()).collect();
790 - if squished.contains("asf64/100") {
793 + if squished.contains("asf64/100") || squished.contains("as_f64()/100") {
791 794 offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
792 795 }
793 796 }
794 - });
797 + };
798 + walk(&root.join("src"), "rs", &mut rust_check);
799 +
800 + // Templates must never do cents math — prices arrive pre-formatted from
801 + // Rust. A `{{ price_cents / 100 }}` bypasses the centralized formatters in
802 + // the one surface the original seal never scanned.
803 + let mut template_check = |path: &Path, contents: &str| {
804 + for (i, line) in contents.lines().enumerate() {
805 + if line.trim_start().starts_with("{#") {
806 + continue;
807 + }
808 + let squished: String = line.chars().filter(|c| !c.is_whitespace()).collect();
809 + if squished.contains("cents/100") || squished.contains("asf64/100") {
810 + offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
811 + }
812 + }
813 + };
814 + walk(&root.join("templates"), "html", &mut template_check);
815 +
795 816 assert!(
796 817 offenders.is_empty(),
797 818 "pricing-format seal violated — convert cents to dollars via \
798 - formatting::format_price / format_revenue / format_dollars_plain, never a raw \
799 - `as f64 / 100.0`. Offending lines:\n{}",
819 + formatting::format_price / format_revenue / format_dollars_plain (never a raw \
820 + `as f64 / 100.0` or `.as_f64() / 100` in Rust, never cents math in a template). \
821 + Offending lines:\n{}",
800 822 offenders.join("\n")
801 823 );
802 824 }
803 825
804 - fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) {
826 + fn walk(dir: &Path, ext: &str, f: &mut impl FnMut(&Path, &str)) {
805 827 let Ok(entries) = std::fs::read_dir(dir) else {
806 828 return;
807 829 };
808 830 for entry in entries.flatten() {
809 831 let path = entry.path();
810 832 if path.is_dir() {
811 - walk(&path, f);
812 - } else if path.extension().is_some_and(|e| e == "rs")
833 + walk(&path, ext, f);
834 + } else if path.extension().is_some_and(|e| e == ext)
813 835 && let Ok(contents) = std::fs::read_to_string(&path)
814 836 {
815 837 f(&path, &contents);
@@ -345,11 +345,15 @@
345 345 headers.insert(
346 346 axum::http::header::HeaderName::from_static("content-security-policy"),
347 347 HeaderValue::from_static(
348 + // The embedded player loads an external same-origin script
349 + // (/static/embed-item-player.js) and carries no inline handlers,
350 + // so scripts need 'self', not 'unsafe-inline' (which would
351 + // silently block the external file and leave the player dead).
348 352 "default-src 'none'; \
349 353 img-src 'self' data: https:; \
350 354 media-src 'self'; \
351 355 style-src 'unsafe-inline'; \
352 - script-src 'unsafe-inline'; \
356 + script-src 'self'; \
353 357 font-src 'self'; \
354 358 base-uri 'none'; \
355 359 form-action 'none'; \
@@ -78,6 +78,13 @@
78 78 .log_statements(log::LevelFilter::Trace)
79 79 .log_slow_statements(log::LevelFilter::Warn, Duration::from_millis(100));
80 80
81 + // Bound every query on every pooled connection: `statement_timeout` caps
82 + // run time so a wedged query can't pin its connection forever (25 of those
83 + // would exhaust the pool with no recovery — acquire_timeout only bounds
84 + // getting a connection, not running one), and `lock_timeout` fails a
85 + // lock-contended query fast instead of blocking indefinitely.
86 + let statement_timeout_ms = constants::DB_STATEMENT_TIMEOUT_SECS * 1000;
87 + let lock_timeout_ms = constants::DB_LOCK_TIMEOUT_SECS * 1000;
81 88 let db = PgPoolOptions::new()
82 89 .max_connections(constants::DB_POOL_MAX_CONNECTIONS)
83 90 .min_connections(constants::DB_POOL_MIN_CONNECTIONS)
@@ -85,6 +92,19 @@
85 92 .max_lifetime(Duration::from_secs(constants::DB_MAX_LIFETIME_SECS))
86 93 .idle_timeout(Duration::from_secs(constants::DB_IDLE_TIMEOUT_SECS))
87 94 .test_before_acquire(true)
95 + .after_connect(move |conn, _meta| {
96 + Box::pin(async move {
97 + use sqlx::Executor;
98 + conn.execute(
99 + format!(
100 + "SET statement_timeout = {statement_timeout_ms}; SET lock_timeout = {lock_timeout_ms}"
101 + )
102 + .as_str(),
103 + )
104 + .await?;
105 + Ok(())
106 + })
107 + })
88 108 .connect_with(connect_options)
89 109 .await
90 110 .expect("Failed to connect to database");
@@ -22,6 +22,7 @@
22 22 // License Keys
23 23 crate::routes::api::license_keys::validate_key,
24 24 crate::routes::api::license_keys::deactivate_key,
25 + crate::routes::api::license_keys::key_status_post,
25 26 crate::routes::api::license_keys::key_status,
26 27 crate::routes::api::license_keys::license_verify,
27 28 crate::routes::api::license_keys::license_deactivate,
@@ -63,6 +64,7 @@
63 64 crate::routes::api::license_keys::ValidateKeyLicense,
64 65 crate::routes::api::license_keys::DeactivateKeyRequest,
65 66 crate::routes::api::license_keys::DeactivateKeyResponse,
67 + crate::routes::api::license_keys::KeyStatusRequest,
66 68 crate::routes::api::license_keys::KeyStatusResponse,
67 69 crate::routes::api::license_keys::KeyStatusLicense,
68 70 crate::routes::api::license_keys::LicenseVerifyRequest,