fix(licensing): wave 2 — session-lookup key leak, expiry enforcement, key-in-logs, +6 - #495
Merged
Merged
Conversation
… and 6 more Deeper adversarial sweep (3 parallel hunters + analysis) of the whole licensing pipeline after wave 1 (#494). Findings, each fixed and tested: SERVER (Cloudflare Pages) - H1 (HIGH) session-lookup returned ANY customer's key with no payment check. GET ?session_id= trusted a session id minted at CREATION (pre-payment) and resolved the customer by buyer-typed email — so starting a checkout with a victim's email, entering it, and abandoning it (no payment) let an attacker read that victim's offline-verifiable key. Now: gate on sessionProvesPurchase (status complete / paid), bind to session.customer (never an email list), and time-box the session id to 24h (sessionWithinWindow). Pure predicates unit-tested. - M4 (MED) no charge.dispute.closed handler → a WON dispute left a paying monthly subscriber terminal forever (isTerminal then blocks every renewal). handleDispute Closed restores 'active' on status 'won', only from the exact 'chargeback' state. - M3 (MED) notify.js was unauthenticated + unmetered → unbounded Stripe customer creation. LICENSE_KV per-IP limiter (20/hr), fail-open, separate key namespace. - L6 strict CSP (default-src 'none', connect-src 'self') + L7 no-referrer on the two key-bearing pages (/activate, /signal/success) so an injected script could never exfiltrate the key and the session-id credential never leaks in Referer. - checkout.js writes signal_tier (was legacy streets_tier) — hygiene. APP (Rust) - Expiry was NEVER enforced + settings.json tamper (MED): has_license_key_available returned true for ANY non-empty license_key, so an EXPIRED 4DA- key kept granting Signal forever (the only auto-downgrade fires on an ABSENT key; validate_license is never auto-called) and pasting tier:"signal"+garbage unlocked Signal. New key_is_usable verifies signature+expiry in the fast path and keychain branch. Makes Terms 4.5 (features unavailable on lapse) true for subscriptions for real. - Recovery command auto-activated a server-returned key with ZERO verification (F3) — a user repointing 4da.ai at their own server could grant any tier. The live server never returns a key here (always 202); dead+dangerous arm removed. - Keygen validation cache is offline-forgeable (F2, honesty-box): key_hash is a bare SHA-256, and the doc comments CLAIMED it was cryptographic. Corrected the false claims; added a named adversarial test pinning the real behaviour. - Trial 45-vs-14 lie: start_trial reported 45 days; gating expires at 14. Now reports the real value from get_trial_status. Verified: 25 Rust license tests pass (3 new), cargo fmt + clippy --lib clean, no size breaches; 64 site lib tests pass (session-predicate + revoked-matrix added). Terms 5.3 reworded to the honest, enforceable statement (offline lifetime keys cannot be technically deactivated today — the lease arc that would change that is the open operator decision in the plan). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RuQRKzs6RawLs5zxxNPBYp
…hunter #2) The deep-link handlers logged the full fourda://activate?key=<SECRET> at info/warn across 9 sites in app_setup.rs and wrote it to the security_events DB table. The production build attaches a daily-rotated file appender at info level, so the self-signed 4DA- key — an offline-verifiable BEARER credential for the Signal tier — was landing in data_dir/logs/*.log and the DB in cleartext, where it rides along in support-bundle zips, cloud-synced profile folders and screen-shares. New utils::redact_deep_link masks the value of any `key=` param (query OR fragment) with <redacted>, keeping host/path/other params for forensics. It is byte-level so an unvalidated (rejected-path) URL carrying hostile multi-byte UTF-8 can never panic on a non-char-boundary slice. Applied at all 6 log sites + 3 security_events writes. Unit-tested (query, fragment, other-params, the "monkey" false-positive, no-key passthrough). Verified: 22 utils::url tests pass (3 new), cargo fmt clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RuQRKzs6RawLs5zxxNPBYp
runyourempire
enabled auto-merge (squash)
August 19, 2026 16:53
runyourempire
added a commit
that referenced
this pull request
Aug 20, 2026
…ired STREETS brand (#497) ## Why `/api/streets/activate` is the production Signal licence endpoint wearing the name of a product retired in June 2026. Every reader — human and agent — kept mistaking the payment path for curriculum leftovers; the operator was one dashboard click from deleting the live Stripe event destination because its URL reads as STREETS junk. The label is the hazard. This PR removes it. ## What - **Canonical route is now `/api/license/activate`** (beside the existing `/api/license/refresh`). `functions/api/streets/activate.js` becomes a 3-line re-export shim: pre-rename desktop builds have the recovery URL baked into the binary, and the Stripe event destination points at the old URL until its one-time dashboard edit. Both routes verified present in the compiled Worker (`wrangler pages functions build`). - **Desktop app recovery URL** (`settings_commands_license.rs`) repointed to the new path. - **`setup-signal.mjs` now checks all SIX handled events** — `charge.refunded`, `charge.dispute.created`, `charge.dispute.closed` were missing from its checklist while the deployed handler (#494/#495) handles them. It also finds the destination on either path. - **`streets.4da.ai` dropped from CORS** — NXDOMAIN (verified against 1.1.1.1); nothing has served from it since STREETS retired. - **Latent e2e bug fixed in passing:** the invalid-tier test POSTed to `/api/streets/checkout`, a route that has never existed on Cloudflare (checkout is `/api/signal/checkout`) — it was asserting against the static 404 page, not the handler's 400 branch. - `'No STREETS license found'` 404 now matches the endpoint's other 404 bodies. - Docs repointed: NETWORK.md (with a legacy-path note for pre-rename builds), DECISIONS.md AD-028 code pointer, validate.yml comment, generate-license.mjs, verify-ed25519-equivalence.mjs. ## What is deliberately NOT touched - **Stripe customer metadata** — the `streets_*` → `signal_*` namespace migration already shipped with a legacy read-fallback in `lib/entitlement.js`; no customer record is affected by this PR. - **`lib/recovery-email.js` historical comment** — file is claimed by a peer lane (`fix-open-code-17-21`); the comment describes the 2026-08-14 era when the old path was canonical, so it stays accurate as written. - **App-internal `streets_engine` / `streets_localization` / `docs/streets`** — retired-curriculum machinery unrelated to payments; renaming the DB column is a schema migration and a separate decision. ## Operator follow-up (after this deploys to 4da.ai) One edit on the existing Stripe event destination (Workbench → Webhooks → ⋯ → Update details): set URL to `https://4da.ai/api/license/activate` and select all six events (`checkout.session.completed`, `invoice.paid`, `customer.subscription.deleted`, `charge.refunded`, `charge.dispute.created`, `charge.dispute.closed`). Same signing secret — no env change, no gap. This simultaneously closes the open P0 (destination only subscribes to 3 of 6 events). If a WAF rate-limit rule was ever created for `/api/streets/activate`, add the new path to it. ## Verification - `wrangler pages functions build` compiles; both `api/license/activate` and `api/streets/activate` present in the bundle - 64/64 site lib tests, 1242/1242 frontend tests, cargo fmt + clippy clean (pre-push gate) - `check-file-sizes.cjs` clean (warnings only, none from this diff) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01U2x3UAWbQvqU2DiQL4pSK9 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
Wave 2 of the licensing-pipeline hardening audit (Wave 1 = #494, merged+deployed). A deeper adversarial sweep — three parallel hunters (site / app / frontend) + direct analysis — found several more issues across the whole pipeline. Every confirmed finding here is fixed and test-covered; two items requiring an app release or a product decision are documented for the operator (not code-fixable this pass).
Fixed & verified
Server (Cloudflare Pages)
GET ?session_id=trusted a session id minted at creation (pre-payment) and resolved the customer by buyer-typed email, so starting a checkout with a victim's email and abandoning it (no payment) let an attacker read that victim's offline-verifiable key. Now: gate onsessionProvesPurchase(status complete / paid), bind tosession.customer(never an email list), and time-box the session id to 24h (sessionWithinWindow). Pure predicates unit-tested.charge.dispute.closedhandler. A won dispute left a paying monthly subscriber terminal forever (the isTerminal guard then blocks every renewal).handleDisputeClosedrestoresactiveonstatus === 'won', only from the exactchargebackstate.notify.jsunauthenticated + unmetered → unbounded Stripe customer creation. LICENSE_KV per-IP limiter (20/hr), fail-open, separate key namespace.default-src 'none',connect-src 'self') on the two key-bearing pages so an injected script can't exfiltrate the key, and the session-id credential never leaks in a Referer.signal_tier(was legacystreets_tier) — hygiene.App (Rust)
fourda://activate?key=<SECRET>was logged at info/warn across 9 sites and written to thesecurity_eventsDB, so the bearer key landed indata_dir/logs/*.login cleartext (support bundles, cloud-synced folders). Newutils::redact_deep_link(byte-safe vs hostile UTF-8) masks thekey=value everywhere.has_license_key_availablereturned true for any non-empty key, so an expired4DA-key kept granting Signal forever (a cancelled monthly subscriber never downgraded) and pastingtier:"signal"+garbage unlocked Signal. Newkey_is_usableverifies signature and expiry. Makes Terms §4.5 true for subscriptions for the first time.4da.aiwere repointed).start_trialnow reports the real value fromget_trial_status.Verified
cargo fmt+clippy --libclean, no size breaches.Documented for the operator (not code-fixable this pass)
🤖 Generated with Claude Code
https://claude.ai/code/session_01RuQRKzs6RawLs5zxxNPBYp