Skip to content

Security review fixes (secure-exec)#79

Merged
NathanFlurry merged 9 commits into
mainfrom
security/runtime-fixes-main
Jun 19, 2026
Merged

Security review fixes (secure-exec)#79
NathanFlurry merged 9 commits into
mainfrom
security/runtime-fixes-main

Conversation

@NathanFlurry

@NathanFlurry NathanFlurry commented Jun 19, 2026

Copy link
Copy Markdown
Member

Re-applies the secure-exec security-review fixes cleanly on current main (e118ec8). Each fix was re-derived against current code (post JS-runtime-platform / Bun-CBOR-IPC refactors), implemented as its own commit with a verifying adversarial test that passes. Expensive saturation variants stay env-gated; the safeguard tests run by default and are watchdog-bounded.

Supersedes #76, which was cut from the abandoned split/runtime-preview base and showed an unusable 2247-file diff.

Fixes

  • F-001 — opt-in TRUE CPU-time budget for guest JS (no default). A guest while(true){} pinned a core on the shared, slot-bounded V8 runtime and starved peers. This PR adds an opt-in CPU-time budget watchdog: a thread that samples the execution thread's per-thread CPU clock (pthread_getcpuclockidclock_gettime, ~50ms poll) and, when accumulated active-JS CPU time exceeds the budget, calls Isolate::terminate_execution() and signals CpuBudgetExceeded (reported as ERR_SCRIPT_CPU_BUDGET_EXCEEDED). Because a thread's CPU clock does not advance while parked/awaiting, this excludes I/O/idle wait — only active JS CPU counts (the standard embedder pattern, since V8 has no native budget). The budget is set via AGENT_OS_V8_CPU_TIME_LIMIT_MS (true CPU ms); unset/0 ⇒ watchdog NOT armed (no limit). The earlier 30s default has been removed — there is no default, so a guest is CPU-bounded only when the operator/platform sets the env. Tests: javascript_infinite_loop_is_terminated_by_cpu_watchdog (budget set ⇒ tight loop killed, cpu-budget reason), javascript_awaiting_guest_is_not_killed_by_cpu_budget (budget set, mostly-awaiting guest past the budget window is not killed — proves idle/await excluded), javascript_no_cpu_budget_when_env_unset (no env ⇒ not armed, not time-limited).
  • F-002 — enforce configured WASM stack byte limit. AGENT_OS_WASM_MAX_STACK_BYTES was plumbed but never read (dead cap). The engine now parses it (rejecting malformed values) and the WASM runner installs a stack-exhaustion guard around wasi.start that terminates nonzero and attributes the failure to the configured budget. Test: wasm_deep_recursion_respects_configured_stack_byte_limit.
  • F-003 — V8 near-heap-limit OOM guard. Hitting AGENT_OS_V8_HEAP_LIMIT_MB triggered V8's fatal-OOM abort (SIGTRAP), crashing the process-global runtime and every concurrent tenant. install_heap_limit_guard now registers a near-heap-limit callback on every heap-capped isolate (fresh + snapshot-restore paths) that terminates the offending isolate and grants headroom so V8 propagates the termination cleanly. Test: javascript_heap_allocation_bomb_is_capped_by_oom_guard.
  • F-004 — default WASM wall-clock timeout when no fuel env. resolve_wasm_execution_timeout returned None without AGENT_OS_WASM_MAX_FUEL, so wait() never terminated a never-returning module. Now applies a default 30s budget when no fuel env is set. Test: wasm_execution_timeout_defaults_to_bounded_value_without_fuel_env.
  • F-005 — SSRF: unspecified + CGNAT. Classifier returned None for 0.0.0.0/:: (which route to host loopback) and CGNAT 100.64.0.0/10. Both are now classified restricted and denied with EACCES. Test: classifier_denies_unspecified_and_cgnat_targets.
  • F-006 — SSRF: IPv4-compatible IPv6. Classifier canonicalized only via to_ipv4_mapped(), missing IPv4-compatible spellings (::169.254.169.254). Added ipv4_compatible_embedded() so ::a.b.c.d canonicalizes to its embedded IPv4 and is classified (::/::1 excluded). Test: classifier_denies_ipv6_spelled_metadata_addresses.
  • F-007 — SSRF: reserved + multicast. Added 240.0.0.0/4 (reserved, incl. broadcast) and 224.0.0.0/4 (multicast) to the classifier. Test: classifier_denies_reserved_and_multicast_targets.

Not in this PR

  • Wall-clock execution backstop (deferred). F-001 here is a CPU-time budget only. There is intentionally no wall-clock limit in this PR: the prior 30s wall-clock default is removed, and no wall-clock env knob is added. The existing TimeoutGuard (wall-clock timer) is left in place but dormant — not armed by F-001. A follow-up PR will re-introduce a wall-clock backstop as its own separate opt-in knob.
  • F-008 / F-009 (browser fetch credentials + network-policy host/port) belong in the agent-os repo. The live browser network adapter is @rivet-dev/agent-os-browser in agent-os; secure-exec's packages/browser (@secure-exec/browser) is an orphaned copy not depended on by anything in the secure-exec workspace. Per the boundary (secure-exec stays free of agent/browser concerns), these are not applied here.

Verification

cargo build --workspace green. javascript_v8_suite (incl. the three F-001 CPU-budget tests), wasm_suite, the sidecar ssrf_egress_classifier_tests, and the F-004 unit test all pass.

Note: with no default, guest JS CPU is uncapped unless the platform/operator sets AGENT_OS_V8_CPU_TIME_LIMIT_MS.

NathanFlurry and others added 6 commits June 19, 2026 01:08
…limit

Guest inline JS (e.g. while(true){}) ran unbounded on the shared, slot-bounded
V8 runtime because register_v8_session hardcoded cpu_time_limit_ms: 0 at every
CreateSession (0 normalizes to None, so no TimeoutGuard was armed), letting a
guest pin a CPU core and starve peers.

Resolve a default CPU-time budget (V8_DEFAULT_CPU_TIME_LIMIT_MS = 30000ms,
overridable/disable-able via AGENT_OS_V8_CPU_TIME_LIMIT_MS) and thread it through
register_v8_session into the CreateSession frame so the existing watchdog pipeline
arms a TimeoutGuard.

Adds bounded safeguard test javascript_infinite_loop_is_terminated_by_cpu_watchdog.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The operator-configured AGENT_OS_WASM_MAX_STACK_BYTES budget was plumbed into
the WASM runner env but never read by the engine, so the cap was silently
ignored (dead): runaway recursion only ever tripped V8's generic default native
stack guard with no attribution to the configured limit.

The engine now parses the stack byte budget (rejecting malformed values like
fuel/memory) and the WASM runner installs a stack-exhaustion guard around
wasi.start: when a stack byte limit is configured and the guest exhausts the
stack, the runner terminates nonzero and attributes the failure to the
configured budget instead of leaking the engine's generic default-guard message.

Adds bounded SAFEGUARD test wasm_deep_recursion_respects_configured_stack_byte_limit.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…minate isolate instead of fatal-aborting the shared process

Hitting the operator-configured AGENT_OS_V8_HEAP_LIMIT_MB cap previously triggered
V8's default fatal-OOM abort (SIGTRAP). Because the V8 runtime is process-global,
one tenant's heap bomb crashed the whole process and every concurrent tenant
(guest-triggerable cross-tenant host DoS).

create_isolate now installs a near-heap-limit callback (via the new
install_heap_limit_guard helper) on every isolate created with a heap cap,
covering both the fresh-isolate and snapshot-restore paths. On approaching the
cap the callback terminates the offending isolate's execution and returns a
small headroom bump so V8 can propagate the uncatchable termination exception
cleanly instead of aborting. The guest run ends with a nonzero exit.

Adds bounded SAFEGUARD test javascript_heap_allocation_bomb_is_capped_by_oom_guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
… no fuel env is set

resolve_wasm_execution_timeout returned None unless AGENT_OS_WASM_MAX_FUEL was
set, so wait() gated termination behind Some(limit) and a never-returning guest
module (e.g. an infinite loop) pinned a host CPU core forever under default
config, starving other tenants on the shared process.

It now applies a default wall-clock budget (DEFAULT_WASM_EXECUTION_TIMEOUT_MS =
30000ms) when no fuel env is configured; operators can still set
AGENT_OS_WASM_MAX_FUEL explicitly for a tighter or looser bound.

Adds bounded SAFEGUARD unit test wasm_execution_timeout_defaults_to_bounded_value_without_fuel_env.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…100.64/10 as restricted egress in SSRF classifier

A guest with network grants could connect to 0.0.0.0 (which Linux routes to
127.0.0.1) and bypass the loopback port-ownership gate, since the egress
classifier returned None for the unspecified address and is_loopback_ip was
false. CGNAT 100.64.0.0/10 was likewise unclassified.

restricted_non_loopback_ip_range now classifies the IPv4/IPv6 unspecified
addresses and the CGNAT block as restricted so they are denied with EACCES.

Adds bounded SAFEGUARD test classifier_denies_unspecified_and_cgnat_targets.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…) in SSRF classifier so ::169.254.169.254 is blocked

The classifier canonicalized IPv6 via to_ipv4_mapped() only, which returns None
for IPv4-compatible addresses (::a.b.c.d), so a guest could spell the cloud-
metadata address as ::169.254.169.254 and bypass the 169.254.0.0/16 link-local
block.

Adds ipv4_compatible_embedded() and consults it before the IPv6 fallthrough so
IPv4-compatible spellings canonicalize back to their embedded IPv4 address and
are classified; :: and ::1 are excluded so they fall through to the
unspecified/loopback paths.

Adds bounded SAFEGUARD test classifier_denies_ipv6_spelled_metadata_addresses.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@railway-app

railway-app Bot commented Jun 19, 2026

Copy link
Copy Markdown

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

NathanFlurry and others added 2 commits June 19, 2026 01:42
…ges in SSRF egress classifier

restricted_non_loopback_ip_range omitted 240.0.0.0/4 (reserved/future-use,
including the 255.255.255.255 broadcast) and 224.0.0.0/4 (multicast), so a guest
connect to e.g. 240.0.0.1 was attempted instead of denied with EACCES.

Both ranges are now classified as restricted (IPv4-compatible IPv6 spellings
canonicalize through the F-006 path and are denied too).

Adds bounded SAFEGUARD test classifier_denies_reserved_and_multicast_targets.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…t); remove 30s wall-clock default; wall-clock backstop deferred
@NathanFlurry
NathanFlurry force-pushed the security/runtime-fixes-main branch from 7164afd to fa0cebb Compare June 19, 2026 08:50
@NathanFlurry
NathanFlurry merged commit f1d9be2 into main Jun 19, 2026
1 check failed
@NathanFlurry

Copy link
Copy Markdown
Member Author

Companion to #79 (security/runtime-fixes-main): #86 closes the remaining security-review coverage gaps, based on top of this merge.

  • DNS rebinding (D.3) — REAL FINDING, FIXED (F-011, High): the Python/Pyodide httpRequestSync HTTPS path handed the hostname to ureq, which re-resolved it for the connect, bypassing the egress range guard this PR's restricted_non_loopback_ip_range work feeds. Security review: close coverage gaps (DNS rebinding, supply chain, builtin desync, browser isolation) #86 pins ureq's resolver to the egress-vetted IP set (and runs literal IPs through the same guard). The CGNAT/unspecified/multicast/reserved/IPv6-compat classifier added here is complementary — the pin ensures the connect actually lands on the address that classifier vetted.
  • Supply chain (I.1/I.3), builtin desync (A.2), browser isolation (E2): DEFENDS-CORRECTLY with new adversarial tests.

See #86 for per-gap detail.

NathanFlurry added a commit that referenced this pull request Jun 19, 2026
…et) (#89)

Deferred follow-up to #79's CPU-only F-001. Re-wires the dormant wall-clock
TimeoutGuard as an INDEPENDENT, opt-in limit alongside the CPU-time budget.

New env knob AGENT_OS_V8_WALL_CLOCK_LIMIT_MS (>0 to arm; unset/0 => off, no
default). Unlike the CPU budget, the wall-clock backstop counts elapsed real
time INCLUDING idle/await, so it can cap a guest that blocks or awaits
indefinitely. Both guards can be armed at once; whichever fires first calls
terminate_execution and the result frame reports which (ERR_SCRIPT_WALL_CLOCK_EXCEEDED
vs ERR_SCRIPT_CPU_BUDGET_EXCEEDED). Off by default so long-lived ACP adapters
are never killed by a wall-clock default; the CPU budget remains the primary guard.

Threaded through the CreateSession/Start frame next to the CPU budget knob.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
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