Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/plugins/apps/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ export const PLUGIN_NAME: PluginName = 'datadog-apps-plugin' as const;
export const APPS_API_PATH = 'api/unstable/app-builder-code/apps';
export const ARCHIVE_FILENAME = 'datadog-apps-assets.zip';
export const BACKEND_FILE_RE = /\.backend\.(ts|tsx|js|jsx)$/;

/**
* Query suffix local execution appends to its `loadModule()`/`ssrLoadModule()`
* call so the transform hook below can tell "give me the real function body to
* run in-process" apart from a normal frontend import of the same file (which
* needs the client-side RPC-proxy stub instead). Follows Vite's own `?raw`/
* `?url`-style query-suffix convention rather than branching on the generic
* `options.ssr` flag, which would also match unrelated future SSR-context
* loads of the same file.
*/
export const LOCAL_EXECUTION_LOAD_SUFFIX = '?dd-local-exec';
export const LOCAL_EXECUTION_LOAD_RE = /\.backend\.(ts|tsx|js|jsx)\?dd-local-exec$/;
export const BACKEND_CODE_EXTENSIONS = [
'.ts',
'.tsx',
Expand Down
208 changes: 200 additions & 8 deletions packages/plugins/apps/src/vite/local-execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

/* global globalThis */

import { mockLogger } from '@dd/tests/_jest/helpers/mocks';
import { mockLogger, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks';

import type { BackendFunction } from '../backend/types';
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',
Expand All @@ -20,18 +22,26 @@ const func: BackendFunction = {

const stubExecuteAction: ExecuteAction = async (fqn) => ({ data: null, stub: true, fqn });

/**
* Hard backstop, independent of whatever a given test's own execution path
* does: `net`/`fetch`/`child_process` are real, process-wide singletons, not
* per-test-file sandboxed state — a test that leaves them patched (e.g. a
* hung function abandoned by the timeout test below) would otherwise leak
* into every test that runs afterward in the same Jest worker, including
* completely unrelated test files. Runs after every test regardless of
* outcome, not just the ones that exercise the guard directly.
*/
afterEach(() => {
forceReset();
});

/**
* A `loadModule` double that resolves the customer's own function from a map
* and rejects anything else (e.g. the action-catalog/apps-backend probes),
* matching the common case where neither package is installed.
*/
function loadModuleReturning(exports: Record<string, unknown>): LoadModule {
return async (specifier: string) => {
if (specifier === func.absolutePath) {
return exports;
}
throw new Error(`Cannot find module '${specifier}'`);
};
return moduleResolverFor(func, exports);
}

const ORDER_MARKER = '__ddLocalExecutionTestOrder';
Expand Down Expand Up @@ -170,6 +180,33 @@ 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,
[],
stubExecuteAction,
loadModuleReturning({ example: () => new Promise(() => {}) }),
mockLogger,
50,
),
).rejects.toThrow(/timed out after 50ms/);

// `run()` (wrapping the hung function) is abandoned, not cancelled —
// it's still "running" forever in the background. Without a hard
// reset independent of its own try/finally, real network/subprocess
// access would stay blocked for the rest of this process, breaking
// every execution (and anything else in the dev server) that comes
// after this one. See network-guard.ts's `forceReset`.
expect(globalThis.fetch).toBe(realFetch);
// eslint-disable-next-line @typescript-eslint/no-require-imports
expect(require('child_process').spawn).toBe(realSpawn);
});

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(
func,
Expand Down Expand Up @@ -235,7 +272,7 @@ describe('local-execution — executeScriptLocally', () => {
| undefined;

const loadModule: LoadModule = async (specifier: string) => {
if (specifier === func.absolutePath) {
if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) {
return {
example: async () =>
registeredImpl?.('com.datadoghq.slack.chat.postMessage', {
Expand Down Expand Up @@ -327,6 +364,161 @@ 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,
[],
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,
[],
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,
[],
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,
[],
executeAction,
loadModuleReturning({
example: async () => {
const actionResult = await (
globalThis as Record<string, any>
).$.Actions.slack.chat.postMessage({ inputs: { text: 'hi' } });
// A raw fetch attempted right after the sanctioned
// $.Actions call must still be blocked — the
// exemption is scoped to the 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 restore real network access after execution, for whatever the dev server itself does next', async () => {
const realFetch = globalThis.fetch;
await executeScriptLocally(
func,
[],
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 () => {
// network-guard.test.ts already proves the ref-counting itself
// at the unit level, calling `runAllowed` directly. This proves
// it holds through the real path a customer's own code takes:
// `executeScriptLocally` → the customer's `Promise.all` →
// `makeActionsProxy`'s `apply` trap → `runAllowed`. `fetchMock`
// stands in for the real network call the dev server's own
// `ExecuteAction` implementation makes (e.g. a `preview-async`
// HTTP request) — if the ref-counting were a naive boolean
// instead of a depth counter, the fast call finishing first
// would re-block network and this call, made from inside the
// still-in-flight slow call, would throw.
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,
[],
executeAction,
loadModuleReturning({
example: () => {
const $ = (globalThis as Record<string, any>).$;
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 fast call's fetch must resolve — and the slow call's own
// fetch, made after the fast call's whole allow scope has
// already exited — must resolve too, proving network stayed
// allowed for the slow call 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');
});
});

describe('serialization of concurrent executions', () => {
beforeEach(() => {
delete (globalThis as Record<string, unknown>)[ORDER_MARKER];
Expand Down
31 changes: 28 additions & 3 deletions packages/plugins/apps/src/vite/local-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
import type { Logger } from '@dd/core/types';

import type { BackendFunction } from '../backend/types';
import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants';

import { forceReset, runAllowed, runBlocked } from './network-guard';

type BackendOutputs = { data: unknown };

Expand Down Expand Up @@ -102,6 +105,10 @@ function enqueue<T>(run: () => Promise<T>): Promise<T> {
* $.Actions.slack.chat.postMessage) to a callable that invokes
* `executeAction` directly, in-process — no IPC serialization needed, since
* there's no separate process to cross.
*
* `executeAction` is wrapped in `runAllowed` — the one sanctioned network
* call exempted from the `runBlocked` guard `runScriptLocally` wraps the
* customer's function in. See `network-guard.ts`.
*/
function makeActionsProxy(executeAction: ExecuteAction, pathParts: string[] = []): unknown {
return new Proxy(function () {}, {
Expand All @@ -123,7 +130,7 @@ function makeActionsProxy(executeAction: ExecuteAction, pathParts: string[] = []
);
}
const fqn = `com.datadoghq.${pathParts.join('.')}`;
return executeAction(fqn, inputs, connectionId);
return runAllowed(() => executeAction(fqn, inputs, connectionId));
},
});
}
Expand Down Expand Up @@ -264,19 +271,37 @@ async function runScriptLocally(
registerBackendRuntimeIfInstalled(loadModule, $),
]);

const mod = await loadModule(func.absolutePath);
// The suffix marks this as local execution's own request for the real
// function body — see LOCAL_EXECUTION_LOAD_SUFFIX's doc comment.
const mod = await loadModule(func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX);
const fn = mod[func.name];
if (typeof fn !== 'function') {
throw new Error(`"${func.name}" is not a function exported from ${func.absolutePath}`);
}

const result = await fn(...args);
// Blocks net/fetch/child_process for the duration of the customer's
// function call only — loadModule and the registration calls above
// (both Vite's own transform pipeline, no network) run unguarded.
// $.Actions calls made from inside fn are exempted via `runAllowed`
// in `makeActionsProxy`. See network-guard.ts.
const result = await runBlocked(() => fn(...args));
return { data: assertJsonSerializable(result, func) };
};

let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
// `Promise.race` below abandons whichever of `run()`/`timeout`
// loses — it doesn't cancel it. A hung `fn` (its returned
// promise never settling) means `run()`'s own `runBlocked` call
// never reaches its `finally`, so the block patch would
// otherwise stay applied for the rest of this process — blocking
// real network/subprocess access for every local execution (and
// anything else in the dev server) that comes after this one.
// Force it back to the real functions here, since the timer
// firing is exactly the signal that `run()` can no longer be
// trusted to unwind its own guard scope in bounded time.
forceReset();
reject(new Error(`Local execution of "${func.name}" timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
Expand Down
Loading
Loading