3rd-audit remediation (F01-F25) + secretless browser/media worker (live)#456
Merged
Conversation
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
marked this pull request as ready for review
July 19, 2026 12:01
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.
Remediates the 3rd security audit (findings F01-F25) and ships the secretless browser/media worker isolation, now live in production.
Audit remediation (F01-F25)
npm ciruns secretless).top_x402_sellerscarryuntrustedContent/source, responses carrycontainsUntrustedContent.dataReceived+ WS) instead ofContent-Length. F12 E2B output cap + concurrency ceiling. F14 MCP abort + await-before-release. F15 PostHog proxy timeout + concurrency + streamed byte cap.verifyX402AbortSignal..github/CODEOWNERS+environmentref on the deploy/publish jobs. Owner half (GitHub ruleset + protected environment) is a dashboard action, tracked separately.src/db-ssl.js): verify on public routes, relaxed on the*.railway.internalprivate 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
/datamount, 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 whenRENDER_WORKER_URLis set; unset means in-process (the default).worker/server.jsboots secretless with a FORBIDDEN_ENV guard that refuses to start if any high-value secret is present. Constant-time bearer auth.start.jsis a shared-image dispatcher: both services build the sameDockerfile;WORKER_MODE=trueboots the worker. This replaces the per-service config-file pointer, which silently fell back to the main server when unset.worker/egress-proxy.js: Chromium launches with--proxy-serverat a validating + pinning forward proxy that is the only resolver and connector. It resolves each destination once, refuses any private/reserved/metadata IP or thefc00::/7ULA that*.railway.internaluses, and connects to the pinned IP. Redirects re-validate.Remaining platform gap: Chromium
--no-sandboxstill 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_URLon the main service and render falls back in-process instantly. No data migration.