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
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// 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.

import { rejectNodeBuiltinImports } from '@dd/apps-plugin/backend/ast-parsing/reject-node-builtin-imports';
import type { ImportDeclaration, Program } from 'estree';

/**
* Helper to build a minimal ESTree Program for testing.
*/
function program(body: Program['body']): Program {
return { type: 'Program', sourceType: 'module', body };
}

/**
* Helper to build a minimal ImportDeclaration node for a given source.
*/
function importDecl(source: string, overrides: Partial<ImportDeclaration> = {}): ImportDeclaration {
return {
type: 'ImportDeclaration',
specifiers: [
{
type: 'ImportDefaultSpecifier',
local: { type: 'Identifier', name: 'x' },
},
],
source: { type: 'Literal', value: source },
attributes: [],
...overrides,
};
}

describe('Backend Functions - rejectNodeBuiltinImports', () => {
const filePath = '/project/src/math.backend.ts';

const allowedCases = [
{
description: 'allow importing a relative module',
source: './helpers',
},
{
description: 'allow importing a scoped npm package',
source: '@datadog/action-catalog',
},
{
description: 'allow importing an ordinary npm package',
source: 'lodash',
},
];

test.each(allowedCases)('Should $description', ({ source }) => {
const ast = program([importDecl(source)]);
expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow();
});

const rejectedCases = [
{
description: 'reject importing "node:fs" via the node: prefix',
source: 'node:fs',
},
{
description: 'reject importing the bare built-in "fs"',
source: 'fs',
},
{
description: 'reject importing "child_process"',
source: 'child_process',
},
{
description: 'reject importing "node:child_process"',
source: 'node:child_process',
},
{
description: 'reject importing "net"',
source: 'net',
},
{
description: 'reject importing a built-in subpath "fs/promises"',
source: 'fs/promises',
},
];

test.each(rejectedCases)('Should $description', ({ source }) => {
const ast = program([importDecl(source)]);
expect(() => rejectNodeBuiltinImports(ast, filePath)).toThrow(
`Importing Node built-in module "${source}" is not supported in .backend.ts files`,
);
expect(() => rejectNodeBuiltinImports(ast, filePath)).toThrow(filePath);
});

test('Should allow a type-only import of a Node built-in', () => {
// import type { Stats } from 'fs';
const ast = program([
importDecl('fs', { importKind: 'type' } as Partial<ImportDeclaration>),
]);
expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow();
});

test('Should ignore non-import statements', () => {
const ast = program([
{
type: 'ExpressionStatement',
expression: { type: 'Literal', value: 1 },
},
]);
expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 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.

import type { BaseNode } from 'estree';
import { builtinModules } from 'node:module';

import { ensureProgram, isTypeOnly } from './type-guards';

const RESTRICTED_MODULES = new Set<string>(builtinModules);

function isRestrictedSource(source: string): boolean {
return source.startsWith('node:') || RESTRICTED_MODULES.has(source);
}

/**
* Reject static imports of Node built-in modules in `.backend.ts` files.
* Backend functions run in a restricted environment with no direct Node
* built-in or network access — everything, including raw HTTP requests,
* must go through an Action Platform action ($.Actions or an
* @datadog/action-catalog typed wrapper).
*
* This is a best-effort, defense-in-depth check on static `import` specifiers
* only — it doesn't catch `require()` or dynamic `import()` of a computed
* specifier. See also `rejectRestrictedGlobals`, which covers bare network
* globals like `fetch` that need no import at all.
*/
export function rejectNodeBuiltinImports(ast: BaseNode, filePath: string): void {
const program = ensureProgram(ast, filePath);
for (const node of program.body) {
if (node.type !== 'ImportDeclaration' || isTypeOnly(node)) {
continue;
}

const source = node.source.value;
if (typeof source === 'string' && isRestrictedSource(source)) {
throw new Error(
`Importing Node built-in module "${source}" is not supported in .backend.ts files. ` +
`Backend functions run in a restricted environment and must use an Action ` +
`Platform action ($.Actions or an @datadog/action-catalog typed wrapper) instead: ${filePath}`,
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// 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.

import { rejectRestrictedGlobals } from '@dd/apps-plugin/backend/ast-parsing/reject-restricted-globals';
import { parseAst } from 'rollup/parseAst';

describe('Backend Functions - rejectRestrictedGlobals', () => {
const filePath = '/project/src/math.backend.ts';

const rejectedCases = [
{
description: 'reject a bare fetch() call',
code: 'export async function run() { return fetch("https://example.com"); }',
},
{
description: 'reject fetch referenced without calling it',
code: 'export function run() { const f = fetch; return f; }',
},
{
description: 'reject new XMLHttpRequest()',
code: 'export function run() { return new XMLHttpRequest(); }',
},
{
description: 'reject new WebSocket(...)',
code: 'export function run() { return new WebSocket("wss://example.com"); }',
},
{
description: 'reject new EventSource(...)',
code: 'export function run() { return new EventSource("/events"); }',
},
];

test.each(rejectedCases)('Should $description', ({ code }) => {
const ast = parseAst(code);
expect(() => rejectRestrictedGlobals(ast, filePath)).toThrow(
'is not supported in .backend.ts files',
);
expect(() => rejectRestrictedGlobals(ast, filePath)).toThrow(filePath);
});

const allowedCases = [
{
description: 'allow calling an imported action-catalog function',
code: "import { request } from '@datadog/action-catalog/http/http';\nexport async function run() { return request({ inputs: {} }); }",
},
{
description: 'allow a locally-declared function that happens to be named fetch',
code: 'function fetch() { return "local"; }\nexport function run() { return fetch(); }',
},
{
description: 'allow a parameter named fetch shadowing the global',
code: 'export function run(fetch) { return fetch(); }',
},
{
description: 'allow unrelated code with no restricted-global references',
code: 'export function run(a, b) { return a + b; }',
},
];

test.each(allowedCases)('Should $description', ({ code }) => {
const ast = parseAst(code);
expect(() => rejectRestrictedGlobals(ast, filePath)).not.toThrow();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// 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.

import type { BaseNode } from 'estree';

import { analyzeModuleScope } from './module-scope';
import { ensureProgram } from './type-guards';

const RESTRICTED_GLOBALS = new Set(['fetch', 'XMLHttpRequest', 'WebSocket', 'EventSource']);

/**
* Reject references to network-capable globals in `.backend.ts` files.
* Backend functions have no raw network access under today's v1 runtime —
* Deno's `--allow-net` is off — so any outbound call must go through an
* Action Platform action (`$.Actions` or an `@datadog/action-catalog` typed
* wrapper), never a direct HTTP client. Import-specifier restriction alone
* can't catch this: these are bare globals, not imports.
*
* This is the enforcement layer for a trap that's easy to fall into
* otherwise: `fetch` works fine during local dev (nothing stopped it before
* this check existed) but fails once the app is actually published, since
* production's sandbox blocks it. A separate, complementary effort adds
* AI-authoring guidance steering generated code away from `fetch` in the
* first place — that reduces how often this gets written at all, but only
* this build-time check actually guarantees it never ships, regardless of
* whether the code came from an AI, a human, or a copy-pasted snippet.
*
* Backend functions' planned v2 (Terrapin-based) sandbox will lift this
* restriction — legacy (pre-v2) apps are the ones that need it.
*
* This is a best-effort, defense-in-depth check: it flags any reference to one
* of these names that eslint-scope can't resolve to a declaration in this
* module (i.e. it falls through to the ambient global instead of a local
* variable or import that happens to share the name).
*/
export function rejectRestrictedGlobals(ast: BaseNode, filePath: string): void {
const program = ensureProgram(ast, filePath);
const scopeAnalysis = analyzeModuleScope(program);

for (const [identifier, reference] of scopeAnalysis.referencesByIdentifier) {
if (!RESTRICTED_GLOBALS.has(identifier.name) || reference.resolved) {
continue;
}

throw new Error(
`Using "${identifier.name}" is not supported in .backend.ts files. ` +
`Backend functions cannot make raw network requests in production — ` +
`use an Action Platform action ($.Actions or an @datadog/action-catalog ` +
`typed wrapper) instead: ${filePath}`,
);
}
}
25 changes: 25 additions & 0 deletions packages/plugins/apps/src/vite/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,31 @@ describe('Backend Functions - getVitePlugin', () => {
expect(assets.collectAssets).toHaveBeenCalledWith(['dist/**/*'], '/build');
});

test('Should reject a backend file importing a Node built-in module', () => {
const plugin = getVitePlugin(defaultOptions);
const transform = plugin!.transform as {
handler: (code: string, id: string) => unknown;
};

expect(() =>
transform.handler.call(
{
parse: parseAst,
resolve: jest.fn(async () => null),
load: jest.fn(async () => null),
addWatchFile: jest.fn(),
},
`
import fs from 'node:fs';
export function myHandler() {
return fs.readFileSync('/etc/passwd', 'utf8');
}
`,
'/build/src/backend/myHandler.backend.ts',
),
).toThrow('Importing Node built-in module "node:fs" is not supported in .backend.ts files');
});

test('Should inject the apps runtime', () => {
getVitePlugin(defaultOptions);

Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/apps/src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
type DoAuthenticatedRequest,
} from '../auth';
import { extractExportedFunctions } from '../backend/ast-parsing/extract-backend-functions';
import { rejectNodeBuiltinImports } from '../backend/ast-parsing/reject-node-builtin-imports';
import { rejectRestrictedGlobals } from '../backend/ast-parsing/reject-restricted-globals';
import { encodeQueryName } from '../backend/encodeQueryName';
import { generateProxyModule } from '../backend/proxy-codegen';
import type { BackendFunction } from '../backend/types';
Expand Down Expand Up @@ -130,6 +132,8 @@ export const getVitePlugin = ({
// frontend proxy that calls executeBackendFunction at runtime.
handler(code, id) {
const ast = this.parse(code);
rejectNodeBuiltinImports(ast, id);
rejectRestrictedGlobals(ast, id);
const exportNames = extractExportedFunctions(ast, id);
if (exportNames.length === 0) {
log.warn(
Expand Down
Loading