Skip to content

[APPS-2792] Add: runtime network/subprocess guard for local execution - #484

Draft
tyffical wants to merge 5 commits into
tiffany.trinh/apps-2792-harden-local-execution-v2from
tiffany.trinh/apps-2792-runtime-network-guard
Draft

[APPS-2792] Add: runtime network/subprocess guard for local execution#484
tyffical wants to merge 5 commits into
tiffany.trinh/apps-2792-harden-local-execution-v2from
tiffany.trinh/apps-2792-runtime-network-guard

Conversation

@tyffical

@tyffical tyffical commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Motivation

Architecture

net.Socket.prototype.connect, globalThis.fetch, and child_process's spawn/exec/execSync are real, process-wide singletons — network-guard.ts monkey-patches them directly rather than sandboxing the customer's module, since there's no process boundary to sandbox with. That makes the guard a single shared piece of mutable state (allowDepth + the saved originals) threaded through one execution's lifetime:

runScriptLocally
      │
      ▼
┌───────────────────────────────┐
│ runBlocked(fn)                │  applyPatches()
│ BLOCKED: net.Socket.connect,  │  → net.Socket.connect   throws
│ fetch, child_process all      │  → fetch                rejects
│ throw/reject                  │  → spawn/exec/execSync  throw
└───────────────┬────────────────┘
                │  customer's fn() runs
                ▼
  fn() calls $.Actions.a() and $.Actions.b() concurrently (Promise.all)
                │
      ┌─────────┴──────────┐
      ▼                    ▼
 runAllowed(a)         runAllowed(b)
 allowDepth 0→1        allowDepth 1→2
 restorePatches()      (already restored — no-op)
      │                    │
      ▼                    ▼
┌────────────────────────────────────┐
│ ALLOWED (allowDepth > 0)           │
│ real net/fetch/spawn restored —    │
│ only inside executeAction          │
└──────┬───────────────────────┬─────┘
       │ b resolves first      │ a still in flight
       ▼                       │
 allowDepth 2→1                │
 (still > 0 → stays ALLOWED) ──┘
       │
       │ a resolves
       ▼
 allowDepth 1→0 → applyPatches() → BLOCKED again
       │
       │  fn() returns
       ▼
 runBlocked's finally: restorePatches()
       │
       ▼
   UNBLOCKED (real functions, for whatever
   the dev server does next)

The ref-count (allowDepth), not a boolean, is what makes the overlap safe: two concurrent $.Actions calls each bump it on entry and drop it on exit, and the guard only re-blocks once the last one exits — a boolean would re-block the instant the faster of two overlapping calls finished, breaking the slower one mid-flight.

runBlocked/runAllowed's own try/finally only unwinds when fn actually settles. runScriptLocally's timeout wraps the whole thing in Promise.race([run(), timeout]), which abandons rather than cancels the loser — a customer function that never resolves means run() (and the runBlocked inside it) never reaches its finally, so without a separate backstop the block would stay applied for the rest of the process once the timeout fires. forceReset() is that backstop: called directly from the timer callback (unconditionally restoring the real functions and zeroing allowDepth) the moment the timeout fires, independently of whether the abandoned run() ever settles. The same function is used as a Jest afterEach in network-guard.test.ts/local-execution.test.ts, for the identical reason at the test level — these are real Node singletons, not per-test-file sandboxed state, so a test that leaves them patched leaks into every test that runs after it in the same Jest worker, including unrelated test files.

Changes

What changed File
New runBlocked(fn): monkey-patches net.Socket.prototype.connect, fetch, and child_process's spawn/exec/execSync to throw/reject for the duration of fn, restoring the real implementations in a finally regardless of how fn completes. network-guard.ts
New runAllowed(fn): temporarily restores real network access for the duration of fn, ref-counted (not a boolean) so two $.Actions calls overlapping within a single execution (e.g. inside a Promise.all) don't re-block network on each other mid-flight. network-guard.ts
New forceReset(): unconditionally restores the real functions and zeroes allowDepth, independent of runBlocked/runAllowed's own finally — the backstop for a fn that's abandoned (timeout) or a test that fails to clean up after itself. network-guard.ts
runScriptLocally now wraps the customer's function call (only — not the loadModule/registration calls before it, which need no network) in runBlocked, and calls forceReset() from the timeout timer itself so an abandoned, still-running hung function can't leave network/subprocess access blocked for the rest of the process. local-execution.ts
makeActionsProxy's apply trap now wraps its executeAction call in runAllowed — the one sanctioned network path, exempted from the block. local-execution.ts
Unit tests for every patched target and both directions (block + restore, restore-on-throw, no state leak across separate runBlocked calls, nested runAllowed exemption, concurrent-overlap ref-counting, re-block-on-throw). A Jest afterEach calls forceReset() unconditionally as a hard safety net, independent of any test's own cleanup. network-guard.test.ts
Integration tests confirming the guard is actually wired into executeScriptLocally: a customer function using raw net/fetch/child_process is rejected; a real $.Actions call still succeeds; network is restored after the execution finishes, including after a timeout abandons a hung function; two real $.Actions calls made concurrently via Promise.all keep network allowed through the entire overlap, exercised through the real executeScriptLocallymakeActionsProxy path (not just the unit-level runAllowed). Same afterEach safety net as above. local-execution.test.ts

QA Instructions

yarn install
yarn test:unit packages/plugins/apps/src/vite/network-guard.test.ts
# Expected: Test Suites: 1 passed / Tests: 11 passed ✅ VERIFIED
yarn test:unit packages/plugins/apps/src/vite/local-execution.test.ts
# Expected: Test Suites: 1 passed / Tests: 27 passed ✅ VERIFIED
yarn test:unit
# Full monorepo suite, including the previously-flaky rollupConfig.test.ts —
# run --runInBand to force worst-case single-worker scheduling (this is how
# the leak this PR fixes was reproduced).
# Expected: Test Suites: 82 passed / Tests: 1923 passed, 1 skipped ✅ VERIFIED
yarn workspace @dd/apps-plugin run typecheck
# Expected: no output, clean exit ✅ VERIFIED
npx eslint packages/plugins/apps/src/vite/network-guard.ts packages/plugins/apps/src/vite/network-guard.test.ts packages/plugins/apps/src/vite/local-execution.ts packages/plugins/apps/src/vite/local-execution.test.ts --quiet
# Expected: no output, clean exit ✅ VERIFIED

Coverage note: this repo's Jest collectCoverageFrom CLI flag didn't produce a usable per-file report for either new/changed file in this environment (pre-existing tooling quirk, not introduced by this change — the coverage table only ever listed _jest helper files regardless of the glob passed). Manually verified every branch in network-guard.ts is exercised by at least one test.

No manual local/staging QA beyond the automated tests above: this module still isn't reachable from a real npm run dev session (that's #481, already merged into this stack's ancestry but not yet released).

Blast Radius

  • No behavior change for any currently-shipping code path — same as [APPS-2792] Add: in-process local execution for backend functions #479/[APPS-2792] Add: harden the in-process local execution path #480, this stack isn't released yet.
  • Scoped precisely to the duration of a local execution's customer-function call; the dev server's own network use (before/after that window, and anything unrelated to local-execution.ts) is never touched.
  • forceReset() on timeout narrows, rather than eliminates, an existing gap: the abandoned (not cancelled) hung function keeps running with real network access restored early rather than staying blocked forever — bounded to that one already-abandoned execution, versus the alternative of leaving every future execution in the same dev server process permanently blocked until restart.
  • Risk: low. Additive, defense-in-depth only — closes a gap that only matters for local-dev-loop safety/prod-parity, not a new production security boundary (production's own Deno sandbox is unaffected and remains the real boundary).

Out of Scope / Follow-ups

Item Status Next step
Native addon bypassing Node's JS-level net stack entirely Accepted residual gap Narrower and rarer than the pure-JS case this closes (most native modules are for CPU-bound work, not networking) — not worth the false-positive risk of blocking native addon loading outright
dns.lookup interception Out of scope Low realistic benefit for this threat model (dev-loop safety, not defending against deliberate DNS-tunneling exfiltration) — would risk breaking legitimate hostname validation for no real gain
A hung customer function is abandoned, not cancelled, on timeout — it keeps running in the background with real network access restored (see Blast Radius) Accepted residual gap Would need real cancellation (e.g. an AbortSignal threaded through the customer's own function, which we don't control) or re-architecting local execution onto a worker thread that can be killed outright — bigger change than this PR's scope

Documentation

Closes a gap the build-time checks in ast-parsing/ can't reach: those
only scan the customer's own .backend.ts file, so a third-party
dependency's own net/http/fetch usage (e.g. a Postgres/Redis client)
was invisible to them, and nothing else stopped it once local
execution moved in-process (no Deno, no process boundary).

Blocks net.Socket.prototype.connect, fetch, and child_process's
spawn/exec/execSync for the duration of a local execution, exempting
only the internal $.Actions call via a ref-counted allow scope (so
concurrent $.Actions calls within a single execution don't fight over
re-blocking).

Co-Authored-By: Claude <noreply@anthropic.com>
@datadog-prod-us1-6

datadog-prod-us1-6 Bot commented Aug 8, 2026

Copy link
Copy Markdown

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 7d6f536 | Docs | Datadog PR Page | Give us feedback!

…xecutions

Promise.race in runScriptLocally abandons whichever of run()/timeout
loses without cancelling it. A hung customer function (its promise
never settling) meant runBlocked's own finally never ran, leaving
net.Socket.connect/fetch/child_process patched to throw for the rest
of the process — poisoning every later local execution, and in CI,
leaking into unrelated test files that happened to run afterward in
the same Jest worker (e.g. rollupConfig.test.ts's real esbuild spawn).

forceReset() is a hard backstop independent of runBlocked/runAllowed's
own try/finally: it unconditionally restores the real functions and
zeroes the ref-count. runScriptLocally calls it directly from the
timeout timer, and network-guard.test.ts/local-execution.test.ts now
call it in an afterEach regardless of test outcome, since these are
real process-wide Node singletons, not per-test-file sandboxed state.
…ocally

network-guard.test.ts already proves runAllowed's ref-counting at the
unit level, calling it directly. Adds the same proof through the real
path a customer's code takes: executeScriptLocally's Promise.all of
two $.Actions calls, through makeActionsProxy's apply trap, with the
mocked ExecuteAction making its own real fetch call to stand in for
the network call the dev server's own implementation makes — network
must stay allowed for the slower call the entire time the faster one
is finishing and re-blocking.

Also adds a regression test for the timeout/forceReset fix, and the
same afterEach safety net as network-guard.test.ts.
local-execution.test.ts and dev-server.test.ts (build-plugins#481) each
defined their own near-identical LoadModule resolver double. Factor the
common resolve-or-throw logic into moduleResolverFor in the shared mocks
helper so both can build on it instead of duplicating it.
tyffical added a commit that referenced this pull request Aug 10, 2026
dev-server.test.ts and local-execution.test.ts (build-plugins#480/#484)
each defined their own near-identical LoadModule resolver double. Factor
the common resolve-or-throw logic into moduleResolverFor in the shared
mocks helper so both can build on it instead of duplicating it.
…function body

server.ssrLoadModule(func.absolutePath) goes through the same transform
hook (vite/index.ts) that rewrites *.backend.ts into the client-side
RPC-proxy stub — so local execution's "real" import can actually still be
the proxy stub, which crashes since globalThis.DD_APPS_RUNTIME doesn't
exist server-side. Every existing test here mocks loadModule directly, so
none of them exercise the real transform pipeline and would catch this.

Append the same query-suffix marker introduced in #481 (matching Vite's
own ?raw/?url convention) so the shared transform hook can recognize this
specific request and skip proxy generation for it. The transform-hook
side of this fix lives in #481, since that's where local execution is
actually wired to a real, plugin-registered dev server — this PR only
needs its own call site and mocks to stay consistent with that contract
so the two branches reconcile cleanly whichever merges first.
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