Skip to content

Security review: close coverage gaps (DNS rebinding, supply chain, builtin desync, browser isolation)#86

Merged
NathanFlurry merged 3 commits into
mainfrom
security/coverage-gaps
Jun 19, 2026
Merged

Security review: close coverage gaps (DNS rebinding, supply chain, builtin desync, browser isolation)#86
NathanFlurry merged 3 commits into
mainfrom
security/coverage-gaps

Conversation

@NathanFlurry

@NathanFlurry NathanFlurry commented Jun 19, 2026

Copy link
Copy Markdown
Member

Closes the four security-review coverage gaps from VECTORS.md. Each gap got an adversarial test that plays an untrusted guest; one was a real, guest-reachable break and is fixed. Based on main (#79 merge).

Gap 1 — DNS rebinding (D.3): REAL FINDING, FIXED (F-011, High)

The Python/Pyodide httpRequestSync outbound HTTP path (crates/sidecar/src/execution.rs::handle_python_http_rpc_requestissue_outbound_http_request) resolved the request hostname once and ran the resolved IPs through the egress range guard (filter_dns_safe_ip_addrs). For http it rewrote the URL host to the vetted IP, but for https it handed the original hostname URL to ureq, which performs its own second DNS resolution for the TCP/TLS connect. A guest-controlled rebinding DNS server returns a public IP for the host's check and a private/link-local/metadata IP (e.g. 169.254.169.254) for ureq's connect — reaching cloud metadata / internal services. Literal-IP requests (https://169.254.169.254/) also skipped filter_dns_safe_ip_addrs entirely. Reachable from any Pyodide guest via fetch/micropip (the policy-vetted bridge whose transport this is).

Fix: pin ureq's resolver to the egress-vetted address set (refusing any other host or an empty/fully-rejected set), route literal IPs through the same range guard, and keep the URL hostname so TLS SNI / Host stays correct. Tests: dns_rebinding_pin_tests (split_netloc_*, outbound_http_connect_is_pinned_to_vetted_ip, outbound_http_refuses_when_no_vetted_address).

Note: the direct net.connect TCP, UDP, and HTTP/2 paths were checked and are already correctly pinned — they resolve once, filter, pick one SocketAddr, and connect to that exact address (no re-resolution).

Gap 2 — Supply chain (I.1/I.3): DEFENDS-CORRECTLY

Module materialization (node_import_cache.rs) writes bundled files only — no install/lifecycle/postinstall script execution, no host process spawn. The Pyodide package base URL / micropip fetch is guest-configurable but is forced through the policy-obeying bridge.httpRequestSyncrequire_network_access egress gate (no direct host fetch / SSRF). The shared transport is the same one hardened in Gap 1, so the egress IP is now pinned there too.

Gap 3 — Builtin allow/deny desync (A.2): DEFENDS-CORRECTLY

Added an adversarial test that, with an allow-list excluding dns, requires every guest resolution path on the live shared V8 runtime (require / createRequire / process.getBuiltinModule / dynamic import) to reject both dns and the sub-path specifier dns/promises. The V8 path funnels through a single loadBuiltinModule that gates by root name (split('/')[0]), so it denies consistently. Test: javascript_execution_denies_dns_and_subpaths_on_every_resolution_path.

Note: the host-Node NODE_EXECUTION_RUNNER_SOURCE in node_import_cache.rs carries a stale DENIED_BUILTINS set missing dns/promises, but that runner is dead for guest JS (only the python/wasm runner paths are read), so it is not guest-reachable — no fix forced into the dead copy.

Gap 4 — Browser isolation (E2): DEFENDS-CORRECTLY

Prior E2 findings were false positives; re-examined the secure-exec-side parts.

  • TS frame-length cap: tryDecodeLengthPrefixedPayload never allocates/slices on the declared length — it only emits a payload once that many real bytes have arrived, so an oversized declared length (0xFFFFFFFF) just reports "incomplete". The browser sync-bridge read (worker.ts readBytesdata.slice(0,len)) is clamped by the SharedArrayBuffer view and the write side rejects oversized payloads. Added adversarial unit tests in packages/core/tests/framing.test.ts.
  • OPFS per-tenant namespacing: the browser runtime is single-tenant-per-origin; OPFS is origin-scoped by the browser itself and there is no multi-tenant-per-origin multiplexing in this code, so the "tenant A reads tenant B same-origin" scenario is not reachable (browser-enforced origin isolation). No fix.

Verdicts

  • D.3 DNS rebinding: REAL FINDING → FIXED (F-011, High)
  • I.1/I.3 supply chain: DEFENDS-CORRECTLY
  • A.2 builtin desync: DEFENDS-CORRECTLY
  • E2 browser isolation: DEFENDS-CORRECTLY

NathanFlurry and others added 3 commits June 19, 2026 02:33
…ding D.3)

The Python/Pyodide httpRequestSync outbound HTTP path resolved the hostname
through the kernel resolver and filtered the result through the egress range
guard (filter_dns_safe_ip_addrs), but for https:// it then handed the original
hostname URL to ureq, which performs its own DNS resolution for the TCP/TLS
connect. A rebinding DNS server could make that second lookup return a
private/link-local/metadata IP (e.g. 169.254.169.254) that the first check
rejected -> guest-reachable SSRF (F-011, High). Literal-IP requests also skipped
filter_dns_safe_ip_addrs entirely.

Fix: validate literal IPs through the same range guard, resolve hostnames once,
and pin ureq's resolver to the vetted address set (refusing any other host or an
empty set) while keeping the URL hostname for TLS SNI / Host header. Adds
adversarial unit tests proving the connect is pinned to the vetted IP and that a
fully-rejected address set is refused.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hs (A.2)

Adversarial coverage for the builtin allow/deny desync (VECTORS.md A.2): with an
allow-list that excludes 'dns', every guest resolution path on the live shared
V8 runtime (require / createRequire / process.getBuiltinModule / dynamic import)
must reject both 'dns' and the sub-path specifier 'dns/promises'. The V8 path's
single loadBuiltinModule funnel (rejectRestrictedBuiltinRequest, gating by root
name split('/')[0]) denies them consistently -> DEFENDS-CORRECTLY, no fix.

Note: the host-Node NODE_EXECUTION_RUNNER_SOURCE in node_import_cache.rs carries
a stale DENIED_BUILTINS set missing 'dns/promises', but that runner is dead for
guest JS (only python/wasm runner paths are read), so it is not guest-reachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ion E2)

The prior E2 review findings were unconfirmed false positives. Re-examined the
secure-exec-side browser parts:

- TS frame-length cap: tryDecodeLengthPrefixedPayload (packages/core/src/
  framing.ts) never allocates/slices on the *declared* length; it only emits a
  payload once that many real bytes have arrived, so an oversized declared
  length (0xFFFFFFFF) just reports 'incomplete'. The browser sync-bridge read
  (worker.ts readBytes -> data.slice(0,len)) is clamped by the SharedArrayBuffer
  view and the write side rejects oversized payloads. DEFENDS-CORRECTLY; added
  adversarial unit tests in packages/core/tests/framing.test.ts.

- OPFS per-tenant namespacing: the browser runtime (packages/browser/src/
  driver.ts) is single-tenant-per-origin; OPFS is origin-scoped by the browser
  itself, and there is no multi-tenant-per-origin multiplexing in this code, so
  the 'tenant A reads tenant B same-origin' scenario is not reachable.
  DEFENDS-CORRECTLY by design (browser-enforced origin isolation). No fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@railway-app

railway-app Bot commented Jun 19, 2026

Copy link
Copy Markdown

🚅 Environment secure-exec-pr-86 in rivet-frontend has no services deployed.

@NathanFlurry NathanFlurry merged commit 3e84608 into main Jun 19, 2026
1 check failed
@NathanFlurry NathanFlurry deleted the security/coverage-gaps branch June 19, 2026 09:37
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