Skip to content

Commit d452932

Browse files
committed
feat(apps): reject Node built-in imports in backend files
Backend functions run in a restricted environment (isomorphic/fetch-based APIs only), so direct static imports of Node built-in modules (fs, child_process, net, etc.) in .backend.ts files are now rejected at build time in the Vite transform hook, right after AST parsing. This is a best-effort, defense-in-depth check on static import specifiers only — it does not catch require() or dynamic import() of a computed specifier.
1 parent 80ef2a7 commit d452932

4 files changed

Lines changed: 175 additions & 0 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
import { rejectNodeBuiltinImports } from '@dd/apps-plugin/backend/ast-parsing/reject-node-builtin-imports';
6+
import type { ImportDeclaration, Program } from 'estree';
7+
8+
/**
9+
* Helper to build a minimal ESTree Program for testing.
10+
*/
11+
function program(body: Program['body']): Program {
12+
return { type: 'Program', sourceType: 'module', body };
13+
}
14+
15+
/**
16+
* Helper to build a minimal ImportDeclaration node for a given source.
17+
*/
18+
function importDecl(source: string, overrides: Partial<ImportDeclaration> = {}): ImportDeclaration {
19+
return {
20+
type: 'ImportDeclaration',
21+
specifiers: [
22+
{
23+
type: 'ImportDefaultSpecifier',
24+
local: { type: 'Identifier', name: 'x' },
25+
},
26+
],
27+
source: { type: 'Literal', value: source },
28+
attributes: [],
29+
...overrides,
30+
};
31+
}
32+
33+
describe('Backend Functions - rejectNodeBuiltinImports', () => {
34+
const filePath = '/project/src/math.backend.ts';
35+
36+
const allowedCases = [
37+
{
38+
description: 'allow importing a relative module',
39+
source: './helpers',
40+
},
41+
{
42+
description: 'allow importing a scoped npm package',
43+
source: '@datadog/action-catalog',
44+
},
45+
{
46+
description: 'allow importing an ordinary npm package',
47+
source: 'lodash',
48+
},
49+
];
50+
51+
test.each(allowedCases)('Should $description', ({ source }) => {
52+
const ast = program([importDecl(source)]);
53+
expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow();
54+
});
55+
56+
const rejectedCases = [
57+
{
58+
description: 'reject importing "node:fs" via the node: prefix',
59+
source: 'node:fs',
60+
},
61+
{
62+
description: 'reject importing the bare built-in "fs"',
63+
source: 'fs',
64+
},
65+
{
66+
description: 'reject importing "child_process"',
67+
source: 'child_process',
68+
},
69+
{
70+
description: 'reject importing "node:child_process"',
71+
source: 'node:child_process',
72+
},
73+
{
74+
description: 'reject importing "net"',
75+
source: 'net',
76+
},
77+
{
78+
description: 'reject importing a built-in subpath "fs/promises"',
79+
source: 'fs/promises',
80+
},
81+
];
82+
83+
test.each(rejectedCases)('Should $description', ({ source }) => {
84+
const ast = program([importDecl(source)]);
85+
expect(() => rejectNodeBuiltinImports(ast, filePath)).toThrow(
86+
`Importing Node built-in module "${source}" is not supported in .backend.ts files`,
87+
);
88+
expect(() => rejectNodeBuiltinImports(ast, filePath)).toThrow(filePath);
89+
});
90+
91+
test('Should allow a type-only import of a Node built-in', () => {
92+
// import type { Stats } from 'fs';
93+
const ast = program([
94+
importDecl('fs', { importKind: 'type' } as Partial<ImportDeclaration>),
95+
]);
96+
expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow();
97+
});
98+
99+
test('Should ignore non-import statements', () => {
100+
const ast = program([
101+
{
102+
type: 'ExpressionStatement',
103+
expression: { type: 'Literal', value: 1 },
104+
},
105+
]);
106+
expect(() => rejectNodeBuiltinImports(ast, filePath)).not.toThrow();
107+
});
108+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
import type { BaseNode } from 'estree';
6+
import { builtinModules } from 'node:module';
7+
8+
import { ensureProgram, isTypeOnly } from './type-guards';
9+
10+
const RESTRICTED_MODULES = new Set<string>(builtinModules);
11+
12+
function isRestrictedSource(source: string): boolean {
13+
return source.startsWith('node:') || RESTRICTED_MODULES.has(source);
14+
}
15+
16+
/**
17+
* Reject static imports of Node built-in modules in `.backend.ts` files.
18+
* Backend functions run in a restricted environment (isomorphic/fetch-based
19+
* APIs only) so direct Node built-in usage isn't supported.
20+
*
21+
* This is a best-effort, defense-in-depth check on static `import` specifiers
22+
* only — it doesn't catch `require()` or dynamic `import()` of a computed
23+
* specifier.
24+
*/
25+
export function rejectNodeBuiltinImports(ast: BaseNode, filePath: string): void {
26+
const program = ensureProgram(ast, filePath);
27+
for (const node of program.body) {
28+
if (node.type !== 'ImportDeclaration' || isTypeOnly(node)) {
29+
continue;
30+
}
31+
32+
const source = node.source.value;
33+
if (typeof source === 'string' && isRestrictedSource(source)) {
34+
throw new Error(
35+
`Importing Node built-in module "${source}" is not supported in .backend.ts files. ` +
36+
`Backend functions run in a restricted environment and must use fetch-based/isomorphic APIs instead: ${filePath}`,
37+
);
38+
}
39+
}
40+
}

packages/plugins/apps/src/vite/index.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,31 @@ describe('Backend Functions - getVitePlugin', () => {
160160
expect(assets.collectAssets).toHaveBeenCalledWith(['dist/**/*'], '/build');
161161
});
162162

163+
test('Should reject a backend file importing a Node built-in module', () => {
164+
const plugin = getVitePlugin(defaultOptions);
165+
const transform = plugin!.transform as {
166+
handler: (code: string, id: string) => unknown;
167+
};
168+
169+
expect(() =>
170+
transform.handler.call(
171+
{
172+
parse: parseAst,
173+
resolve: jest.fn(async () => null),
174+
load: jest.fn(async () => null),
175+
addWatchFile: jest.fn(),
176+
},
177+
`
178+
import fs from 'node:fs';
179+
export function myHandler() {
180+
return fs.readFileSync('/etc/passwd', 'utf8');
181+
}
182+
`,
183+
'/build/src/backend/myHandler.backend.ts',
184+
),
185+
).toThrow('Importing Node built-in module "node:fs" is not supported in .backend.ts files');
186+
});
187+
163188
test('Should inject the apps runtime', () => {
164189
getVitePlugin(defaultOptions);
165190

packages/plugins/apps/src/vite/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type DoAuthenticatedRequest,
1515
} from '../auth';
1616
import { extractExportedFunctions } from '../backend/ast-parsing/extract-backend-functions';
17+
import { rejectNodeBuiltinImports } from '../backend/ast-parsing/reject-node-builtin-imports';
1718
import { encodeQueryName } from '../backend/encodeQueryName';
1819
import { generateProxyModule } from '../backend/proxy-codegen';
1920
import type { BackendFunction } from '../backend/types';
@@ -130,6 +131,7 @@ export const getVitePlugin = ({
130131
// frontend proxy that calls executeBackendFunction at runtime.
131132
handler(code, id) {
132133
const ast = this.parse(code);
134+
rejectNodeBuiltinImports(ast, id);
133135
const exportNames = extractExportedFunctions(ast, id);
134136
if (exportNames.length === 0) {
135137
log.warn(

0 commit comments

Comments
 (0)