diff --git a/.github/trigger-deploy b/.github/trigger-deploy index c2151daf..16c24eb5 100644 --- a/.github/trigger-deploy +++ b/.github/trigger-deploy @@ -26,3 +26,7 @@ reaudit3-worker d6d81e0 reaudit3-f11 510ef4a reaudit3-f04 4e28ac3 worker-shared-image ff21b4f +f21-ratelimit e1f992f +f13-f21-ci 669033a +audit-hardening 94b7fcb +guard-falsepos-fix 1f8ea44 diff --git a/.github/trigger-test b/.github/trigger-test index ffe6cab6..35301258 100644 --- a/.github/trigger-test +++ b/.github/trigger-test @@ -41,3 +41,7 @@ reaudit3-f11 510ef4a reaudit3-f04 4e28ac3 worker-shared-image ff21b4f canary-render-leg 30df60f +f21-ratelimit e1f992f +f13-f21-ci 669033a +audit-hardening 94b7fcb +guard-falsepos-fix 1f8ea44 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ea7002b0..85821650 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -924,6 +924,12 @@ jobs: - name: Postgres TLS policy by route (F11 — verified cert on public hosts, relaxed only on the private railway.internal mesh — offline) run: node scripts/test-db-ssl.js + - name: Bounded rate-limit maps + global waitlist ceiling (F21 — stale one-time-IP keys swept, distributed flood capped — offline) + run: node scripts/test-rate-sweep.js + + - name: Idempotent render credit (F13 — capacity-refused paid render retried without a second charge; single-use, TTL, eviction — offline) + run: node scripts/test-render-credit.js + - name: /health envelope + .flags shape (boolean discipline locks the Yahoo-relay-flap class) run: node scripts/test-health-envelope.js diff --git a/docs/audit-closeout-f01-f25.md b/docs/audit-closeout-f01-f25.md new file mode 100644 index 00000000..98d814bf --- /dev/null +++ b/docs/audit-closeout-f01-f25.md @@ -0,0 +1,145 @@ +# 3rd security audit (A402-F01..F25) — closeout ledger + +Final disposition of every finding from `Agent402-Final-Security-Reaudit`. Status +is one of: **Fixed** (shipped in code, live), **Accepted** (won't-fix with +rationale), or **Owner action** (cannot be closed from this repo — needs a +GitHub-admin, DNS/registrar, or product decision; exact steps below). + +As of this writing, 22 of 25 are Fixed (F13 flag-gated OFF), 1 is Accepted, and 2 need owner action +(plus the owner half of F01). + +## Ledger + +| ID | Sev | Status | Where | +|---|---|---|---| +| F01 | High | Fixed (code) + **Owner action** (repo rulesets/env) | `.github/CODEOWNERS`, deploy/publish `environment:` refs | +| F02 | High | Fixed | secretless worker live (`worker/server.js`, `RENDER_WORKER_URL`) | +| F03 | High | Fixed | CDP actual-byte counting (`src/tools/render.js`) | +| F04 | High | Fixed | validate+pin egress proxy (`worker/egress-proxy.js`) | +| F05 | High | Fixed | tollbooth Durable-Object atomic replay (OSS pkg) | +| F06 | High | Fixed | media parsing in the secretless worker | +| F07 | High | Fixed | CI: untrusted `npm ci` step runs secretless | +| F08 | Med | Fixed | narrowed Gitleaks excludes | +| F09 | Med | Fixed | untrusted-content provenance on router + MCP | +| F10 | Med | Fixed | waitlist drops PII fallback, fails closed | +| F11 | Med | Fixed | route-aware Postgres TLS (`src/db-ssl.js`) | +| F12 | Med | Fixed | E2B output cap + concurrency ceiling | +| F13 | Med | Fixed (code, flag-gated OFF) | idempotent render credit (`src/render-credit.js`); enable with `RENDER_CREDIT_ENABLED` | +| F14 | Med | Fixed | MCP abort + await-before-release | +| F15 | Med | Fixed | PostHog proxy timeout + concurrency + byte cap | +| F16 | Med | Fixed | `Dockerfile.mcp` digest-pinned + non-root | +| F17 | Med | **Owner action** (DNS) | DMARC — steps below | +| F18 | Low | Fixed | tollbooth PoW origin+method binding (OSS pkg) | +| F19 | Low | Fixed | tollbooth `verifyX402` AbortSignal (OSS pkg) | +| F20 | Low | Fixed | operator dashboard `no-store` | +| F21 | Low | Fixed | bounded rate maps + global waitlist ceiling (`src/rate-sweep.js`) | +| F22 | Low | **Accepted** (won't-fix) | CSP inline — rationale below | +| F23 | Low | Fixed | dormant renderer href scheme guard | +| F24 | Low | Fixed | log-injection stripping | +| F25 | Low | **Owner action** (DNS) | DNSSEC/CAA/MTA-STS/www — steps below | + +--- + +## Owner-action items — exact steps + +These need an owner action from outside the repo (GitHub-admin, DNS/registrar, or +flipping a reviewed prod flag). Each is copy-paste ready. F13 is fully built and +only needs its flag enabled; F01 (owner half), F17, and F25 are the genuine +external actions. + +### F01 (owner half) — GitHub branch rulesets + protected environment + +Do this in the GitHub UI or via `gh api`. **Two guardrails so this does not +break the working CI/deploy flow:** +- The deployment-branch policy on the environment MUST allow the dev branch + `claude/sweet-brown-i99jl3` (deploys run from it), not only `main`. +- Do **not** require signed commits unless you start GPG/SSH-signing — current + commits are unsigned, so the rule would block every push. +- Do **not** enable "require review from someone other than the last pusher" + while you are the sole maintainer — it would make every PR unmergeable. + +Ruleset on `main` (and optionally the dev branch): require a pull request before +merging, require status checks to pass (`test`, `gitleaks`, `markers`), require +review from Code Owners, block force-pushes and deletions. + +Protected environment `agent402 / production` (referenced by deploy/publish +jobs): add a required reviewer (yourself is fine), set the deployment-branch +policy to allow `main` **and** `claude/sweet-brown-i99jl3`, and move the +deploy/publish secrets to env scope. + +### F13 — render capacity refused after settlement — BUILT (approach B), flag-gated OFF + +Approach **B (idempotent durable credit)** is implemented in `src/render-credit.js` +and wired into the paywall in `src/server.js`, **flagged OFF by default** so +billing is byte-identical until enabled. On a capacity refusal (503) of a +*settled* paid render/screenshot, the server mints a one-time **credit token** (a +256-bit bearer secret) returned only in that buyer's own 503 response +(`X-Render-Credit` header + JSON `renderCredit`), bound to the exact request +(route + body). A retry presenting the token via `X-Render-Credit` skips the gate +(PoW + replay guard + USDC paywall) and is served once; if that retry also fails, +a fresh token is re-issued so the paid credit is never lost. 10-minute TTL, +FIFO-capped. + +Security (adversarially reviewed): the token is unguessable and delivered only to +the paying caller, so knowing a (public) wallet cannot forge or steal it; it is +bound to route+body so it can't be spent on a different or costlier render; +verified live that a forged token still 402s. **Single-use is enforced atomically +at admission** via `claim()` (validate-and-remove in one synchronous step), NOT a +validate-now/consume-on-finish pattern — so a burst of concurrent retries with +the same token can't each be served (the earlier draft had that double-spend; the +test now asserts exactly one of 100 concurrent claims wins). Unit-tested +(`scripts/test-render-credit.js`, 24 cases: atomic single-use / no double-spend, +forgery + cross-request rejection, TTL, re-issue on failed delivery, eviction). + +**To turn it on (owner, after review):** set `RENDER_CREDIT_ENABLED=true` on the +main Railway service. Rollback = unset it (billing reverts, no data). Approach A +(reserve-before-settle) writeup remains in `docs/f13-capacity-options.md` if the +ordering-change route is ever preferred. + +### F17 — publish DMARC (DNS) + +MX is Zoho; SPF is currently `~all` (soft-fail) with no DMARC. + +1. In the Zoho mail admin, confirm DKIM is enabled and publish the selector TXT + record Zoho provides for every sending domain. +2. Publish DMARC at `_dmarc.agent402.tools` (TXT), start in monitor mode: + `v=DMARC1; p=none; rua=mailto:dmarc@agent402.tools; fo=1; adkim=s; aspf=s` +3. Watch the aggregate (`rua`) reports ~2 weeks, confirm all legit senders align, + then tighten: `p=quarantine` → `p=reject`. +4. Only after every authorized sender is confirmed, change SPF `~all` → `-all`. + +### F25 — DNS/mail transport hardening (DNS/registrar) + +None of DS, CAA, MTA-STS, TLS-RPT, or `www` are present. + +- **DNSSEC:** enable at the DNS provider and publish the DS record at the + registrar. +- **CAA:** restrict issuance to the CA the platform actually uses. Railway + fronts TLS with Let's Encrypt, so: + `agent402.tools CAA 0 issue "letsencrypt.org"` and + `agent402.tools CAA 0 iodef "mailto:security@agent402.tools"`. + **Verify the CA before publishing** — a wrong CAA breaks cert renewal. +- **MTA-STS:** host `https://mta-sts.agent402.tools/.well-known/mta-sts.txt` + (`version: STSv1`, `mode: testing` first, Zoho MX hosts, `max_age`), and + publish `_mta-sts.agent402.tools TXT "v=STSv1; id="`. +- **TLS-RPT:** `_smtp._tls.agent402.tools TXT "v=TLSRPTv1; rua=mailto:tls@agent402.tools"`. +- **www:** either add a `www` record with a TLS-covered redirect to the apex, or + document the domain as apex-only. HSTS is `includeSubDomains`, so any future + `www` endpoint must serve a valid cert from first use. + +--- + +## Accepted (won't-fix) + +### F22 — CSP still permits inline script/style + +`script-src`/`style-src` keep `'unsafe-inline'`. **Accepted at Low** because: +output is escaped on every served surface, no live XSS is reachable (the audit +confirms this), and removing `'unsafe-inline'` requires per-response nonces +threaded through every page renderer (`ledger-home`, `market-page`, catalog, +guides, …) — a broad change with real breakage risk on live pages for a Low +finding. + +If we later choose to close it: add a per-request nonce, remove `'unsafe-inline'` +from `script-src` first (styles second), and roll out behind +`Content-Security-Policy-Report-Only` to catch misses before enforcing. diff --git a/docs/worker-isolation-plan.md b/docs/worker-isolation-plan.md index 6f36c689..1c4cf60a 100644 --- a/docs/worker-isolation-plan.md +++ b/docs/worker-isolation-plan.md @@ -126,6 +126,25 @@ longer do its own resolution, so the validate-then-rebind TOCTOU is gone. Verified by `scripts/test-egress-proxy.js` (blocks loopback / RFC1918 / metadata / ULA at both the resolver and the live CONNECT). +**Hardening from adversarial review (2026-07-19):** +- The boot guard was an incomplete *denylist* (12 names) while the app reads 30+ + secret env vars. It is now a **pattern-based fail-closed guard** + (`KEY|SECRET|TOKEN|MNEMONIC|PRIVATE|PASSWORD|CREDENTIAL` + a short list of + non-pattern secrets), so an unlisted secret like `GITHUB_TOKEN`/`E2B_API_KEY` + now blocks boot too. Enforced only at boot (isMain), never on import, so a + CI/test process with its own `GITHUB_TOKEN` can still import the module. + Railway's health-gated rollout means a false positive fails the *new* deploy + while the old worker keeps serving — no outage. +- `/call` auth now **fails closed**: `tokenOk` denies when no token is + configured, and the worker refuses to boot without `RENDER_WORKER_TOKEN`, so a + missing-token misconfig can't leave the endpoint open on the private mesh. +- Tool dispatch uses `Object.hasOwn(HANDLERS, slug)` so a slug like + `constructor`/`toString` can't resolve to an inherited prototype function. +- Chromium is launched with `--proxy-bypass-list=<-loopback>` so loopback and + link-local literals (incl. `169.254.169.254`) ALSO traverse the F04 validating + proxy, making it the single egress chokepoint rather than leaning on the + app-layer route guard for literal-IP metadata reach. + **What remains the platform gap:** the Chromium **sandbox** (`--no-sandbox`) — that needs userns/seccomp Railway doesn't expose. Non-root + secretless + the egress proxy already remove the secret-theft and internal-pivot impact of a diff --git a/scripts/test-rate-sweep.js b/scripts/test-rate-sweep.js new file mode 100644 index 00000000..02c55dcd --- /dev/null +++ b/scripts/test-rate-sweep.js @@ -0,0 +1,40 @@ +// F21: unit tests for the bounded rate-limit primitives (offline, no server). +// node scripts/test-rate-sweep.js +import { sweepStaleTsMap, makeWindowCounter } from "../src/rate-sweep.js"; + +let pass = 0, fail = 0; +const ok = (c, m) => { c ? pass++ : fail++; console.log(`${c ? "ok" : "FAIL"} - ${m}`); }; + +// --- sweepStaleTsMap: one-time IPs are evicted, live keys pruned in place ----- +{ + const now = 1_000_000; + const W = 60_000; + const m = new Map([ + ["stale-a", [now - 90_000, now - 70_000]], // all older than window -> drop + ["stale-b", [now - 60_001]], // exactly past the window -> drop + ["live", [now - 90_000, now - 10_000]], // one live ts -> keep only the live one + ["fresh", [now - 1000, now - 2000]], // all live -> unchanged + ]); + sweepStaleTsMap(m, W, now); + ok(!m.has("stale-a"), "all-stale key is deleted"); + ok(!m.has("stale-b"), "boundary-stale key (== window) is deleted"); + ok(m.has("live") && m.get("live").length === 1 && m.get("live")[0] === now - 10_000, "partially-stale key keeps only its live timestamps"); + ok(m.has("fresh") && m.get("fresh").length === 2, "all-live key is retained intact"); + ok(m.size === 2, "map shrank to only the keys with live hits"); +} + +// --- makeWindowCounter: global ceiling trips at the limit, recovers after window +{ + const W = 60_000; + const c = makeWindowCounter(W, 3); + let t = 500_000; + ok(c.allow(t) && c.allow(t) && c.allow(t), "first 3 hits in the window are allowed"); + ok(c.allow(t) === false, "the 4th hit in the window is refused (global ceiling)"); + ok(c.size() === 3, "counter holds exactly the window's hits"); + // slide past the window: the earlier hits expire, capacity returns + ok(c.allow(t + 60_001) === true, "after the window passes, a new hit is allowed again"); + ok(c.size() === 1, "expired hits are dropped from the window"); +} + +console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/scripts/test-render-credit.js b/scripts/test-render-credit.js new file mode 100644 index 00000000..b9d59423 --- /dev/null +++ b/scripts/test-render-credit.js @@ -0,0 +1,89 @@ +// F13: unit tests for the idempotent render-credit ledger (offline, no server). +// node scripts/test-render-credit.js +import { createRenderCreditLedger, bodyHashFor, mintCreditToken } from "../src/render-credit.js"; + +let pass = 0, fail = 0; +const ok = (c, m) => { c ? pass++ : fail++; console.log(`${c ? "ok" : "FAIL"} - ${m}`); }; + +const META = { route: "POST /api/render", bodyHash: bodyHashFor({ url: "https://example.com" }) }; +const OTHER_BODY = { route: "POST /api/render", bodyHash: bodyHashFor({ url: "https://evil.com" }) }; +const OTHER_ROUTE = { route: "GET /api/screenshot?url=x", bodyHash: META.bodyHash }; + +// --- token secrecy / shape ------------------------------------------------- +{ + const a = mintCreditToken(), b = mintCreditToken(); + ok(typeof a === "string" && a.length >= 43, "minted token is a long (256-bit) URL-safe string"); + ok(a !== b, "each minted token is unique (unguessable bearer secret)"); + ok(bodyHashFor(undefined) === "-" && bodyHashFor({}) === "-", "empty/absent body → '-' sentinel"); +} + +// --- issue / valid (read-only) --------------------------------------------- +{ + const L = createRenderCreditLedger({ ttlMs: 1000 }); + const tok = L.issue(META, 0); + ok(typeof tok === "string" && tok.length >= 43, "issue returns the bearer token"); + ok(L.valid(tok, META, 0) === true, "the token is valid for the exact request it was issued for"); + ok(L.valid(tok, META, 0) === true, "valid() is read-only — it does NOT consume (so admission must use claim())"); +} + +// --- ATOMIC claim: this is what closes the double-spend -------------------- +{ + const L = createRenderCreditLedger({ ttlMs: 10_000 }); + const tok = L.issue(META, 0); + // Simulate a concurrent burst: every retry validates before any finishes, but + // admission goes through claim(), which removes the token on the FIRST call. + ok(L.claim(tok, META, 0) === true, "first concurrent retry claims the credit"); + ok(L.claim(tok, META, 0) === false, "a second concurrent retry with the same token is refused (no double-spend)"); + ok(L.claim(tok, META, 0) === false, "and a third — one paid credit yields exactly one delivery"); + // 100 racers, one token: + const L2 = createRenderCreditLedger({ ttlMs: 10_000 }); + const t2 = L2.issue(META, 0); + let wins = 0; + for (let i = 0; i < 100; i++) if (L2.claim(t2, META, 0)) wins++; + ok(wins === 1, "exactly ONE of 100 concurrent claims of the same token succeeds"); +} + +// --- claim: forgery / cross-request resistance (and no consume on mismatch) - +{ + const L = createRenderCreditLedger({ ttlMs: 1000 }); + const tok = L.issue(META, 0); + ok(L.claim("not-a-real-token", META, 0) === false, "a guessed/forged token is rejected"); + ok(L.claim(tok, OTHER_BODY, 0) === false, "a token cannot be claimed for a DIFFERENT body"); + ok(L.claim(tok, OTHER_ROUTE, 0) === false, "a token cannot be claimed for a DIFFERENT route"); + ok(L.claim(tok, META, 0) === true, "the mismatched attempts did NOT consume the credit — it is still claimable for its real request"); + ok(L.claim(null, META, 0) === false && L.claim(undefined, META, 0) === false, "missing token → not claimable"); +} + +// --- TTL expiry ------------------------------------------------------------ +{ + const L = createRenderCreditLedger({ ttlMs: 1000 }); + const tok = L.issue(META, 0); + ok(L.valid(tok, META, 999) === true, "valid within the TTL"); + ok(L.claim(tok, META, 1000) === false, "cannot claim at/after the TTL boundary (expired)"); + const tok2 = L.issue(META, 2000); + ok(L.claim(tok2, META, 7000) === false, "claiming past the TTL reports false (expired)"); +} + +// --- capacity refusal: a failed pre-paid retry re-issues, success does not -- +{ + const L = createRenderCreditLedger({ ttlMs: 10_000 }); + const tok = L.issue(META, 0); // paid call 503'd -> token minted + ok(L.claim(tok, META, 100) === true, "retry #1 claims the credit (bypasses the paywall)"); + // retry #1 ALSO 503s -> the server re-issues a fresh token; the OLD one is dead + ok(L.claim(tok, META, 150) === false, "the claimed token is dead even though delivery failed (server re-issues a new one)"); + const tok2 = L.issue(META, 200); // server's re-issue on the failed retry + ok(L.claim(tok2, META, 300) === true, "retry #2 with the re-issued token succeeds -> consumed"); + ok(L.claim(tok2, META, 400) === false, "no free renders after the one paid delivery"); +} + +// --- FIFO eviction at capacity --------------------------------------------- +{ + const L = createRenderCreditLedger({ ttlMs: 10_000, maxEntries: 2 }); + const a = L.issue(META, 0), b = L.issue(META, 1); + const c = L.issue(META, 2); // evicts the oldest (a) + ok(L.valid(a, META, 5) === false, "oldest credit evicted when the ledger is full"); + ok(L.valid(b, META, 5) && L.valid(c, META, 5), "newer credits retained"); +} + +console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/scripts/test-worker-isolation.js b/scripts/test-worker-isolation.js index 42907ee6..3feac05b 100644 --- a/scripts/test-worker-isolation.js +++ b/scripts/test-worker-isolation.js @@ -99,5 +99,29 @@ const listen = (app) => new Promise((res) => { const s = app.listen(0, () => res ok(/import\(\s*workerMode\s*\?\s*["']\.\/worker\/server\.js["']\s*:\s*["']\.\/src\/server\.js["']\s*\)/.test(src), "start.js imports (not spawns) the selected server so the gosu entrypoint keeps PID 1"); } +// --- 5. Boot-guard secret detection: catches real secrets, not infra vars --- +{ + const { forbiddenSecretsIn } = await import("../worker/server.js"); + // The real secretless worker env on Railway must pass (no false positive) — + // this is the RAILWAY_PRIVATE_DOMAIN regression that took a deploy down. + const cleanWorkerEnv = { + PORT: "3999", WORKER_MODE: "true", RENDER_WORKER_TOKEN: "abc", + RAILWAY_PRIVATE_DOMAIN: "agent402-worker.railway.internal", + RAILWAY_ENVIRONMENT_NAME: "production", RAILWAY_PROJECT_ID: "x", + RAILWAY_SERVICE_ID: "y", RAILWAY_SERVICE_AGENT402_URL: "z", + RAILWAY_DOCKERFILE_PATH: "Dockerfile.worker", PATH: "/usr/bin", HOME: "/home/node", + RENDER_EGRESS_PROXY_URL: "http://127.0.0.1:5", + }; + ok(forbiddenSecretsIn(cleanWorkerEnv).length === 0, "the secretless worker env (incl. RAILWAY_PRIVATE_DOMAIN + its own token) does NOT trip the guard"); + // Real secrets the OLD denylist missed must now trip it. + for (const k of ["GITHUB_TOKEN", "E2B_API_KEY", "STELLAR_FACILITATOR_KEY", "ALGORAND_BURNER_MNEMONIC", "OPENROUTER_API_KEY", "CDP_API_KEY_SECRET"]) { + ok(forbiddenSecretsIn({ [k]: "v", RENDER_WORKER_TOKEN: "t" }).includes(k), `guard catches ${k} (pattern-based, not the old 12-name denylist)`); + } + // Non-pattern secrets stay covered; the worker's own token stays allowed. + ok(forbiddenSecretsIn({ WALLET_ADDRESS: "0x", DATABASE_URL: "postgres://x" }).length === 2, "non-pattern secrets (WALLET_ADDRESS, DATABASE_URL) still caught"); + ok(forbiddenSecretsIn({ RENDER_WORKER_TOKEN: "t" }).length === 0, "the worker's OWN inbound-auth token is allowed"); + ok(forbiddenSecretsIn({ FOO_KEY: "" }).length === 0, "an empty secret var is ignored (only set values count)"); +} + console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); diff --git a/src/rate-sweep.js b/src/rate-sweep.js new file mode 100644 index 00000000..e7e65082 --- /dev/null +++ b/src/rate-sweep.js @@ -0,0 +1,45 @@ +// F21: bounded rate-limit bookkeeping. +// +// A per-IP "hits" map (ip -> [timestamps]) only prunes an entry when the SAME ip +// comes back, so a flood of one-time IPs accumulates keys without bound. And a +// per-IP limit alone lets a distributed source (many IPs, each under the limit) +// mass-submit. These two primitives close both: +// +// sweepStaleTsMap — evict keys whose timestamps are all older than the window. +// Run on a periodic timer AND inline when a map grows large. +// makeWindowCounter — a global rolling-window counter: the aggregate ceiling a +// distributed source can't slip under. + +/** + * Delete every key of `map` (ip -> number[] of ms timestamps) whose timestamps + * are all older than `windowMs`; keep the live subset otherwise. Mutates and + * returns `map`. + */ +export function sweepStaleTsMap(map, windowMs, now = Date.now()) { + for (const [k, ts] of map) { + const live = ts.filter((t) => now - t < windowMs); + if (live.length) map.set(k, live); + else map.delete(k); + } + return map; +} + +/** + * Aggregate rolling-window counter. `allow()` records the hit and returns true + * while fewer than `limit` hits fall inside the last `windowMs`, false once the + * window is full. Not keyed by IP — this is the global ceiling. + */ +export function makeWindowCounter(windowMs, limit) { + let hits = []; + return { + allow(now = Date.now()) { + hits = hits.filter((t) => now - t < windowMs); + if (hits.length >= limit) return false; + hits.push(now); + return true; + }, + size() { + return hits.length; + }, + }; +} diff --git a/src/render-credit.js b/src/render-credit.js new file mode 100644 index 00000000..1ec04dbf --- /dev/null +++ b/src/render-credit.js @@ -0,0 +1,69 @@ +// F13: idempotent durable credit for capacity-refused paid renders. +// +// x402 settles BEFORE the handler runs. If a paid render/screenshot then hits the +// bounded Chromium pool and is refused (503, security audit A402-08), the buyer +// was charged but not served — and because each x402 authorization nonce is +// single-use, a naive retry pays AGAIN. On such a refusal we mint a one-time +// CREDIT TOKEN — a 256-bit bearer secret returned only in that buyer's own 503 +// response — and record a credit bound to the exact request (route + body). A +// retry that presents the token (`X-Render-Credit`) for the SAME request skips +// the paywall and is served without a second charge; the credit is consumed only +// on successful delivery and survives a repeated 503 for another retry. +// +// Why a server-minted token, NOT the payer address: the token is unguessable and +// is handed only to the paying caller, so a third party who merely knows the +// (public) wallet cannot forge or steal the credit. Binding to route+body means a +// token can't be spent on a different or costlier render than the one paid for. +import { createHash, randomBytes } from "node:crypto"; + +export function bodyHashFor(body) { + return body && typeof body === "object" && Object.keys(body).length + ? createHash("sha256").update(JSON.stringify(body)).digest("hex") + : "-"; +} + +// 256-bit URL-safe bearer secret. Only ever returned to the paying caller. +export function mintCreditToken() { + return randomBytes(32).toString("base64url"); +} + +export function createRenderCreditLedger({ ttlMs = 10 * 60 * 1000, maxEntries = 5000 } = {}) { + const store = new Map(); // token -> { route, bodyHash, at } + const live = (e, now) => !!e && now - e.at < ttlMs; + const matches = (e, meta) => e.route === meta.route && e.bodyHash === meta.bodyHash; + return { + // Record a credit for a settled-but-refused render; returns the bearer token. + // A caller-supplied token is only for deterministic tests. + issue(meta, now = Date.now(), token = mintCreditToken()) { + while (store.size >= maxEntries && store.size > 0) store.delete(store.keys().next().value); + store.set(token, { route: meta.route, bodyHash: meta.bodyHash, at: now }); + return token; + }, + // True iff there is a LIVE credit for this token AND it was issued for this + // exact request (route + body). Read-only; lazily drops an expired token. + valid(token, meta, now = Date.now()) { + if (!token || typeof token !== "string") return false; + const e = store.get(token); + if (!live(e, now)) { if (e) store.delete(token); return false; } + return matches(e, meta); + }, + // ATOMIC single-use admission: validate AND remove in one synchronous step. + // Returns true iff a LIVE credit for this exact request existed (now consumed). + // This is what closes the double-spend: concurrent retries presenting the same + // token race here — only the FIRST claim removes it and returns true; every + // other gets false and falls through to the paywall. A non-matching request + // does NOT consume the credit. Use this at gate admission, never `valid`. + claim(token, meta, now = Date.now()) { + if (!token || typeof token !== "string") return false; + const e = store.get(token); + if (!live(e, now)) { if (e) store.delete(token); return false; } + if (!matches(e, meta)) return false; + store.delete(token); + return true; + }, + prune(now = Date.now()) { + for (const [t, e] of store) if (!live(e, now)) store.delete(t); + }, + size() { return store.size; }, + }; +} diff --git a/src/server.js b/src/server.js index f23199a7..e14aa071 100644 --- a/src/server.js +++ b/src/server.js @@ -175,6 +175,8 @@ import { buildSkillTools } from "./tools/skill-runner.js"; import { buildRouteExecuteTool } from "./tools/route-execute.js"; import { issueChallenge, verifySolution, isComputePayable, powInfo, POW_DIFFICULTY, WALLET_ONLY_SLUGS, verifyHeartbeatToken } from "./pow.js"; import { createLimiter as createRateLimiter, LIMITS_LABEL as POW_LIMITS_LABEL } from "./rate-limit.js"; +import { sweepStaleTsMap, makeWindowCounter } from "./rate-sweep.js"; +import { bodyHashFor, createRenderCreditLedger } from "./render-credit.js"; // Shared with the MCP free tier (src/mcp-http.js) — same policy, separate // per-IP bucket. PoW redemption on the direct HTTP path goes through here. @@ -1028,8 +1030,16 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const waitlistHits = new Map(); // ip -> [timestamps] const WAITLIST_LIMIT = 5; // per IP per window const WAITLIST_WINDOW_MS = 60_000; +// F21: an aggregate ceiling across ALL IPs — a distributed source (many +// one-time IPs, each under the per-IP limit) must not mass-insert leads. +const WAITLIST_GLOBAL_LIMIT = 60; // per WAITLIST_WINDOW_MS, across every IP +const waitlistGlobal = makeWindowCounter(WAITLIST_WINDOW_MS, WAITLIST_GLOBAL_LIMIT); +// F21: keep the per-IP map from growing without bound between the periodic +// sweeps below when a burst of unique IPs arrives. +const RL_MAP_MAX_KEYS = 5000; function waitlistRateOk(ip) { const now = Date.now(); + if (waitlistHits.size > RL_MAP_MAX_KEYS) sweepStaleTsMap(waitlistHits, WAITLIST_WINDOW_MS, now); const arr = (waitlistHits.get(ip) || []).filter((t) => now - t < WAITLIST_WINDOW_MS); if (arr.length >= WAITLIST_LIMIT) { waitlistHits.set(ip, arr); @@ -1051,6 +1061,11 @@ app.post("/api/tollbooth/waitlist", async (req, res) => { if (!waitlistRateOk(ip)) { return res.status(429).json({ ok: false, error: "rate-limited" }); } + // F21: aggregate ceiling — checked after the per-IP gate so a distributed + // flood of one-time IPs can't slip past the per-IP limit and mass-insert. + if (!waitlistGlobal.allow()) { + return res.status(429).json({ ok: false, error: "waitlist-busy" }); + } const b = req.body || {}; // Honeypot: real form leaves `website` empty; bots fill every field. if (typeof b.website === "string" && b.website.length > 0) { @@ -1946,11 +1961,21 @@ app.get("/api/index", (_req, res) => // per-route parser here would be a no-op — the global one already parsed the // body by the time this handler runs). const REG_WINDOW_MS = 3600_000; -const regByIp = new Map(); +const regByIp = new Map(); // ip -> [timestamps]; global cap is regGlobal below let regGlobal = []; +// F21: evict stale keys from the one-time-IP rate maps so distributed input +// can't grow them without bound (the per-IP prune only fires when the SAME IP +// returns). Mirrors the powChallengeHits / operatorSessions sweeps; the inline +// size backstops above cover bursts between ticks. +setInterval(() => { + const now = Date.now(); + sweepStaleTsMap(waitlistHits, WAITLIST_WINDOW_MS, now); + sweepStaleTsMap(regByIp, REG_WINDOW_MS, now); +}, 60_000); app.post("/api/index/register", async (req, res) => { const now = Date.now(); const ip = req.ip || "?"; + if (regByIp.size > RL_MAP_MAX_KEYS) sweepStaleTsMap(regByIp, REG_WINDOW_MS, now); const mine = (regByIp.get(ip) || []).filter((t) => now - t < REG_WINDOW_MS); if (mine.length >= 5) return res.status(429).json({ error: "rate limit: 5 submissions per hour per IP" }); const v = validateOriginInput(req.body?.origin, { selfOrigin: BASE_URL }); @@ -2678,6 +2703,67 @@ if (FREE_MODE) { next(); }); + // F13: idempotent durable credit for capacity-refused paid renders. x402 + // settles BEFORE the handler, so a paid render/screenshot that then hits the + // bounded Chromium pool (503) was charged but not served — and the single-use + // nonce means a naive retry pays again. On such a refusal we mint a one-time + // credit TOKEN (256-bit bearer secret) returned only in that buyer's own 503 + // response and bound to the exact request (route+body); a retry presenting the + // token via `X-Render-Credit` skips the gate below (served without a second + // charge) and the credit is consumed only on a successful delivery. The token + // is unguessable and handed only to the payer, so knowing a (public) wallet + // can't forge or steal it. Flagged OFF by default → billing is byte-identical + // until RENDER_CREDIT_ENABLED is set. + const RENDER_CREDIT_ENABLED = /^(1|true|yes|on)$/i.test((process.env.RENDER_CREDIT_ENABLED || "").trim()); + const RENDER_CREDIT_SLUGS = new Set(["render", "screenshot"]); // the A402-08 bounded-pool tools + const renderCredits = createRenderCreditLedger(); + if (RENDER_CREDIT_ENABLED) { + setInterval(() => renderCredits.prune(), 60_000).unref(); + app.use((req, res, next) => { + const def = CATALOG[`${req.method} ${req.path}`]; + if (!def || !RENDER_CREDIT_SLUGS.has(def.slug)) return next(); + const meta = { route: `${req.method} ${req.originalUrl}`, bodyHash: bodyHashFor(req.body) }; + // Retry path — ATOMIC single-use admission. claim() validates AND removes + // the token in one synchronous step, so a burst of concurrent retries with + // the same token can't each be served: only the first claim wins, the rest + // get false and fall through to the paywall. (A validate-then-consume-on- + // finish pattern would let N concurrent retries all pass before any one + // finished — the double-spend this closes.) + const token = req.header("x-render-credit"); + const claimed = !!(token && renderCredits.claim(token, meta)); + if (claimed) { + req.__renderCreditToken = token; + res.setHeader("X-Render-Credit-Replay", "true"); + } + // A capacity 503 only reaches the handler AFTER the paywall passed, so a + // 503 on a request that carried a payment header means the buyer settled + // and was not served → mint a token. And if a CLAIMED credit didn't deliver + // (any non-2xx), give a fresh token BACK so the consumed-but-undelivered + // credit is never lost. The token rides the response headers (not yet + // flushed) so only the paying caller receives it. res.json is the error + // path for both render and screenshot; a screenshot SUCCESS uses res.send, + // so a delivered credit is never re-issued. + const paid = !!(req.header("x-payment") || req.header("payment-signature")); + if (paid || claimed) { + const origJson = res.json.bind(res); + res.json = (body) => { + const delivered = res.statusCode >= 200 && res.statusCode < 300; + const owe = !delivered && (claimed || (paid && res.statusCode === 503)); + if (owe && !res.headersSent) { + const t = renderCredits.issue(meta); + res.setHeader("X-Render-Credit", t); + if (body && typeof body === "object" && !Array.isArray(body)) { + body = { ...body, renderCredit: t, renderCreditHint: "retry this exact request with header 'X-Render-Credit: ' to be served without paying again" }; + } + } + return origJson(body); + }; + } + next(); + }); + console.log("F13 render-credit ENABLED: a capacity-refused paid render returns an X-Render-Credit token; retry with it is served without a second charge"); + } + // Gate: for a compute-payable route, a valid proof-of-work bypasses the x402 // paywall; otherwise the normal USDC paywall applies (and we advertise the // PoW alternative via a response header on its 402). PoW redemption is @@ -2685,6 +2771,9 @@ if (FREE_MODE) { // hosted MCP free tier (src/rate-limit.js) — otherwise a client exhausted // on /mcp could keep hammering /api/* with fresh PoW solutions for free. app.use((req, res, next) => { + // F13: a valid pre-paid render credit token skips the whole gate (PoW, replay + // guard, and USDC paywall) — the buyer already paid for this exact render. + if (req.__renderCreditToken) return next(); const slug = POW_ROUTES.get(`${req.method} ${req.path}`); if (slug) { const solution = req.header("x-pow-solution"); diff --git a/src/tools/render.js b/src/tools/render.js index a6a7bb70..2f29d6ec 100644 --- a/src/tools/render.js +++ b/src/tools/render.js @@ -110,7 +110,15 @@ async function getBrowser() { // (in-process) => no proxy; the render.js route guard still applies. const launchArgs = ["--no-sandbox", "--disable-dev-shm-usage"]; const egressProxy = (process.env.RENDER_EGRESS_PROXY_URL || "").trim(); - if (egressProxy) launchArgs.push(`--proxy-server=${egressProxy}`); + if (egressProxy) { + launchArgs.push(`--proxy-server=${egressProxy}`); + // Chromium implicitly bypasses the proxy for loopback + link-local + // literals (incl. the 169.254.169.254 metadata IP). "<-loopback>" + // removes that implicit bypass so those ALSO traverse the validating + // proxy — making the proxy the single egress chokepoint rather than + // relying on the app-layer route guard for literal-IP metadata reach. + launchArgs.push('--proxy-bypass-list=<-loopback>'); + } const browser = await chromium.launch({ args: launchArgs }); // Self-heal: if Chromium dies (OOM, crash), the next call relaunches // instead of serving errors until the process restarts. diff --git a/worker/server.js b/worker/server.js index f8a73fb3..86f8965c 100644 --- a/worker/server.js +++ b/worker/server.js @@ -29,22 +29,38 @@ const HANDLERS = { }; for (const t of MEDIA_TOOLS) HANDLERS[t.slug] = (input) => t.handler(input || {}); -// Boot guard: this worker must never be given a high-value secret. If one is -// present in env, refuse to start — that is a misconfigured deployment that -// would defeat the whole point of the isolation. -const FORBIDDEN_ENV = [ - "WALLET_ADDRESS", "CDP_API_KEY_SECRET", "CDP_API_KEY_ID", "OPENROUTER_API_KEY", - "OPENAI_API_KEY", "AGENT402_OPERATOR_TOKEN", "DATABASE_URL", "ANALYTICS_DATABASE_URL", - "POW_SECRET", "BURNER_KEY", "SOLANA_BURNER_KEY", "STELLAR_BURNER_SECRET", -]; -const present = FORBIDDEN_ENV.filter((k) => (process.env[k] || "").trim()); -if (present.length) { - console.error(`[worker] FATAL: secret env present in the SECRETLESS worker: ${present.join(", ")}. Remove these from the worker service and redeploy.`); - process.exit(1); +// Boot guard: this worker must be SECRETLESS by construction. It refuses to boot +// (see the isMain block) if any secret-bearing env var is present — a +// misconfigured deployment that would defeat the isolation. The check is +// PATTERN-based (KEY/SECRET/TOKEN/…) so it catches current AND future secrets by +// name, not a hand-kept denylist that drifts behind the app's 30+ secret env +// vars; plus a few secret vars whose names don't match the pattern. The worker's +// OWN inbound-auth token is the sole allowed pattern match. Enforced ONLY at boot +// (not at module import) so a test/CI process — which legitimately has its own +// GITHUB_TOKEN etc. — can import the exports without tripping it. Because Railway +// gates a deploy on the healthcheck, a false positive fails the new deploy +// visibly while the old worker keeps serving, rather than causing an outage. +// NB: no bare "PRIVATE" — Railway injects RAILWAY_PRIVATE_DOMAIN (the worker's +// own private-network hostname, not a secret), and a private KEY is already +// caught by "KEY". Matching "PRIVATE" alone would false-positive and refuse to +// boot on a benign infra var. +const SECRET_NAME_RE = /(KEY|SECRET|TOKEN|MNEMONIC|PASSWORD|CREDENTIAL)/i; +const SECRET_NAME_ALLOW = new Set(["RENDER_WORKER_TOKEN"]); +const FORBIDDEN_ENV = ["WALLET_ADDRESS", "WALLET_ENS", "DATABASE_URL", "ANALYTICS_DATABASE_URL"]; +// Pure + exported for tests: the secret-bearing env var names present in `env`. +export function forbiddenSecretsIn(env) { + return Object.keys(env).filter( + (k) => (env[k] || "").trim() && !SECRET_NAME_ALLOW.has(k) && (SECRET_NAME_RE.test(k) || FORBIDDEN_ENV.includes(k)) + ); } +const secretsPresent = () => forbiddenSecretsIn(process.env); +// Fail CLOSED: a request is authorized only if it presents the exact configured +// token. An empty/unset TOKEN denies everything (and the boot guard below also +// refuses to start the listener without one), so /call can never be open on the +// private mesh via a missing-token misconfiguration. const tokenOk = (got) => { - if (!TOKEN) return true; // no token configured (dev) — allow + if (!TOKEN) return false; const a = Buffer.from(String(got || "")); const b = Buffer.from(TOKEN); return a.length === b.length && timingSafeEqual(a, b); @@ -60,7 +76,9 @@ app.post("/call", async (req, res) => { const auth = (req.headers.authorization || "").replace(/^Bearer\s+/i, ""); if (!tokenOk(auth)) return res.status(401).json({ error: "unauthorized" }); const { slug, input } = req.body || {}; - const handler = HANDLERS[slug]; + // Object.hasOwn so a slug like "constructor"/"toString" can't resolve to an + // inherited Object.prototype function instead of an actual handler. + const handler = typeof slug === "string" && Object.hasOwn(HANDLERS, slug) ? HANDLERS[slug] : null; if (!handler) return res.status(404).json({ error: `unknown worker tool: ${String(slug).slice(0, 40)}` }); try { res.json(await handler(input || {})); @@ -75,6 +93,17 @@ app.post("/call", async (req, res) => { const isMain = (process.argv[1] && process.argv[1].endsWith("worker/server.js")) || /^(1|true|yes|on)$/i.test((process.env.WORKER_MODE || "").trim()); if (isMain) { + // Enforce the secretless invariant and the auth requirement ONLY when actually + // booting as the worker (not on import), and fail loud on either. + const leaked = secretsPresent(); + if (leaked.length) { + console.error(`[worker] FATAL: secret env present in the SECRETLESS worker: ${leaked.join(", ")}. Remove these from the worker service and redeploy.`); + process.exit(1); + } + if (!TOKEN) { + console.error("[worker] FATAL: RENDER_WORKER_TOKEN is not set — /call would be unauthenticated on the private mesh. Set it (same value on the main service) and redeploy."); + process.exit(1); + } // F04: start the validating + pinning egress proxy and point Chromium at it, // so the browser can't resolve/connect to a private/metadata/railway.internal // destination even under DNS rebinding. Set before the first render (Chromium