Skip to main content

max / makenotwork

Harden server across 5 audit axes (ultra-fuzz Run #19 remediation) Drive every audit axis to A. Highlights: UX/theming: fix a real stored-XSS in custom pages — lightningcss does not escape `<`, so `content:"</style><script>"` reached the page; escape `<` to `\3c ` in the sanitized sheet. Drop non-hex base intents in theme-common. Security: replace the replayable HMAC password-reset link with a single-use DB token (migration 140), mirroring login_tokens; derive lockout `just_locked` from a CTE so re-locks notify; add an `aud` claim to SyncKit JWTs. Performance: contain CPU-layer panics so a crafted upload can't kill the scan pool (fail closed to HeldForReview); route >8 GB uploads to review instead of stranding them Pending; unify spool tempfile cleanup; background the admin shutdown fan-out; continuously delete expired tower_sessions rows; rate-limit the /health endpoints; prune password-reset tokens. Payments: resolve the recurring epoch-period bug structurally — the five subscription/billing writers now take the raw Stripe period and own the end>0 filter + conversion, so a handler cannot construct an epoch DateTime. Move webhook dedup to after successful processing (v1 and v2) so a crash can't strand an event. Ticket checkout amount mismatches; pass base price to promos. Storage: make custom-page publish transactional; mark draft previews no-store; correct the presign doc and clamp presign expiry; bind the draft-cleanup interval; add a (item_id, created_at) version index (migration 141).
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-13 20:36 UTC
Signed with PGP, not checked
Commit: af2c94fc239e78b91681c5a8c553972c654b0c85
Parent: f3a0f76
37 files changed, +1107 insertions, -594 deletions
@@ -118,6 +118,25 @@
118 118 .await
119 119 .expect("Failed to migrate session store");
120 120
121 + // Continuously delete expired rows from the tower-sessions table. A session
122 + // row is minted on every anonymous page render, so without this the table
123 + // grows without bound under bot/crawler traffic and is read on every
124 + // request. (The app's own `user_sessions` table is pruned by the daily
125 + // scheduler; this covers the tower-sessions store, which the scheduler does
126 + // not own.) Runs hourly on a background task.
127 + {
128 + use tower_sessions::ExpiredDeletion;
129 + let deletion_store = session_store.clone();
130 + tokio::task::spawn(async move {
131 + if let Err(e) = deletion_store
132 + .continuously_delete_expired(tokio::time::Duration::from_secs(3600))
133 + .await
134 + {
135 + tracing::error!(error = ?e, "tower-sessions expired-deletion task exited");
136 + }
137 + });
138 + }
139 +
121 140 // In release mode, require HTTPS for session cookies (override with INSECURE_COOKIES=1 for staging)
122 141 let secure_cookies =
123 142 !cfg!(debug_assertions) && std::env::var("INSECURE_COOKIES").unwrap_or_default() != "1";
@@ -14,6 +14,11 @@
14 14 /// Issuer claim value for all SyncKit JWTs.
15 15 const SYNCKIT_JWT_ISSUER: &str = "makenotwork-synckit";
16 16
17 + /// Audience claim value for all SyncKit JWTs. Pinning `aud` (in addition to
18 + /// `iss`) means a token signed with this secret for any other purpose can never
19 + /// be replayed against the sync API, even if the secret were ever shared.
20 + const SYNCKIT_JWT_AUDIENCE: &str = "makenotwork-synckit-clients";
21 +
17 22 /// JWT claims for SyncKit tokens.
18 23 #[derive(Debug, Serialize, Deserialize)]
19 24 pub struct SyncClaims {
@@ -27,6 +32,8 @@
27 32 pub key: String,
28 33 /// Issuer
29 34 pub iss: String,
35 + /// Audience
36 + pub aud: String,
30 37 /// Expiration (Unix timestamp)
31 38 pub exp: i64,
32 39 /// Issued at (Unix timestamp)
@@ -46,6 +53,7 @@
46 53 app: app_id,
47 54 key: key.to_string(),
48 55 iss: SYNCKIT_JWT_ISSUER.to_string(),
56 + aud: SYNCKIT_JWT_AUDIENCE.to_string(),
49 57 exp: now + SYNCKIT_JWT_EXPIRY_SECS,
50 58 iat: now,
51 59 };
@@ -72,6 +80,7 @@
72 80 pub fn decode_sync_token(secret: &str, token: &str) -> Result<SyncClaims, AppError> {
73 81 let mut validation = Validation::new(Algorithm::HS256);
74 82 validation.set_issuer(&[SYNCKIT_JWT_ISSUER]);
83 + validation.set_audience(&[SYNCKIT_JWT_AUDIENCE]);
75 84
76 85 let data = decode::<SyncClaims>(
77 86 token,
@@ -195,6 +204,7 @@
195 204 app: app_id,
196 205 key: TEST_KEY.to_string(),
197 206 iss: SYNCKIT_JWT_ISSUER.to_string(),
207 + aud: SYNCKIT_JWT_AUDIENCE.to_string(),
198 208 exp: now - 3600, // expired 1 hour ago
199 209 iat: now - 7200,
200 210 };
@@ -251,6 +261,7 @@
251 261 app: app_id,
252 262 key: TEST_KEY.to_string(),
253 263 iss: "wrong-issuer".to_string(),
264 + aud: SYNCKIT_JWT_AUDIENCE.to_string(),
254 265 exp: now + SYNCKIT_JWT_EXPIRY_SECS,
255 266 iat: now,
256 267 };
@@ -265,6 +276,29 @@
265 276 assert!(decode_sync_token(TEST_SECRET, &token).is_err());
266 277 }
267 278
279 + #[test]
280 + fn wrong_audience_rejected() {
281 + // A token correctly signed and issued but minted for a different
282 + // audience must not authenticate against the sync API.
283 + let now = chrono::Utc::now().timestamp();
284 + let claims = SyncClaims {
285 + sub: UserId::new(),
286 + app: SyncAppId::new(),
287 + key: TEST_KEY.to_string(),
288 + iss: SYNCKIT_JWT_ISSUER.to_string(),
289 + aud: "some-other-audience".to_string(),
290 + exp: now + SYNCKIT_JWT_EXPIRY_SECS,
291 + iat: now,
292 + };
293 + let token = encode(
294 + &Header::default(),
295 + &claims,
296 + &EncodingKey::from_secret(TEST_SECRET.as_bytes()),
297 + )
298 + .unwrap();
299 + assert!(decode_sync_token(TEST_SECRET, &token).is_err());
300 + }
301 +
268 302 #[test]
269 303 fn missing_claims_rejected() {
270 304 use serde::Serialize;
@@ -373,6 +407,7 @@
373 407 app: app_id,
374 408 key: TEST_KEY.to_string(),
375 409 iss: SYNCKIT_JWT_ISSUER.to_string(),
410 + aud: SYNCKIT_JWT_AUDIENCE.to_string(),
376 411 exp: now + SYNCKIT_JWT_EXPIRY_SECS,
377 412 iat: now + 86400 * 365, // 1 year in the future
378 413 };
@@ -401,6 +436,7 @@
401 436 app: app_id,
402 437 key: TEST_KEY.to_string(),
403 438 iss: SYNCKIT_JWT_ISSUER.to_string(),
439 + aud: SYNCKIT_JWT_AUDIENCE.to_string(),
404 440 exp: now + SYNCKIT_JWT_EXPIRY_SECS,
405 441 iat: now + 30, // within the 60s skew window
406 442 };