system/prelaunch-audit-2026-05-24-steffon.md
Independent infrastructure pass on turf-monster (mainnet candidate) plus its blast-radius neighbors (mcritchie-studio SSO hub, studio-engine/solana-studio gems, turf-vault deploy chain). Anchor program logic (Jasper) and Rails authorization/business logic (Carl) are out of scope here.
Block launch. Two Critical findings, four High. The Stripe/Solana boot guards are good; the mainnet runbook is detailed; rack-attack covers the obvious credential-stuffing surface. But the SSO hub cookie is unhardened on the very domain it shares with the money app, config.hosts is unset on both apps (DNS rebinding to webhook endpoints), Sentry is undeployed (no production error visibility on day 1), and CSP is unsafe-inline + unsafe-eval on a real-money wallet UI. Fix C1-C2, H1-H4 before any git push heroku main against mainnet config.
domain: .mcritchie.studio and the same cookie name _studio_session. Each request from a user that touches app.mcritchie.studio (the hub) issues a Set-Cookie: _studio_session=...; Path=/; Domain=.mcritchie.studio with NO Secure, NO HttpOnly, NO SameSite. That re-writes the hardened cookie the turf-monster response set on the prior request. The browser sends the same cookie to turf.mcritchie.studio afterward.document.cookie. (b) Any *.mcritchie.studio subdomain that gets a TLS termination misconfig (or a future http-only marketing subdomain) leaks the cookie in cleartext. (c) Cross-site CSRF defenses degrade to "depends on browser default" - Chrome's default SameSite=Lax mitigates somewhat, Firefox 102+ is similar, but Safari/older browsers vary. None of this should be left to the client.Studio::ErrorHandling docs to make this a hard NEW_APP_SETUP requirement. Studio engine's RUNBOOK.md:55 and NEW_APP_SETUP.md:106-109 already promise "identical session_store.rb config" - the hub is the outlier.ActionDispatch::HostAuthorization, but with config.hosts empty the production default is to accept ANY Host header. (a) An attacker hosts evil.com that rebinds to Heroku's IP at TLS-terminator level via CNAME/TXT trickery and serves XSS-bait that the browser delivers under turf.mcritchie.studio's cookie scope. (b) Webhook hardening (/webhooks/stripe, /webhooks/moonpay) does not protect against forged Host headers - a Stripe-signed payload replayed against the dyno's direct *.herokuapp.com URL bypasses any reverse-proxy / CDN allowlist you might add later. (c) The Sidekiq admin panel under /admin/jobs is mounted in routes.rb and gated by SidekiqAdminMiddleware (session lookup); a SSRF inside the dyno hitting localhost:PORT/admin/jobs with a forged Host has unobstructed access to the Rack stack.
# turf-monster production.rb
config.hosts = [
"turf.mcritchie.studio",
"turf-monster.herokuapp.com", # Heroku health checks
]
config.host_authorization = { exclude: ->(req) { req.path == "/up" } }
Same pattern for mcritchie-studio: app.mcritchie.studio + Heroku internal host.if ENV["SENTRY_DSN"].present? guards the entire init. MAINNET_LAUNCH.md does not list SENTRY_DSN in the step-6 env-var block (lines 148-162).Rails.logger.warn lines vanish with the dyno. ErrorLog.capture! writes to Postgres but has no notification path.window.solana or Alpine.store('session') and silently sign attacker-crafted transactions when the user next confirms a contest entry, or it can read session_token-bound cookies and replay them (only httponly blocks doc.cookie reads - and from C1, the SSO-side cookie isn't httponly anyway).frame-ancestors :none (clickjacking), form-action allow-list, object-src :none, the OPSEC-021 keypair #inspect redaction. Good but secondary.:strict_dynamic + nonces; move the inline selectionBoard() / solanaWalletConnect() / solanaModal blobs (called out in turf-monster CLAUDE.md) into module form by hoisting the Alpine x-data factory into Alpine.data(name, fn) registrations on alpine:init, which doesn't need inline.policy.report_uri "/csp/report" + a Rack endpoint or Sentry report-to-uri) so you can measure where inlines actually live before the nonce refactor. Also remove :unsafe_eval - nothing in the importmap modules or Alpine 3 needs it; verify by setting it to report-only in staging and watching for violations.gem "rack-attack" to mcritchie-studio's Gemfile, port the turf-monster init (drop the Solana / Stripe / faucet blocks), at minimum throttle /login IP + email and /sso_continue IP + email.already_processed? checks for a minted StripePurchase per session. Stripe sends checkout.session.completed exactly-once for a given session, but charge.refunded / charge.dispute.* can be redelivered on retry (Stripe retries up to 3 days). The dispute handler at lines 112-122 isn't idempotent - a redelivered dispute will freeze a user a second time (no-op effectively, but logs a duplicate payment_risk_flag: true write).git log -S sweeps for sk_live_, BEGIN PRIVATE KEY, and MANAGED_WALLET_ENCRYPTION_KEY= returned permission errors in this sandbox. .gitignore correctly excludes .env, .env*, and config/master.key. No .env is currently tracked.
git -C /Users/alex/projects/turf-monster log -p -S 'sk_live_' --all
git -C /Users/alex/projects/turf-monster log -p -S 'BEGIN PRIVATE KEY' --all
git -C /Users/alex/projects/turf-monster log -p -S 'MANAGED_WALLET_ENCRYPTION_KEY=' --all
git -C /Users/alex/projects/turf-monster log -p -S 'helius' --all # paid RPC URLs
git -C /Users/alex/projects/turf-monster log -p -S 'whsec_' --all
Then run the same against mcritchie-studio, studio-engine, solana-studio, turf-vault. Until this completes, M6 stays open.[tokens] webhook.event_payload dump (stripe_controller.rb:38-49) which logs customer_email as a plain field, plus full metadata. customer_email is not in filter_parameters (only the substring :email is, but the validator's Rails.logger.info "validator.ok ..." lines don't pass through parameter_filter - they're direct string interpolation).[tokens] webhook.event_payload dump call - only log opaque IDs, never PII fields.dig +short over the known subdomain list (app., turf., www., api., mail., the Resend _dmarc. and _domainkey. records) and confirm every CNAME resolves to a live resource you control. Document the canonical subdomain inventory in the credentials doc.| Item | State | Notes |
|---|---|---|
| Mainnet RPC URL configured | Not yet - devnet default in Solana::Config:14 | Add boot-time check (L5) |
| Stripe live keys vs test keys | Enforced at boot (stripe.rb:13-15) and at deploy (bin/deploy:108-114) | Solid |
| MoonPay live keys (OPSEC-035) | Deferred; MOONPAY_ENABLED=true will trigger boot-fail-on-missing | M3 still owes the re-fetch validator |
| SENTRY_DSN set | Not set | H1 - block launch |
| ENABLE_TEST_SCAFFOLDING off in prod | Enforced at boot (test_scaffolding_guard.rb:10-13) | Solid |
| Squads mainnet vault deployed | TBD per MAINNET_LAUNCH.md step 0 - operator action | M5 - all 3 keys in operator hands |
| SKIP_IDL_VERIFICATION unset in prod | Enforced at boot + bin/deploy:100-105 | Solid |
| BYPASS_IDL_CHECK not present | Soft (logs warn; no audit) | M4 |
| SOLANA_NETWORK=mainnet-beta alignment | Enforced at boot via genesis hash check (solana_network_alignment.rb) | Solid for code; runbook step 6 sets the vars |
| MANAGED_WALLET_ENCRYPTION_KEY set | Enforced at boot + 1Password backup | Solid |
| config.hosts set | Not set - both apps | C2 - block launch |
| Cookies hardened on SSO hub | Not set on mcritchie-studio | C1 - block launch |
| Rack-attack on SSO hub | Not present | H4 - block launch |
| CSP nonce / strict-dynamic | Inline + unsafe-eval still on | H2 - at least add report-uri before launch |
| Heroku log drain | None documented | H1 |
| Pre-commit secret scan | None installed | M9 |
| Git-history secret sweep | Not run (sandbox blocked) | M6 - operator must run locally |
| turfmonster.media / mcritchie.studio subdomain inventory | Not documented in credentials.md | M8 |
Total: a focused day. Nothing here requires touching Anchor code or business logic.
We emailed a one-tap sign-in link to . It expires shortly and can only be used once.
No email? Check spam, or close this and try again.