Skip to content

[APPS-2792] Add: reject Node built-in imports in backend files - #476

Draft
tyffical wants to merge 2 commits into
masterfrom
tiffany.trinh/apps-2792-sandboxing-import-restriction
Draft

[APPS-2792] Add: reject Node built-in imports in backend files#476
tyffical wants to merge 2 commits into
masterfrom
tiffany.trinh/apps-2792-sandboxing-import-restriction

Conversation

@tyffical

@tyffical tyffical commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Motivation

  • Part of APPS-2792 — local Node execution for App Builder backend functions. Backend functions run in a restricted environment with no unrestricted filesystem/process/network access — under today's v1 runtime, that includes no raw network access at all (not even fetch); everything must go through an Action Platform action ($.Actions or an @datadog/action-catalog typed wrapper).
  • Static imports of Node built-in modules (fs, child_process, net, etc.) in .backend.ts files are rejected at build time, so an author gets immediate, actionable feedback instead of code that silently behaves differently (or breaks) once local Node execution lands.
  • Network-capable globals (fetch, XMLHttpRequest, WebSocket, EventSource) need a separate check: they're bare globals, not imports, so import-specifier restriction can't catch them. This closes a real trap: fetch works fine during local dev (nothing stopped it before this check existed) but fails once the app is published, since production's sandbox blocks it.
  • A separate, complementary effort (web-ui#340206) 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 guarantees it never ships, regardless of whether the code came from an AI, a human, or a copy-pasted snippet. Both layers exist for a reason — this PR isn't superseded by that guidance work.
  • This restriction (and the AI-authoring guidance) is v1-specific: backend functions' planned v2 (Terrapin-based) sandbox will lift it. Legacy (pre-v2) apps are the ones that need it.
  • These are the two "Layer 2" static defenses proposed in the design doc's Sandboxing section; the companion item (ambient TypeScript globals for $ that omit Node-specific types) is deferred — see Out of Scope below.

Changes

What changed File
Added rejectNodeBuiltinImports, which walks a .backend.ts file's static ImportDeclarations and throws if any source is a Node built-in (via node: prefix or Node's own builtinModules list). reject-node-builtin-imports.ts
Added rejectRestrictedGlobals, an eslint-scope-based check that throws on any unshadowed reference to fetch/XMLHttpRequest/WebSocket/EventSource — i.e. any reference that doesn't resolve to a local declaration or import sharing the same name, meaning it falls through to the real ambient global. reject-restricted-globals.ts
Corrected rejectNodeBuiltinImports' doc comment and error message, which previously pointed to fetch-based/isomorphic APIs as the allowed escape hatch — no longer accurate now that fetch itself is blocked too. reject-node-builtin-imports.ts
Wired both checks into the Vite transform hook, right after this.parse(code) and before export extraction. vite/index.ts
Added unit tests covering allowed imports (relative, scoped, ordinary npm packages), rejected imports (node:fs, bare fs, child_process, net, fs/promises), and edge cases (type-only imports, non-import statements). reject-node-builtin-imports.test.ts
Added unit tests covering rejected global references (bare fetch() calls, referencing fetch without calling it, new XMLHttpRequest()/WebSocket()/EventSource()), and allowed cases (an imported action-catalog function, a locally-declared function or parameter that happens to be named fetch — shadowing-safe). reject-restricted-globals.test.ts
Added an end-to-end test that runs a real .backend.ts file with a node:fs import through the actual transform handler (using rollup's real parseAst, not a hand-built AST) to confirm the rejection fires through the genuine pipeline. vite/index.test.ts

QA Instructions

Build the plugin and link it into a scratch Vite project, then confirm a backend file importing a Node built-in — or referencing fetch — is rejected while an ordinary backend file still transforms correctly.

# 1. Build and link the plugin from this branch
cd ~/dd/build-plugins/packages/published/vite-plugin
yarn build
npm link

# 2. Scaffold a throwaway consumer project
mkdir -p ~/import-restriction-qa/src && cd ~/import-restriction-qa
cat > package.json <<'EOF'
{ "name": "import-restriction-qa", "private": true, "type": "module", "devDependencies": { "vite": "^5.0.0" } }
EOF
cat > vite.config.ts <<'EOF'
import { datadogVitePlugin } from '@datadog/vite-plugin/dist/src';
import { defineConfig } from 'vite';
export default defineConfig({
    plugins: [datadogVitePlugin({ apps: { identifier: 'qa-app-id', name: 'import-restriction-qa', dryRun: true } })],
});
EOF
cat > src/badImport.backend.ts <<'EOF'
import fs from 'node:fs';
export function readSecret() { return fs.readFileSync('/etc/passwd', 'utf8'); }
EOF
cat > src/badFetch.backend.ts <<'EOF'
export async function callExternal() { return fetch('https://example.com'); }
EOF
cat > src/goodImport.backend.ts <<'EOF'
export function doubleNumber(input: number) { return input * 2; }
EOF
npm install && npm link @datadog/vite-plugin

# 3. Confirm the bad Node-builtin import is rejected with a clear error
npx vite --port 5199 --strictPort &
sleep 3
curl -s http://localhost:5199/src/badImport.backend.ts | grep -o 'Importing Node built-in module.*not supported in .backend.ts files'
# Expected: Importing Node built-in module "node:fs" is not supported in .backend.ts files ✅ VERIFIED
kill %1

# 4. Confirm the bad fetch reference is rejected with a clear error
npx vite --port 5197 --strictPort &
sleep 3
curl -s http://localhost:5197/src/badFetch.backend.ts | grep -o 'Using "fetch" is not supported in .backend.ts files'
# Expected: Using "fetch" is not supported in .backend.ts files ✅ VERIFIED
kill %1

# 5. Confirm an ordinary backend file still transforms into a working proxy
npx vite --port 5198 --strictPort &
sleep 3
curl -s http://localhost:5198/src/goodImport.backend.ts
# Expected: export async function doubleNumber(...args) { return globalThis.DD_APPS_RUNTIME.executeBackendFunction(...); } ✅ VERIFIED
kill %1
# Automated pass
yarn test:unit packages/plugins/apps
# Expected: Test Suites: 24 passed, 24 total / Tests: 306 passed, 306 total ✅ VERIFIED
yarn workspace @dd/apps-plugin run typecheck
# Expected: no output, exit 0 ✅ VERIFIED

Blast Radius

  • Scoped to .backend.ts files' static imports and top-level global references only. No feature flag — this is a build-time compile error for a pattern (Node built-ins, or raw network globals) that wasn't previously usable in production anyway, since production's real sandbox already blocks both.
  • Best-effort, defense-in-depth: the import check only catches static import specifiers (not require() or a dynamically computed import()); the global-reference check only catches references eslint-scope can't resolve to a local declaration/import of the same name.
  • Risk: low. No behavioral change for any existing .backend.ts file that doesn't import a Node built-in or reference one of the four restricted globals directly (306/306 existing apps-plugin tests pass unchanged).

Out of Scope / Follow-ups

Item Status Next step
Ship backend-function-globals.d.ts (ambient TypeScript type for $ that omits Deno/process/Node-builtin globals) Deferred Editor-only DX polish, not an enforced guarantee — this PR's checks already enforce the restriction regardless of what types an author's editor shows. Getting a hand-written .d.ts into the published dist/ tarball requires new build-tooling wiring in packages/tools/src/rollupConfig.mjs (shared by all 5 published bundler plugins), which is disproportionate scope for this PR. Revisit once a scaffold tool exists to actually wire the type into a consumer's tsconfig.json.
Revisit/remove both checks once backend-functions v2 ships Deferred v2's Terrapin-based sandbox will allow fetch; not blocking today's v1 rollout

Documentation

tyffical added 2 commits July 30, 2026 16:07
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.
Backend functions have no raw network access in every real production
runtime -- Deno's --allow-net is off today, and the planned
Terrapin-based v2 sandbox restricts it the same way -- so any outbound
call must go through an Action Platform action ($.Actions or an
@datadog/action-catalog typed wrapper), never a direct HTTP client.
rejectNodeBuiltinImports only catches import specifiers; fetch and
friends need no import at all, so this adds a separate,
eslint-scope-based check for unshadowed references to fetch,
XMLHttpRequest, WebSocket, and EventSource.

Also corrects rejectNodeBuiltinImports' doc comment and error message,
which previously pointed to fetch-based/isomorphic APIs as the allowed
escape hatch -- no longer accurate now that fetch itself is blocked
too.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant