Security review: close coverage gaps (DNS rebinding, supply chain, builtin desync, browser isolation)#86
Merged
Merged
Conversation
…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>
|
🚅 Environment secure-exec-pr-86 in rivet-frontend has no services deployed. |
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.
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
httpRequestSyncoutbound HTTP path (crates/sidecar/src/execution.rs::handle_python_http_rpc_request→issue_outbound_http_request) resolved the request hostname once and ran the resolved IPs through the egress range guard (filter_dns_safe_ip_addrs). Forhttpit rewrote the URL host to the vetted IP, but forhttpsit handed the original hostname URL toureq, 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) forureq's connect — reaching cloud metadata / internal services. Literal-IP requests (https://169.254.169.254/) also skippedfilter_dns_safe_ip_addrsentirely. Reachable from any Pyodide guest viafetch/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.connectTCP, UDP, and HTTP/2 paths were checked and are already correctly pinned — they resolve once, filter, pick oneSocketAddr, 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 /micropipfetch is guest-configurable but is forced through the policy-obeyingbridge.httpRequestSync→require_network_accessegress 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/ dynamicimport) to reject bothdnsand the sub-path specifierdns/promises. The V8 path funnels through a singleloadBuiltinModulethat 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_SOURCEinnode_import_cache.rscarries a staleDENIED_BUILTINSset missingdns/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.
tryDecodeLengthPrefixedPayloadnever 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.tsreadBytes→data.slice(0,len)) is clamped by the SharedArrayBuffer view and the write side rejects oversized payloads. Added adversarial unit tests inpackages/core/tests/framing.test.ts.Verdicts