diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.ts new file mode 100644 index 000000000..bdbae89c5 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.test.ts @@ -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 { + 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), + ]); + 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(); + }); +}); diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts new file mode 100644 index 000000000..e39e5eab3 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts @@ -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(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}`, + ); + } + } +} diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.test.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.test.ts new file mode 100644 index 000000000..d43ec70b7 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.test.ts @@ -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(); + }); +}); diff --git a/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.ts b/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.ts new file mode 100644 index 000000000..f7a3b8bd6 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/reject-restricted-globals.ts @@ -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}`, + ); + } +} diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 3a798312f..debb6b72e 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -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); diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index 831ce75c2..9e4cf3a96 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -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'; @@ -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(