Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/trigger-deploy
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions .github/trigger-test
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
145 changes: 145 additions & 0 deletions docs/audit-closeout-f01-f25.md
Original file line number Diff line number Diff line change
@@ -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=<timestamp>"`.
- **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.
19 changes: 19 additions & 0 deletions docs/worker-isolation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions scripts/test-rate-sweep.js
Original file line number Diff line number Diff line change
@@ -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);
89 changes: 89 additions & 0 deletions scripts/test-render-credit.js
Original file line number Diff line number Diff line change
@@ -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);
24 changes: 24 additions & 0 deletions scripts/test-worker-isolation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Loading