fix(licensing): close the four live holes in the payment/recovery path - #494
Merged
Conversation
Professional-licensing hardening arc, server side. Four confirmed defects, each now closed and pinned by dependency-free tests: 1. Session-id leak to PostHog (HIGH): /signal/success loaded analytics while its URL carries ?session_id=cs_... — and that session id is a BEARER CREDENTIAL (GET /api/streets/activate?session_id= returns the licence key to whoever holds it). Every purchase shipped that credential to a third party in $current_url, and session replay would also record the rendered key. The page now sets noAnalytics, same as /activate. 2. Webhook event-id dedup (HIGH): a duplicate Stripe delivery of checkout.session.completed / invoice.paid minted ANOTHER valid signed key. New LICENSE_KV namespace (prod 33eb6985, preview 83ffb6f2, bound in wrangler.toml) gates dispatch on event.id — recorded only AFTER a handler succeeds so failures stay retryable, TTL 7d (outlives Stripe retries), and FAIL OPEN so a KV outage degrades to yesterday's behaviour, never a dropped paid event. 3. Recovery metering (MED): the unauthenticated ?email= path was unmetered — ~100 requests for ONE known customer address exhausts the Resend 100/day free-tier quota, silencing the licence-delivery mail of every real purchase that day (denial of licence delivery, not just inbox nuisance). Now: 30 req/IP/hour (honest 429, depends only on caller behaviour) and 5 mails/address/day enforced INSIDE the deferred delivery, after the constant 202 — response body and timing stay identical in every case, so the customer-existence oracle stays closed. Both fail open. 4. Revoked keys were still re-delivered (MED): recovery mail and the session-id lookup returned the key regardless of terminal status, quietly handing a refunded/charged-back customer back the exact access the refund ended (keys verify offline; nothing revokes a re-delivered copy). New entitlement.isRevoked() — deliberately STRICTER than isTerminal: refund/ chargeback block re-delivery with an honest "no longer active" notice, while a CANCELLED subscriber keeps retrieval until the key's own expiry, because that paid tail is the policy being sold. Tests: +21 across abuse-guards.test.mjs (new, wired into test:scripts), entitlement.test.mjs and license-email.test.mjs — 62 passing. 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 02:54
runyourempire
added a commit
that referenced
this pull request
Aug 19, 2026
… key-in-logs, +6 (#495) ## 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) - **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 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 (the isTerminal guard then blocks every renewal). `handleDisputeClosed` restores `active` on `status === 'won'`, only from the exact `chargeback` state. - **M3 (MED) — `notify.js` unauthenticated + unmetered** → unbounded Stripe customer creation. LICENSE_KV per-IP limiter (20/hr), fail-open, separate key namespace. - **CSP + no-referrer (L6/L7)** — strict CSP (`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. - **checkout.js** writes `signal_tier` (was legacy `streets_tier`) — hygiene. ### App (Rust) - **Key-in-production-logs (MED)** — the full `fourda://activate?key=<SECRET>` was logged at info/warn across 9 sites and written to the `security_events` DB, so the bearer key landed in `data_dir/logs/*.log` in cleartext (support bundles, cloud-synced folders). New `utils::redact_deep_link` (byte-safe vs hostile UTF-8) masks the `key=` value everywhere. - **Expiry never enforced + settings.json tamper (MED)** — `has_license_key_available` returned true for any non-empty key, so an *expired* `4DA-` key kept granting Signal forever (a cancelled monthly subscriber never downgraded) and pasting `tier:"signal"`+garbage unlocked Signal. New `key_is_usable` verifies signature **and** expiry. Makes Terms §4.5 true for subscriptions for the first time. - **Recovery auto-activated an unverified key (F3)** — removed (the live server never returns a key here; it was dead + dangerous if `4da.ai` were repointed). - **Forgeable Keygen cache (F2)** — the doc comments claimed it was cryptographic; it isn't (bare SHA-256). Corrected the claims + added a named adversarial test pinning the real honesty-box behaviour. - **Trial 45-vs-14 lie** — `start_trial` now reports the real value from `get_trial_status`. - **Terms §5.3** reworded from the impossible "the licence is deactivated" to the enforceable "the licence terminates; continued use is not permitted." ## Verified - **Rust**: 25 license tests + 22 url tests pass (6 new), `cargo fmt` + `clippy --lib` clean, no size breaches. - **Site**: 64 lib tests pass (session-predicate + revoked-matrix added). ## Documented for the operator (not code-fixable this pass) - **Deep-link activation consent (MED)** — a website can silently swap a paying user's licence for the attacker's own valid key (DoS/griefing). The fix is a UI consent flow, and the project's verification rule requires UI/entitlement changes to be tested against the running app; deferred as a focused, live-verified follow-up with the design ready (email-match refusal + confirmation modal). This is the top open security item. - **Lease-client arc** — real lifetime-refund revocation + killing the monthly re-paste. Needs an app release + a NETWORK.md/ADR decision. - **Signal-feed "isPro" gate** — per tier-truth #473 that feed is free-floor, so this is a vestigial sales-tease, NOT a data leak (all actually-sold features enforce server-side). Product call, not a patch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01RuQRKzs6RawLs5zxxNPBYp --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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>
runyourempire
added a commit
that referenced
this pull request
Aug 21, 2026
…that lied (#500) ## Finishing the two hand-offs from #499 Both files were **claimed in flight by peer lanes** when the surrounding work landed, so they were documented and deliberately left alone rather than handing an active peer a mid-flight merge conflict. Both lanes have since released them. ### Badge overflow `Subscription renewed` is **20 characters** against a ~16 budget, and the email header has no media query to save it — Gmail strips `<style>` in several contexts, so the three header cells must fit unaided on a 320px phone. It overflowed, wrapped the row and squeezed the wordmark. Now **`Renewal`**. The test pinning the old string failed, which is exactly what it was for. Rather than just swap the literal, the assertion now checks the **budget**: it extracts whatever badge was rendered and fails if it exceeds `BADGE_MAX_CHARS`. The next person who writes a more descriptive badge fails *here* instead of in a customer's inbox. **Revert-checked** — restoring `Subscription renewed` makes it fail, naming both the offending string and its length. ### A comment that lied `license/activate.js` said *"there is no event-id dedup store"* while dedup has existed since **#494** — twenty lines from the `LICENSE_KV` call that implements it. **#497** moved the file and carried the wrong comment along with it. The fix is deliberately **not deletion**. The guard that comment sits above is still load-bearing, and saying so is the whole point: - **dedup** suppresses a *redelivery of the same event id* - **this guard** catches a genuinely *new* `invoice.paid` arriving after a refund or dispute Two different defences against two different failures. The comment now says which is which, instead of denying that the other one exists. ## Verification **212 script tests pass.** The badge guard is revert-checked in both directions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Fq96xWyPQjx2bCCzWtsnC9
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
The server-side arc of the professional-licensing hardening audit (2026-08-19). A full sweep of the licensing pipeline — checkout → webhook → key mint → delivery email → success page → recovery → refund/chargeback — found four live defects. All four are closed here, each pinned by dependency-free tests that run in the
repo-guardsCI job.The four holes
1. Checkout session ids leaked to PostHog (HIGH)
/signal/successloaded PostHog while its URL carries?session_id=cs_.... That session id is a bearer credential:GET /api/streets/activate?session_id=returns the licence key to whoever holds it. Every purchase shipped that credential to a third party inside$current_url— and the page renders the key itself in the DOM, which session replay would record. The page now setsnoAnalytics: true, same reasoning as/activate.2. Duplicate Stripe deliveries minted extra keys (HIGH)
checkout.session.completed/invoice.paidmint a new signed key on every run, and Stripe re-sends event ids routinely. New LICENSE_KV namespace (prod33eb6985..., preview83ffb6f2..., bound insite/wrangler.toml) gates webhook dispatch onevent.id:3. Recovery was unmetered — a denial-of-licence-delivery vector (MED)
The unauthenticated
?email=recovery path could be looped: ~100 requests for one known customer address exhausts the Resend free tier's 100/day quota, silencing the licence-delivery mail of every real purchase that day. Now metered via LICENSE_KV:4. Refunded/charged-back customers could still retrieve their key (MED)
Recovery mail and the session-id lookup returned the key regardless of terminal status — quietly handing back the exact access a refund ended (keys verify offline; nothing revokes a re-delivered copy). New
entitlement.isRevoked(), deliberately stricter thanisTerminal:/signal); session lookup answers 410;Tests
+21 new assertions:
site/lib/abuse-guards.test.mjs(new, wired intotest:scripts),entitlement.test.mjs(isRevoked semantics incl. legacystreets_prefix),license-email.test.mjs(fulldeliverRecoveryEmailmatrix: active/refunded/chargeback/cancelled/legacy/expired/unknown/stripe-error). 62 passing locally.What this deliberately does NOT change
/api/license/refreshcurrently has zero consumers). Documented as its own decision arc.Deploy note
KV namespaces already exist; the binding lands with the next
wrangler pages deployfrom this branch's merge. All KV consumers fail open, so deploy order cannot break the payment path.🤖 Generated with Claude Code
https://claude.ai/code/session_01RuQRKzs6RawLs5zxxNPBYp