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,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<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 (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}`,
);
}
}
}
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
2 changes: 2 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,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';
Expand Down Expand Up @@ -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(
Expand Down
Loading