Skip to content

3rd-audit remediation (F01-F25) + secretless browser/media worker (live)#456

Merged
MikeyPetrillo merged 11 commits into
mainfrom
claude/sweet-brown-i99jl3
Jul 19, 2026
Merged

3rd-audit remediation (F01-F25) + secretless browser/media worker (live)#456
MikeyPetrillo merged 11 commits into
mainfrom
claude/sweet-brown-i99jl3

Conversation

@MikeyPetrillo

Copy link
Copy Markdown
Owner

Remediates the 3rd security audit (findings F01-F25) and ships the secretless browser/media worker isolation, now live in production.

Audit remediation (F01-F25)

  • F07 CI: the test-job install step shadows provider keys to empty (keys stay job-scope so trusted test steps still gate correctly; only the untrusted npm ci runs secretless).
  • F09 External router + MCP top_x402_sellers carry untrustedContent/source, responses carry containsUntrustedContent.
  • F10 Waitlist drops the GitHub issue-URL PII fallback and fails closed. F16 Dockerfile.mcp digest-pinned + non-root. F20 operator no-store. F24 log-safe redaction.
  • F03 Renderer counts actual bytes (CDP dataReceived + WS) instead of Content-Length. F12 E2B output cap + concurrency ceiling. F14 MCP abort + await-before-release. F15 PostHog proxy timeout + concurrency + streamed byte cap.
  • F05/F18/F19 Tollbooth (OSS package, not our prod): Durable Object atomic replay + fail-closed; PoW origin+method binding; verifyX402 AbortSignal.
  • F01 (code half): .github/CODEOWNERS + environment ref on the deploy/publish jobs. Owner half (GitHub ruleset + protected environment) is a dashboard action, tracked separately.
  • F11 Route-aware Postgres TLS (src/db-ssl.js): verify on public routes, relaxed on the *.railway.internal private mesh. DB confirmed on Railway's private network.

Secretless browser/media worker (F02/F04/F06) — live

A separate Railway service runs Chromium and ffmpeg/ffprobe with no payment/DB/operator/provider secrets and no /data mount, so a renderer or native-parser compromise lands in a box with nothing to steal. The main service dispatches render/screenshot/media over the private network when RENDER_WORKER_URL is set; unset means in-process (the default).

  • worker/server.js boots secretless with a FORBIDDEN_ENV guard that refuses to start if any high-value secret is present. Constant-time bearer auth.
  • start.js is a shared-image dispatcher: both services build the same Dockerfile; WORKER_MODE=true boots the worker. This replaces the per-service config-file pointer, which silently fell back to the main server when unset.
  • F04 DNS-rebinding / TOCTOU is closed in code by worker/egress-proxy.js: Chromium launches with --proxy-server at a validating + pinning forward proxy that is the only resolver and connector. It resolves each destination once, refuses any private/reserved/metadata IP or the fc00::/7 ULA that *.railway.internal uses, and connects to the pinned IP. Redirects re-validate.
  • Verified end-to-end: an example.com render through the worker returned HTTP 200 with correct extracted content, Chromium egressing through the F04 proxy.

Remaining platform gap: Chromium --no-sandbox still needs userns/seccomp that Railway does not expose. Non-root + secretless + the egress proxy already remove the secret-theft and internal-pivot impact.

Tests

New offline suites wired into CI: test-egress-proxy.js (12), test-worker-isolation.js (17, incl. the dispatch decision + boot-guard refusal), test-db-ssl.js (6), plus the batch-specific suites for F03/F05/F09/F12/F14/F15/F24. Render queue/cancel/byte-budget suites still green.

Rollback

Unset RENDER_WORKER_URL on the main service and render falls back in-process instantly. No data migration.

MikeyPetrillo and others added 11 commits July 18, 2026 22:59
Safe code fixes from the final re-audit. No payment path changed; all chains
keep selling.

F07 (secrets in CI install scope): the deploy.yml `test` job carried BRAVE /
FRED / FRED_V2 / E2B keys at JOB scope, so they were in the environment of
`npm ci`. Split install (secretless) from the server boot, and scoped those
four keys to the boot step only. (The deploy job runs no npm install — every
step is a trusted Railway GraphQL call — so its job-scope secrets are never
exposed to untrusted install code.)

F10 (waitlist PII in a GitHub issue URL): on a DB 503 or network error the
client opened /issues/new with the lead's name/email/org/message in the query
string and showed "success", contradicting the private-storage promise. Removed
the ghBody/ghUrl/window.open fallback entirely; it now fails CLOSED with a retry
message and no data leaves the page.

F09 (marketplace external-seller provenance): router (/api/route) and MCP
top_x402_sellers returned seller-controlled name/description/homepage with no
trust marker. Every external result now carries untrustedContent:true + source,
and the response carries containsUntrustedContent:true; local (self) results
stay unmarked. New test-marketplace-provenance.js includes the audit's exact
semantic-injection string as a mandatory bypass regression (returned as data,
correctly marked).

F20 (operator no-store): middleware before all /__operator routes sets
Cache-Control: no-store, private; Pragma: no-cache; Vary: Cookie, Authorization.

F24 (log injection): new src/log-safe.js strips CR/LF, ANSI/CSI, and C0/C1
control chars from attacker-controlled strings before they hit a log line;
applied to the wish cluster key and the MCP clientInfo telemetry.

F16 (Dockerfile.mcp): pinned the base by digest (matching the main image) and
run as the non-root node user; npm ci --ignore-scripts (mcp deps are pure JS).

Tests: test-marketplace-provenance (7), test-log-safe (7), test-security-headers
extended with the F20 + F10 assertions (16). Router + MCP e2e still green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cost/cancellation + byte-accounting hardening from the final re-audit. No
payment path changed; all chains keep selling.

F07 follow-up (fixes the Batch A CI break): full step-scoping of the test job's
provider keys broke steps that gate live-tool expectations on their own env
(the server had the key, the test step didn't -> expected 503, got 200). Keep
the keys at job scope so every trusted TEST step matches the booted server, but
SHADOW them to empty on the dependency-install step only — the sole step that
runs untrusted third-party code. That is the real F07 exposure.

F03 (renderer byte-limit bypass): byte accounting trusted Content-Length, so a
chunked / streamed / no-Content-Length response (and WebSocket frames) reported
zero and bypassed the 25MB/50MB caps. Now a CDP session counts ACTUAL bytes
(Network.dataReceived.encodedDataLength + WebSocket frames) per resource and per
page; crossing either cap trips the budget, aborts further route hops, AND
closes the context. Pure makeByteBudget() is unit-tested (streamed/aggregate/WS/
per-resource); a full real-Chromium RSS test belongs with the browser worker
(F02) since the SSRF guard blocks a localhost test origin.

F12 (E2B cost/output): stdout/stderr/result/traceback were unbounded. Added an
aggregate UTF-8 output budget (256KB / 1MB per tier) with an explicit truncation
marker (UTF-8-boundary safe), and a global sandbox concurrency ceiling
(E2B_MAX_CONCURRENT, default 8) refused BEFORE Sandbox.create.

F14 (MCP non-cancellation): the deadline race released the slot while
handleRequest kept running. Now an AbortController fires on deadline OR client
disconnect (aborts + closes the transport and reaches the handler via ctx.signal),
and the slot is released only AFTER the underlying run actually settles (bounded
by MCP_DRAIN_MS) — mcpInFlight no longer undercounts live work.

F15 (PostHog proxy): added an upstream timeout (AbortSignal.timeout), a global
in-flight ceiling, and streamed byte counting that aborts at the cap instead of
buffering the whole body via arrayBuffer().

Tests: test-render-bytebudget (8), test-code-run-caps (11). MCP e2e + mcp-limits
+ posthog-proxy + render-queue/cancel all still green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hardens the agent402-tollbooth OSS package (the pay-per-crawl gate other site
owners self-host). Not our production; no agent402.tools payment path changed.

F05 (non-atomic replay claim on the Worker): the Cloudflare KV single-use claim
was get-then-put (eventually consistent), so concurrent duplicate PoW solutions
across isolates/locations could BOTH pass. Added a Durable Object claim
(TollboothReplay): a DO instance serializes every request to one id through its
input gate, so the get-then-put inside it is atomic. worker.js prefers a bound
TOLLBOOTH_REPLAY Durable Object; falls back to KV with a loud enforcement
warning that it is best-effort; wrangler.toml documents the DO binding +
migration. edge.js now FAILS CLOSED — a claim-store outage denies rather than
propagates/grants.

F18 (proofs not origin-bound): resourceBaseUrl defaulted empty, so a proof for
/x on site A transferred to site B under a shared secret. Both paths now bind the
canonical ORIGIN (configured resourceBaseUrl, or the request origin on the edge /
Host on Node) plus the HTTP METHOD in the PoW binding; the x402 `resource` stays
a plain URL for wire compatibility.

F19 (verifier timeout doesn't cancel): the verifyX402 timeout raced but never
cancelled it, so a slow verifier that also settles could move money after a 402
was returned. Now an AbortSignal is passed to verifyX402 and aborted on timeout
(and on resolve). A pure-verification callback may ignore it; a settling one must
cancel/drain on it.

Tests: existing tollbooth suites (node + edge + 54 features) still green;
new test-tollbooth-replay.js (8) covers the DO single-use claim, the adapter
routing, and the origin/method binding. The 100-concurrent atomicity is a DO
runtime guarantee (documented) verifiable only in the real Cloudflare env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…blish

Audit F01 deployment-control-plane, the parts expressible in the repo:
- .github/CODEOWNERS declares @MikeyPetrillo owner of the CI/CD, container/deploy,
  and payment/replay/idempotency paths (enforcement is a repository ruleset —
  owner setting).
- deploy + publish jobs now reference the protected GitHub environment
  "agent402 / production". Today it only associates the job; once the owner
  configures a required reviewer + deploy-branch policy, prod mutations pause for
  approval and can use environment-scoped secrets.

No deploy marker: this is control-plane config that arms on the next deploy. The
required rulesets/reviewers and the environment deploy-branch policy (must allow
the deploy branch claude/sweet-brown-i99jl3, or move deploys to main) are
owner-configured GitHub settings, not code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…heme guard

F08 (broad Gitleaks allowlists hide future credentials): removed the blanket
`docs/.*` and `scripts/test-.*\.js` path excludes — a real key pasted into a doc
or test fixture would have passed the scan. Those paths are now FULLY scanned;
each intentional fake/public value is allowlisted exactly (the EVM-address regex,
LEAKCANARY, the a1b2c3 KDF example, the demo-JWT stopword). The tracked R-01
incident (a now-dead, rotated Stellar key in gitignored .remember/ history) is
scoped to its EXACT commit (70fcc51) instead of the whole path. Verified with
the pinned gitleaks 8.21.2: full-history + tree scans both green; removing the
commit allowlist re-surfaces exactly that one dead key (so the allowlist, not a
detection gap, is doing the suppression). New secret-scan.yml step plants a
detectable canary in docs/ and FAILS if gitleaks doesn't catch it — proving the
path is genuinely scanned, not silently excluded.

F23 (dormant legacy renderer accepts unsafe href schemes): the legacy indexPage
seller row emitted `href="${esc(homepage)}"` — esc() escapes HTML but not the
URL scheme, so a manifest homepage of `javascript:`/`data:` would become a
clickable link if that renderer is re-enabled (production /index redirects to
/marketplace, whose renderer already uses safeHref). Added a safeHref guard
(http(s) only, else `#`), mirroring market-page.js. test-index-page.js grows 4
assertions proving a javascript:/data: homepage renders inert while the seller
still lists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…OFF)

Owner-approved. Ships the secretless worker DISABLED by default, so production
is byte-identical until it is deployed and the flag is set. No payment path
changed; all chains keep selling.

The core of F02/F06 is that a Chromium or ffmpeg/ffprobe RCE from hostile
page/media content currently sits in the SAME container as the payment/DB/
operator/provider secrets. This moves that untrusted work into a separate
service that holds NONE of those secrets:

- worker/server.js — POST /call {slug,input} for render/screenshot/media-info/
  audio-convert/audio-normalize, reusing the exact in-process implementations.
  Constant-time bearer-token auth. A BOOT GUARD refuses to start if any
  payment/DB/operator/provider secret is present in its env, so the isolation
  can't be silently undone.
- src/worker-client.js — workerEnabled()/runOnWorker(). When RENDER_WORKER_URL
  is set, server.js's render/screenshot routes + the media tool handlers
  dispatch to the worker; UNSET (the default) runs in-process, unchanged.
- Dockerfile.worker — hardened base (non-root, digest-pinned, ffmpeg + Chromium,
  setuid stripped) running the worker.
- scripts/test-worker-isolation.js (13): worker auth/dispatch, client round-trip
  (incl. binary decode + error propagation), and the boot-guard refusal.

HONEST LIMITATION (documented in docs/worker-isolation-plan.md): Railway exposes
no seccomp/userns or egress firewall, so this does NOT enable Chromium's sandbox
(F04 sandbox half) or a network-layer egress policy — those stay platform gaps.
What it DOES close is the big one: the secrets are no longer in blast range of a
browser/parser compromise. Turn-on steps (create the Railway worker service with
no secrets + set RENDER_WORKER_URL/TOKEN) are in the plan doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ed on private mesh)

Confirmed live: both DATABASE_URL and ANALYTICS_DATABASE_URL point at
`*.railway.internal` (Railway PRIVATE networking), not a public proxy — so the
MITM scenario F11 warned about does not apply (traffic never leaves Railway's
encrypted internal mesh). The finding's real severity here is negligible.

The latent footgun was the code accepting ANY cert unconditionally
(`rejectUnauthorized: false`), so a future switch to a public `*.proxy.rlwy.net`
URL would be silently MITM-able. New src/db-ssl.js makes TLS route-aware:
- `*.railway.internal` (private mesh, current prod route) -> keeps
  rejectUnauthorized:false. Prod behaviour is BYTE-IDENTICAL.
- any public/proxied/unknown host -> rejectUnauthorized:true (verify the cert,
  FAIL CLOSED). `sslmode=disable` still opts out explicitly.

Applied to leads-db.js + analytics-db.js. test-db-ssl.js (6) locks the policy.
No payment path changed; all chains keep selling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Worker listens on :: (dual-stack) so a Railway worker service is reachable at
<service>.railway.internal. docs/f13-capacity-options.md tees up the F13
capacity decision (reserve-before-settle vs idempotent credit vs accept).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The root railway.toml pins dockerfilePath=Dockerfile, and Railway config-as-code
overrides the RAILWAY_DOCKERFILE_PATH env var — so the worker service built the
MAIN image. Pointing the agent402-worker service's config-file path at
railway.worker.json makes it build Dockerfile.worker. (One Railway dashboard
setting; the CLI can't set a per-service config-file path.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rker

Closes the Chromium DNS-rebinding / TOCTOU finding in CODE (Railway has no egress
firewall — `railway outbound-network` only manages the source IP, not
destinations). No payment path changed; in-process render is byte-identical
(the proxy only activates in the worker).

The gap: Chromium resolves DNS and connects on its own, so the app-layer route
guard validates a hostname but doesn't bind Chromium's socket to the checked IP
— a rebinding host can return a public IP to the validator and a private one to
Chromium's connect, reaching *.railway.internal / RFC1918 / loopback / metadata.

worker/egress-proxy.js is a validating + pinning forward proxy. Chromium is
launched with --proxy-server pointed at it (render.js, gated on
RENDER_EGRESS_PROXY_URL which only the worker sets), so the proxy is the ONLY
resolver + connector: it resolves each destination ONCE, refuses if ANY resolved
IP is private/reserved (isPrivateIp — now exported from fetch-guard, the same
classifier the SSRF guard uses, which also flags the fc00::/7 ULA that
*.railway.internal resolves to), and connects to that exact PINNED IP. Redirects
re-validate because Chromium re-issues them through the proxy. The
validate-then-rebind window is gone.

The worker starts the proxy at boot and sets RENDER_EGRESS_PROXY_URL before the
first render. test-egress-proxy.js (12): the resolver blocks
loopback/RFC1918/metadata/CGNAT/::1/ULA/link-local, and a live CONNECT to
loopback/RFC1918/metadata is refused 403. worker-isolation + render tests green.

Remaining F04-adjacent gap: the Chromium sandbox (--no-sandbox) still needs
userns/seccomp Railway doesn't expose. Non-root + secretless + this egress proxy
already remove the secret-theft and internal-pivot impact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t (fix failed build)

The worker service was building the MAIN Dockerfile and running node
src/server.js, which fails its healthcheck for lack of a wallet. Root cause: the
per-service "config file path" pointer was never set, so the worker fell back to
the root railway.toml (dockerfilePath = Dockerfile) instead of
railway.worker.json. Confirmed from the deployment manifest:
railwayConfigFile undefined, dockerfilePath "Dockerfile".

That pointer is fragile (silent wrong-server fallback). Replace it with a shared
image: both services build the same Dockerfile (railway.toml already pins every
service to it) and a tiny dispatcher, start.js, boots worker/server.js when
WORKER_MODE=true, else src/server.js. It imports (never spawns), so the gosu
privilege-drop entrypoint still owns PID 1 and gets SIGTERM for the drain.

- start.js: WORKER_MODE regex -> import the chosen server.
- Dockerfile: COPY start.js + worker/, CMD ["node","start.js"]. Main service
  (WORKER_MODE unset) boots src/server.js byte-identically; verified locally the
  main path serves /health with 505 tools and drains on SIGTERM, and the worker
  path (WORKER_MODE=true) serves /health {ok,tools:[render,screenshot,media-*]}
  with the F04 egress proxy up.
- worker/server.js: isMain now also true under WORKER_MODE (direct
  node worker/server.js still works for tests + Dockerfile.worker).
- test-worker-isolation.js: asserts the dispatch decision (17 passing).

Dockerfile.worker + railway.worker.json remain a valid alternative but are no
longer required. Activation is now one env var on the worker service:
WORKER_MODE=true. Main service unchanged (WORKER_MODE unset).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@MikeyPetrillo
MikeyPetrillo marked this pull request as ready for review July 19, 2026 12:01
@MikeyPetrillo
MikeyPetrillo merged commit 2086d6b into main Jul 19, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant