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..d11e56163 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/reject-node-builtin-imports.ts @@ -0,0 +1,40 @@ +// 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 (isomorphic/fetch-based + * APIs only) so direct Node built-in usage isn't supported. + * + * 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. + */ +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 fetch-based/isomorphic APIs 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..f23481cb2 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -14,6 +14,7 @@ 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 { encodeQueryName } from '../backend/encodeQueryName'; import { generateProxyModule } from '../backend/proxy-codegen'; import type { BackendFunction } from '../backend/types'; @@ -130,6 +131,7 @@ export const getVitePlugin = ({ // frontend proxy that calls executeBackendFunction at runtime. handler(code, id) { const ast = this.parse(code); + rejectNodeBuiltinImports(ast, id); const exportNames = extractExportedFunctions(ast, id); if (exportNames.length === 0) { log.warn(