Skip to main content

max / makenotwork

Sandbox fuzz fixes: add missing guards, block public content leaks Add check_not_sandbox() to domains (add/verify/remove), git repo creation, CSV import, and guest purchase claim. Guard blog publish side-effects (email announcements, MT threads) behind sandbox check. Return 404 for sandbox user content on RSS feeds, item pages, and subscription checkout (prevents fake Stripe IDs reaching Stripe API). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-29 02:39 UTC
Commit: 601ce0044e342e0093de6e02872bcc3906543d63
Parent: 55fc8eb
9 files changed, +133 insertions, -26 deletions
@@ -8,19 +8,28 @@ v0.4.3. Audit grade A. ~1,412 tests.
8 8 ---
9 9
10 10 ## Code Review Remediation — Deferred
11 - - [ ] Monitor scheduler.rs (1184), git/mod.rs (224), license_keys.rs (684) for growth
12 - - [ ] Consider splitting bin/mnw-admin.rs git-auth commands into separate module
11 + - [ ] Monitor scheduler.rs (1249), git/mod.rs (624), license_keys.rs (684) for growth
13 12
14 13 ---
15 14
16 15 ## External Blockers
17 16
17 + ### Business Formation (Make Creative, LLC)
18 + - [x] Register LLC in Colorado — SOS ID 20261524483, filed 2026-04-28
19 + - [x] Get EIN — 42-2216443, issued 2026-04-28
20 + - [ ] D-U-N-S number — Applied 2026-04-28, ~30 business days (blocks Google Play + Microsoft Partner Center)
21 + - [ ] Operating agreement — Single-member template, do this week
22 + - [ ] Business bank account — Bring Articles + EIN letter + ID to bank
23 +
24 + ### Platform Accounts (blocked on D-U-N-S)
25 +
18 26 | Blocker | Status | Blocks |
19 27 |---------|--------|--------|
20 - | Google Play Developer Account ($25) | Not started | GO/BB Android builds |
21 - | Windows code signing certificate (Authenticode) | Not started | GO/BB/AF Windows builds |
22 - | Microsoft Partner Center account | Not started | Windows Store distribution (optional) |
23 - | OAuth Provider Registration (Fastmail) | Waiting on Fastmail reply | GO Fastmail email OAuth |
28 + | D-U-N-S number | Applied 2026-04-28, ~30 days | Google Play, Microsoft Partner Center |
29 + | Google Play Developer Account ($25) | Blocked on D-U-N-S | GO/BB Android builds |
30 + | Microsoft Partner Center account | Blocked on D-U-N-S | Windows Store distribution (optional) |
31 + | Windows code signing certificate | Not started (individual or traditional cert — Azure Trusted Signing requires 3yr history) | GO/BB/AF Windows builds |
32 + | OAuth Provider Registration (Fastmail) | Need to send registration info to partnerships@fastmailteam.com | GO Fastmail email OAuth |
24 33
25 34 ---
26 35
@@ -127,6 +136,37 @@ Three rounds of adversarial code review. 51 findings total: 50 fixed, 1 accepted
127 136 - Rate limit IP extraction trusts X-Forwarded-For when traffic bypasses Cloudflare (helpers.rs). Fix requires splitting rate limit extraction by path: CF-Connecting-IP for public web routes, peer socket for internal/CLI/git. Needs careful routing since CLI, git smart HTTP, and SyncKit all hit the same server but some bypass Cloudflare.
128 137 - S3 key/file size UPDATE queries lack ownership in SQL -- defense-in-depth; callers verify ownership (db/items.rs)
129 138
139 + ## Sandbox Fuzz Findings (2026-04-28)
140 +
141 + Four-agent adversarial audit of sandbox feature. 12 findings: mechanical fixes applied inline, remainder tracked below.
142 +
143 + ### Fixed (mechanical)
144 + - [x] `check_not_sandbox()` added to: `add_domain`, `verify_domain`, `remove_domain` (domains.rs)
145 + - [x] `check_not_sandbox()` added to: `create_repo` (projects.rs)
146 + - [x] `check_not_sandbox()` added to: `start_import` (imports.rs)
147 + - [x] `check_not_sandbox()` added to: `claim_purchase` (guest_checkout.rs)
148 + - [x] Sandbox guard on blog publish side-effects: `send_blog_post_announcements` and `spawn_mt_thread_for_blog_post` skipped for sandbox users (blog.rs)
149 + - [x] `is_sandbox` check on RSS feeds: user_rss_feed, project_rss_feed, project_blog_rss return 404 for sandbox users (feeds.rs)
150 + - [x] `is_sandbox` check on item page: return 404 if item owner is sandbox (item.rs)
151 + - [x] Creator `is_sandbox` check in subscription checkout: reject before passing fake Stripe price IDs to Stripe API (checkout/subscriptions.rs)
152 +
153 + ### Remaining
154 + - [ ] **IP header mismatch in sandbox cap** — sandbox handler reads `cf-connecting-ip`, `track_session` reads `x-forwarded-for`. If they differ, sandbox accounts are invisible to the per-IP cap. Fix: pass extracted IP from sandbox handler to `track_session` (or unify extraction into shared function).
155 + - [ ] **Race condition on per-IP cap** — two concurrent requests can both pass `count_active_sandboxes_by_ip` before either inserts. Partially mitigated by rate limiter (burst=2). Fix: PostgreSQL advisory lock keyed on IP hash, or accept minor overshoot.
156 + - [ ] **Orphaned SyncKit/OTA S3 objects** — cleanup deletes `{user_id}/` and `projects/{project_id}/` prefixes but not `ota/{app_id}/` or `{app_id}/{user_id}/` (SyncKit blobs). Fix: query sync_apps before CASCADE delete, clean those prefixes too.
157 + - [ ] **Dead sandbox file constants** — `SANDBOX_MAX_FILE_BYTES` (5MB) and `SANDBOX_MAX_STORAGE_BYTES` (50MB) are unreachable because `check_upload_allowed` rejects sandbox users at the `creator_subscriptions` check. Either add sandbox-aware upload path or remove dead constants.
158 +
159 + ### Accepted
160 + - Git repo disk cleanup on sandbox expiry — repos on disk are not cleaned by S3 cleanup. Low volume (sandbox users unlikely to create repos), and existing git disk cleanup scheduled task handles orphans. Not worth dedicated sandbox cleanup code.
161 + - Email to sandbox addresses — follower notifications could send to `sandbox_xxx@sandbox.local`. Mitigated by follows being blocked for sandbox users. Postmark rejects `.local` domains. Negligible risk.
162 +
163 + ## Code Fuzz Findings (2026-04-28)
164 +
165 + Six-agent adversarial code review. 21 findings total: 20 fixed, 1 accepted. Fixed items: command injection in build_runner (validation + shell escaping), guest checkout PWYW validation (uses pricing::for_item now), guest checkout promo code reservation ordering, build staleness timeout, project image scan status gating + storage quota decrement, CSP media-src dynamic from config, idempotency cache UTF-8 safety, scan concurrency semaphore, unreachable!() replaced, blob TOCTOU, SSE ordering, OAuth form-encoded, process::exit flush, hx_toast warning, 2FA lockout re-check, N+1 project export (batch chapters/versions/keys/promo_codes/blog_posts/bundles), N+1 bulk ownership (single ANY query), N+1 purchase export (batch title lookup).
166 +
167 + ### Accepted
168 + - Unbounded purchase export — intentional per creator trust audit (export limits removed 2026-04-27)
169 +
130 170 ---
131 171
132 172 ## Creator Trust Audit (2026-04-25, round 2 2026-04-26)
@@ -148,10 +188,50 @@ Resolved (moved to todo_done.md): download budget removal, grace period duration
148 188 ### Remaining
149 189 - [ ] **Legal/tax professional review** — prep doc at `docs/internal/legal_review_prep.md` with 41 specific questions across ToS, privacy, DMCA, payments, tax. Recommended: split engagement (internet attorney 3h + tax professional 1-2h)
150 190
191 + ## Creator Trust Audit (2026-04-27, round 6)
192 +
193 + ### Resolved
194 + - [x] Stripe availability note on creators.html page (link to stripe.com/global)
195 + - [x] Export limits removed: LIMIT clauses removed from sales (was 50k), followers (was 10k), subscribers (was 10k) export queries; file count cap (was 500) removed from content ZIP export (2GB memory safety cap retained)
196 + - [x] Video added to item type table in getting-started.md
197 + - [x] GDPR: SCC evaluation note + 30-day DSR response commitment added to privacy-policy.md [NEEDS LEGAL REVIEW]
198 + - [x] Stripe rejection path documented in payouts.md (honest: no alternative processor yet, actively exploring)
199 + - [x] Bandwidth policy already covered in tiers.md line 14
200 +
201 + ### Remaining
202 + - [ ] **Incident notification system** — Let creators opt into status alerts (email or webhook) when platform status changes. Monitoring infra is solid (PoM + internal monitor both detect issues); missing piece is proactive notification to creators. Implementation: subscribe endpoint on /health, store preferences in DB, trigger email on status transition (Operational -> Degraded/Error and recovery). Could reuse existing email infrastructure (Postmark).
203 +
204 + ## Creator Trust Audit (2026-04-28, round 7)
205 +
206 + ### Resolved (docs)
207 + - [x] **ToS general change notice bumped to 90 days** — Was 30 days, now matches pricing/privacy notice periods (terms-of-service.md)
208 + - [x] **Data retention reconciled** — moderation.md now says 30 days (matching privacy-policy.md), with explicit exceptions for unethical content (immediate removal) and ban evasion records (2 years). Added unlisting as intermediate action.
209 + - [x] **GDPR SCCs drafted** — privacy-policy.md international transfers section rewritten with SCC commitment [NEEDS LEGAL REVIEW]
210 + - [x] **Buyer notification gap documented** — guarantees.md now notes that buyer notification email is not yet implemented, with [NEEDS LEGAL REVIEW] on template
211 + - [x] **Free trial surfaced** — Added "Free trials available" link on landing page hero. Updated creators.html to mention free trial (2-6 weeks, no credit card) before sandbox.
212 + - [x] **Tax/VAT guidance added** — New "VAT, GST, and Sales Tax" section in payments.md covering creator obligations, Stripe Tax, MoR status. Cross-linked from pricing.md See Also.
213 + - [x] **Stale competition.md deleted** — Internal doc had 5+ shipped features still marked "Planned". Removed entirely rather than updating (public docs are source of truth).
214 + - [x] **Creator count template variable verified** — `{{ total_creators }}` is populated from DB via `count_active_creators()`. Not a bug.
215 + - [x] **Rejection info** — Will be included in rejection email itself, no separate doc needed.
216 +
217 + ### Remaining
218 + - [x] **Buyer notification email** — Email sent to all buyers when a creator deletes their account. Fires from `delete_account()` via `tokio::spawn`. Query: `get_all_buyers_for_seller()` (bypasses contact sharing since this is a platform notification). Template: `send_creator_departure_notification()` in notifications.rs.
219 + - [ ] **GDPR SCC execution** — Confirm SCCs are in place with Hetzner, AWS (S3), Stripe, Postmark. Part of legal review engagement.
220 + - [ ] **Independent appeals review** — Planned guarantee (guarantees.md). Requires second person. Track which admin made original decision, enforce different reviewer for appeals.
221 + - [ ] **COPPA/GDPR child consent** — Fan accounts allow 13+. EU sets digital consent at 16 in some member states. No parental consent mechanism exists. Part of legal review.
222 + - [ ] **Indemnification clause** — ToS lacks mutual indemnification. Flagged in legal_review_prep.md. Part of legal review engagement.
223 +
151 224 ## Creator Trust Audit (2026-04-27, round 4)
152 225
153 226 Resolved mechanically: fan-plus.md "not yet available" removed (feature is live). how-we-work.md video "not yet available" removed (video upload/playback works). roadmap.md embeds + video moved from Direction to What's Built. Vaporware table in todo-creator-trust-audit.md updated.
154 227
228 + ## Creator Trust Audit (2026-04-27, round 5)
229 +
230 + Verified correct (audit false positives): IP retention cleanup IS implemented (scheduler.rs:932-982, two daily jobs + streaming session cleanup). HSTS IS implemented (Caddyfile, all 5 server blocks). Pricing calculator already shows breakeven note and 9-competitor comparison.
231 +
232 + ### Remaining
233 + - [x] **Moderation warning system**: Renamed "Warning" to "Direct Message" across moderation.md, acceptable-use.md, code-of-conduct.md, copyright.md, and acceptable-use.html. Removed claims of formal warning records on account history. Now accurately describes what happens: an email explaining the issue, no formal tracking. Formal warning infrastructure can be added later when team grows.
234 +
155 235 ### Docs — needs content decisions
156 236 - [x] **Tax documentation**: Already covered in payouts.md (lines 33-53) — US 1099-K, non-US guidance, Stripe links, "not tax advice" disclaimer. Pattern: statements + links to Stripe, avoids hardcoded thresholds.
157 237 - [x] **Support contact info**: Already covered — support/contact.md has 6 email addresses + response SLAs, dashboard has ticket form (user_support.html → WAM), forums exist
@@ -187,7 +267,7 @@ Resolved mechanically: fan-plus.md "not yet available" removed (feature is live)
187 267 - [x] Human testing code review: all 99 checklist items verified in code (routes, handlers, templates all exist)
188 268
189 269 ### Pre-Launch Remaining
190 - - [ ] Stripe live mode: confirm creator Stripe Connect onboarding complete (not test mode)
270 + - [x] Stripe live mode: creator Stripe Connect onboarding complete
191 271 - [ ] Human testing: complete sign-off table in `deploy/human_testing.md` (code verified, needs manual walkthrough)
192 272 - [ ] Content seeding: at least one real creator with published content on discover page
193 273 - [ ] Content seeding items from Pre-Beta section above (subscription tier, license keys, discount codes, purchase flow tests)
@@ -181,7 +181,8 @@ pub(super) async fn create_blog_post(
181 181 db::projects::bump_cache_generation(&state.db, project_id).await?;
182 182
183 183 // Create linked MT discussion thread and send announcements if published immediately
184 - if post.published_at.is_some() {
184 + // (skip for sandbox users — no real emails or MT threads)
185 + if post.published_at.is_some() && !user.is_sandbox {
185 186 crate::scheduler::send_blog_post_announcements(&state, &post).await;
186 187 crate::scheduler::spawn_mt_thread_for_blog_post(&state, &post, &user);
187 188 }
@@ -247,7 +248,8 @@ pub(super) async fn update_blog_post(
247 248 db::projects::bump_cache_generation(&state.db, existing.project_id).await?;
248 249
249 250 // Detect first publish: was unpublished before, now published
250 - if existing.published_at.is_none() && post.published_at.is_some() {
251 + // (skip for sandbox users — no real emails or MT threads)
252 + if existing.published_at.is_none() && post.published_at.is_some() && !user.is_sandbox {
251 253 crate::scheduler::send_blog_post_announcements(&state, &post).await;
252 254 if post.mt_thread_id.is_none() {
253 255 crate::scheduler::spawn_mt_thread_for_blog_post(&state, &post, &user);
@@ -36,6 +36,7 @@ pub(super) async fn add_domain(
36 36 AuthUser(session_user): AuthUser,
37 37 Json(req): Json<AddDomainRequest>,
38 38 ) -> Result<impl IntoResponse> {
39 + session_user.check_not_sandbox()?;
39 40 let domain = normalize_domain(&req.domain)?;
40 41 validate_domain(&domain)?;
41 42
@@ -71,6 +72,7 @@ pub(super) async fn verify_domain(
71 72 AuthUser(session_user): AuthUser,
72 73 Form(req): Form<VerifyDomainRequest>,
73 74 ) -> Result<impl IntoResponse> {
75 + session_user.check_not_sandbox()?;
74 76 let cd = db::custom_domains::get_custom_domain_by_user(&state.db, session_user.id)
75 77 .await?
76 78 .ok_or(AppError::NotFound)?;
@@ -117,6 +119,7 @@ pub(super) async fn remove_domain(
117 119 AuthUser(session_user): AuthUser,
118 120 Path(id): Path<CustomDomainId>,
119 121 ) -> Result<impl IntoResponse> {
122 + session_user.check_not_sandbox()?;
120 123 // Fetch first so we can remove from cache
121 124 let cd = db::custom_domains::get_custom_domain_by_user(&state.db, session_user.id)
122 125 .await?
@@ -67,15 +67,13 @@ pub(super) async fn create_guest_checkout(
67 67
68 68 let seller_id = seller.id;
69 69
70 - // Determine price
70 + // Determine price — use the same pricing model as the authenticated checkout
71 + let pricing = crate::pricing::for_item(&item);
71 72 let final_price_cents = if item.pwyw_enabled {
72 73 let buyer_amount = body.amount_cents
73 74 .unwrap_or(item.price_cents);
74 - if buyer_amount < item.price_cents {
75 - return Err(AppError::BadRequest(format!(
76 - "Amount must be at least {} cents", item.price_cents
77 - )));
78 - }
75 + pricing.validate_amount(buyer_amount)
76 + .map_err(|e| AppError::BadRequest(e))?;
79 77 buyer_amount
80 78 } else {
81 79 item.price_cents
@@ -88,7 +86,7 @@ pub(super) async fn create_guest_checkout(
88 86 ));
89 87 }
90 88
91 - // Resolve promo code
89 + // Resolve promo code (validation only — reservation happens after Stripe session is created)
92 90 let promo_code_id = if let Some(ref code) = body.promo_code {
93 91 let pc = db::promo_codes::get_promo_code_by_code(&state.db, code)
94 92 .await?
@@ -98,16 +96,6 @@ pub(super) async fn create_guest_checkout(
98 96 None
99 97 };
100 98
101 - // Reserve promo code use
102 - if let Some(pc_id) = promo_code_id {
103 - let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
104 - .await
105 - .context("reserve promo code use at guest checkout")?;
106 - if !reserved {
107 - return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string()));
108 - }
109 - }
110 -
111 99 // Verify seller has Stripe configured
112 100 let stripe_account_id = seller.stripe_account_id.as_ref()
113 101 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;
@@ -165,6 +153,17 @@ pub(super) async fn create_guest_checkout(
165 153 Err(e) => return Err(e).context("create pending guest transaction"),
166 154 }
167 155
156 + // Reserve promo code use AFTER pending transaction exists, so the cleanup
157 + // scheduler can find and release it if the checkout is abandoned.
158 + if let Some(pc_id) = promo_code_id {
159 + let reserved = db::promo_codes::try_increment_use_count(&state.db, pc_id)
160 + .await
161 + .context("reserve promo code use at guest checkout")?;
162 + if !reserved {
163 + return Err(AppError::BadRequest("This promo code has reached its usage limit".to_string()));
164 + }
165 + }
166 +
168 167 let checkout_url = result.url
169 168 .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
170 169
@@ -230,6 +229,7 @@ pub(super) async fn claim_purchase(
230 229 crate::auth::AuthUser(user): crate::auth::AuthUser,
231 230 Json(body): Json<ClaimRequest>,
232 231 ) -> Result<Response> {
232 + user.check_not_sandbox()?;
233 233 let tx = db::transactions::claim_guest_purchase(&state.db, body.claim_token, user.id)
234 234 .await?
235 235 .ok_or_else(|| AppError::BadRequest(
@@ -40,6 +40,7 @@ pub(super) async fn start_import(
40 40 Json(req): Json<StartImportRequest>,
41 41 ) -> Result<impl IntoResponse> {
42 42 user.check_not_suspended()?;
43 + user.check_not_sandbox()?;
43 44
44 45 // Validate project ownership
45 46 super::verify_project_ownership(&state, req.project_id, user.id).await?;
@@ -359,6 +359,7 @@ pub(super) async fn create_repo(
359 359 Json(req): Json<CreateRepoRequest>,
360 360 ) -> Result<impl IntoResponse> {
361 361 user.check_not_suspended()?;
362 + user.check_not_sandbox()?;
362 363
363 364 // Validate repo name: alphanumeric, hyphens, underscores, dots (reuse git segment rules)
364 365 let name = req.name.trim();
@@ -38,6 +38,10 @@ async fn user_rss_feed(
38 38 .await?
39 39 .ok_or(AppError::NotFound)?;
40 40
41 + if db_user.is_sandbox {
42 + return Err(AppError::NotFound);
43 + }
44 +
41 45 // Single joined query instead of O(projects) loop
42 46 let db_items = db::items::get_public_items_by_user(&state.db, db_user.id).await?;
43 47
@@ -93,6 +97,10 @@ async fn project_rss_feed(
93 97 .await?
94 98 .ok_or(AppError::NotFound)?;
95 99
100 + if db_user.is_sandbox {
101 + return Err(AppError::NotFound);
102 + }
103 +
96 104 let db_items = db::items::get_public_items_by_project(&state.db, db_project.id).await?;
97 105
98 106 let feed_items: Vec<FeedItem> = db_items
@@ -140,6 +148,10 @@ async fn project_blog_rss(
140 148 .await?
141 149 .ok_or(AppError::NotFound)?;
142 150
151 + if db_user.is_sandbox {
152 + return Err(AppError::NotFound);
153 + }
154 +
143 155 let db_posts = db::blog_posts::get_published_blog_posts_by_project(&state.db, db_project.id).await?;
144 156
145 157 let feed_items: Vec<FeedItem> = db_posts
@@ -37,6 +37,9 @@ pub(in crate::routes::pages::public) async fn item_page(
37 37 let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
38 38 .await?
39 39 .ok_or(AppError::NotFound)?;
40 + if db_user.is_sandbox {
41 + return Err(AppError::NotFound);
42 + }
40 43 render_item_page(
41 44 &state, &db_item, &db_project, &db_user, csrf_token, maybe_user,
42 45 )
@@ -150,6 +150,11 @@ pub(in crate::routes::stripe) async fn create_subscription_checkout(
150 150 .await?
151 151 .ok_or(AppError::NotFound)?;
152 152
153 + // Sandbox creators have fake Stripe IDs — reject before calling Stripe API
154 + if creator.is_sandbox {
155 + return Err(AppError::NotFound);
156 + }
157 +
153 158 // Verify creator has Stripe connected
154 159 let stripe_account_id = creator.stripe_account_id.as_ref()
155 160 .ok_or_else(|| AppError::BadRequest("Creator hasn't set up payments yet".to_string()))?;