From 09ba149c514b9c458da38535704da6cb19ca34a3 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 23 Oct 2026 09:01:11 -0400 Subject: [PATCH] feat(apps): limit local call processes with Node's permission model Each local backend function call process now runs under Node's permission model: it reads the project, its node_modules folders and linked packages, Vite and server.fs.allow, writes only to its own temp dir and /tmp, and can't spawn subprocesses, workers or native addons. Module loads over IPC are checked against the readable folders and server.fs.deny before Vite reads them, and a load error about an unreadable file is replaced with a refusal that says whether the file matches server.fs.deny or is outside the readable folders. On a Node without the permission model, or without --allow-inspector while debugging, the process keeps the in-process guards. On a Node without module.registerHooks, such as Node 20, modules get no import.meta.resolve: Vite resolves it on a worker thread there, which the permission model refuses. The dev server warns once when a call runs that way. Co-Authored-By: Claude Opus 5.5 --- packages/plugins/apps/README.md | 3 +- packages/plugins/apps/package.json | 4 +- .../src/vite/dev-server.integration.test.ts | 5 + .../plugins/apps/src/vite/dev-server.test.ts | 4 + packages/plugins/apps/src/vite/index.ts | 10 + .../apps/src/vite/local-execution-child.ts | 12 +- ...ocal-execution-process.integration.test.ts | 325 +++++++++++++++++- .../src/vite/local-execution-process.test.ts | 4 + .../apps/src/vite/local-execution-process.ts | 86 ++++- .../apps/src/vite/local-execution-protocol.ts | 1 + .../src/vite/local-execution-sandbox.test.ts | 225 ++++++++++++ .../apps/src/vite/local-execution-sandbox.ts | 302 ++++++++++++++++ .../childLaterImport.backend.ts | 9 + yarn.lock | 2 + 14 files changed, 968 insertions(+), 24 deletions(-) create mode 100644 packages/plugins/apps/src/vite/local-execution-sandbox.test.ts create mode 100644 packages/plugins/apps/src/vite/local-execution-sandbox.ts create mode 100644 packages/tests/src/_jest/fixtures/apps_backend_project/childLaterImport.backend.ts diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md index 531dc2c85..d8938d925 100644 --- a/packages/plugins/apps/README.md +++ b/packages/plugins/apps/README.md @@ -101,10 +101,11 @@ When Datadog rejects a bundle as longer than the `jsFunctionWithActions` `script - **Fresh state**: module-level variables and globals start over on every call. - **Timeout**: a call that runs past its time budget is killed, even inside a synchronous loop. -- **Guards**: guards in the call's process refuse writes outside the call's own temp dir (`os.tmpdir()`, also its working directory) and `/tmp`, subprocesses and worker threads, from the module's top-level code on. The heap is capped at 256 MB. +- **Sandbox**: Node's [permission model](https://nodejs.org/api/permissions.html) lets a call read the project, its `node_modules` folders and the packages linked from them, Vite and Vite's [`server.fs.allow`](https://vite.dev/config/server-options#server-fs-allow) folders, and write only to its own temp dir (`os.tmpdir()`), which is also its working directory, and `/tmp`. Modules loaded through Vite, though not `fs` reads or a native `require`, refuse files matching Vite's [`server.fs.deny`](https://vite.dev/config/server-options#server-fs-deny), such as `.env`, and, unless `server.fs.strict` is `false`, files outside those folders. It refuses subprocesses, worker threads and native addons, allows the network, and caps the heap at 256 MB. On a Node without [`module.registerHooks`](https://nodejs.org/api/module.html#moduleregisterhooksoptions), added in Node 22.15, modules get no `import.meta.resolve`, which Vite resolves on a worker thread there, and the dev server warns once. A known gap, from Node's [documented limitations](https://nodejs.org/api/permissions.html#limitations-and-known-issues): "Symbolic links will be followed even to locations outside of the set of paths that access has been granted to." Here, a symlink in a writable folder (the call's temp dir or `/tmp`) lets a call read or write wherever it points, project files included. Local execution is a parity aid, not a security boundary. - **Environment**: a call sees no variables from the dev server's environment, keys included, except Node's TLS and time zone settings (`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `SSL_CERT_DIR`, `TZ`), and no Node options from `NODE_OPTIONS`. Vite still inlines `import.meta.env` and `define` values into the code it serves. `$.Actions` calls go back to the dev server, which holds the credentials and checks each call's connection against the function's allowed connections. - **Logs**: a call's `stdout` and `stderr` reach the dev server's terminal line by line, as each line ends. - **Debugging**: when the dev server runs under a debugger (`--inspect`), each call's process listens on a random local port and prints its `Debugger listening on ws://…` URL. Attach to that URL to debug the call; calls don't time out while the dev server is under a debugger. +- **Older Node**: on a Node without the permission model, or without `--allow-inspector` while debugging, a call's process runs under in-process guards instead. They refuse writes outside the temp dirs, subprocesses and worker threads, from the module's top-level code on. Local execution doesn't support Yarn Plug'n'Play, whose packages load through hooks a call's process doesn't get: each call fails with a hint to set `nodeLinker: node-modules` or use `dev:verify`. diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json index b40aa3fa3..315df84a4 100644 --- a/packages/plugins/apps/package.json +++ b/packages/plugins/apps/package.json @@ -46,12 +46,14 @@ "esbuild": "0.25.8", "eslint-scope": "7.2.2", "glob": "11.1.0", - "jszip": "3.10.1" + "jszip": "3.10.1", + "picomatch": "4.0.7" }, "devDependencies": { "@types/eslint-scope": "3.7.7", "@types/estree": "1.0.8", "@types/node": "^20", + "@types/picomatch": "4.0.2", "graceful-fs": "4.2.11", "rollup": "4.60.4", "typescript": "5.4.3", diff --git a/packages/plugins/apps/src/vite/dev-server.integration.test.ts b/packages/plugins/apps/src/vite/dev-server.integration.test.ts index ea8e57d47..9b8dc2d10 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -182,6 +182,11 @@ const makeChildExecutor = (devServer: ViteDevServer) => { projectRoot: FIXTURE_ROOT, childEntryPath: mockChildEntryPath, moduleRunnerPath, + readRoots: devServer.config.server.fs.allow, + denyPatterns: devServer.config.server.fs.deny, + restrictModuleLoads: true, + resolveModule: async (url, importer) => + (await devServer.environments.ssr.pluginContainer.resolveId(url, importer))?.id, log: getMockLogger(), writeOutput: () => {}, }); diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index 30343f0ae..31aee417e 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -208,6 +208,10 @@ function createTestExecutor(): LocalExecutor { projectRoot: '/project', childEntryPath: '/nonexistent/local-execution-child.mjs', moduleRunnerPath: '/nonexistent/module-runner.js', + readRoots: [], + denyPatterns: [], + restrictModuleLoads: true, + resolveModule: async () => undefined, log: mockLog, }); } diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index 3ce06055a..3b5b02845 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -686,6 +686,16 @@ export const getVitePlugin = ({ projectRoot: context.buildRoot, childEntryPath, moduleRunnerPath, + readRoots: server.config.server.fs?.allow ?? [], + denyPatterns: server.config.server.fs?.deny ?? [], + restrictModuleLoads: server.config.server.fs?.strict !== false, + resolveModule: async (url, importer) => { + const resolved = await server.environments.ssr.pluginContainer.resolveId( + url, + importer, + ); + return resolved?.id; + }, log, }) : undefined; diff --git a/packages/plugins/apps/src/vite/local-execution-child.ts b/packages/plugins/apps/src/vite/local-execution-child.ts index e659748a0..9cefe4baf 100644 --- a/packages/plugins/apps/src/vite/local-execution-child.ts +++ b/packages/plugins/apps/src/vite/local-execution-child.ts @@ -7,6 +7,7 @@ // Runs one backend function call in its own process, forked by the dev server's local execution. // Modules come from the dev server over IPC; `$.Actions` calls go back to it for scope checks and credentials. +import nodeModule from 'node:module'; import type { Writable } from 'node:stream'; import type { ChildMessage, ChildStartConfig, ParentMessage } from './local-execution-protocol'; @@ -29,7 +30,7 @@ type CreateImportMeta = (modulePath: string) => unknown; type ModuleRunnerOptions = { transport: { invoke(payload: unknown): Promise }; hmr: false; - createImportMeta: CreateImportMeta; + createImportMeta?: CreateImportMeta; }; type ModuleRunnerModule = { ModuleRunner: new (options: ModuleRunnerOptions, evaluator: unknown) => ModuleRunnerInstance; @@ -168,11 +169,13 @@ async function load(config: ChildStartConfig): Promise { // Declared, so top-level code reading `$` sees undefined as in production rather than a ReferenceError. Reflect.set(globalThis, '$', undefined); const evaluator = new ESModulesEvaluator(); + // Without `module.registerHooks`, Vite resolves it on a hooks thread, which the permission model refuses. + const resolvesImportMeta = config.installGuards || 'registerHooks' in nodeModule; // Without it, Vite's runner gives modules an import.meta.resolve that always throws. const runnerOptions: ModuleRunnerOptions = { transport: { invoke }, hmr: false, - createImportMeta: createNodeImportMeta, + ...(resolvesImportMeta && { createImportMeta: createNodeImportMeta }), }; const runner = new ModuleRunner(runnerOptions, evaluator); const mod = await runner.import(config.entryUrl); @@ -195,8 +198,11 @@ async function load(config: ChildStartConfig): Promise { return assertJsonRoundTrippable(returned, subject); } -// The guards cover the module's top-level code as well as the call. +// Without the permission model, the guards cover the module's top-level code as well as the call. function execute(config: ChildStartConfig): Promise { + if (!config.installGuards) { + return load(config); + } installGuards(); return runBlocked(() => load(config)); } diff --git a/packages/plugins/apps/src/vite/local-execution-process.integration.test.ts b/packages/plugins/apps/src/vite/local-execution-process.integration.test.ts index 6e928cc8a..4aa7e2d88 100644 --- a/packages/plugins/apps/src/vite/local-execution-process.integration.test.ts +++ b/packages/plugins/apps/src/vite/local-execution-process.integration.test.ts @@ -7,6 +7,7 @@ import { getContextMock, getMockLogger } from '@dd/tests/_jest/helpers/mocks'; import fs from 'fs'; import http from 'http'; import { type ChildProcess, fork } from 'node:child_process'; +import nodeModule from 'node:module'; import os from 'os'; import path from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; @@ -31,6 +32,7 @@ import { import { CHILD_MAX_OLD_SPACE_MB, createChildProcessExecutor, + IMPORT_META_RESOLVE_UNAVAILABLE_MESSAGE, killLocalExecutionChildren, type ChildProcessExecutorOptions, type LocalExecutionRequest, @@ -38,6 +40,7 @@ import { resolveViteModuleRunner, } from './local-execution-process'; import type { ChildStartConfig } from './local-execution-protocol'; +import { DENIED_MODULE_REASON, OUTSIDE_READ_ROOTS_REASON } from './local-execution-sandbox'; import { isIndexableRecord } from './local-execution-shared'; import { DEFAULT_LONG_POLLING_CONFIG, @@ -101,6 +104,9 @@ const waitUntilGone = async (pid: number, deadline = Date.now() + 5000): Promise return waitUntilGone(pid, deadline); }; +const requestedUrlOf = (payload: HotPayload): unknown => + payload.type === 'custom' ? payload.data?.data?.[0] : undefined; + const childPidFrom = (debugLines: string[]) => { const match = debugLines.join('\n').match(/\(pid (\d+),/); if (!match) { @@ -169,6 +175,11 @@ describe('Local execution in a child process', () => { projectRoot: FIXTURE_ROOT, childEntryPath, moduleRunnerPath, + readRoots: server.config.server.fs.allow, + denyPatterns: server.config.server.fs.deny, + restrictModuleLoads: true, + resolveModule: async (url, importer) => + (await server.environments.ssr.pluginContainer.resolveId(url, importer))?.id, log: getMockLogger(), writeOutput: () => {}, ...overrides, @@ -592,6 +603,7 @@ describe('Local execution in a child process', () => { runtimeContext: previewRuntimeContext, actionCatalogInstalled: false, appsBackendInstalled: false, + installGuards: false, flushTimeoutMs: 1000, }; const child = fork(childEntryPath, [], { @@ -687,7 +699,9 @@ describe('Local execution in a child process', () => { ); }); - describe('Guards', () => { + describe('Sandbox', () => { + const withoutAllowInspector = new Set(process.allowedNodeEnvironmentFlags); + withoutAllowInspector.delete('--allow-inspector'); let fetchServer: http.Server; let fetchUrl: string; let projectFile: string; @@ -720,7 +734,7 @@ describe('Local execution in a child process', () => { }); test( - 'Should refuse project writes, subprocesses and workers with the in-process guards, and allow temp writes and fetch', + 'Should refuse project writes, subprocesses and workers, and allow temp writes and fetch', async () => { const execute = makeExecutor(); const result = await execute( @@ -730,6 +744,35 @@ describe('Local execution in a child process', () => { sharedTmpFile, ]), ); + expect(result.data).toEqual({ + sharedTmpWrite: 'ok: written', + projectWrite: 'refused: ERR_ACCESS_DENIED', + tempWrite: 'ok: x', + spawn: 'refused: ERR_ACCESS_DENIED', + worker: 'refused: ERR_ACCESS_DENIED', + fetch: 'ok: 200 pong', + }); + expect(fs.existsSync(projectFile)).toBe(false); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should fall back to the in-process guards while debugging on a Node without --allow-inspector', + async () => { + const lines: string[] = []; + const execute = makeExecutor({ + nodeFlags: withoutAllowInspector, + isInspectorActive: () => true, + writeOutput: (_stream, line) => lines.push(line), + }); + const result = await execute( + makeRequest(fixtureFunction('childSandbox', 'runSandboxProbes'), [ + projectFile, + fetchUrl, + sharedTmpFile, + ]), + ); expect(result.data).toEqual({ sharedTmpWrite: 'ok: written', projectWrite: 'refused: EROFS', @@ -739,14 +782,18 @@ describe('Local execution in a child process', () => { fetch: 'ok: 200 pong', }); expect(fs.existsSync(projectFile)).toBe(false); + expect(lines.join('\n')).toContain('Debugger listening on ws://127.0.0.1:'); }, CALL_TIMEOUT_MS, ); test( - "Should guard a module's top-level code too", + "Should guard a module's top-level code too while debugging on a Node without --allow-inspector", async () => { - const execute = makeExecutor(); + const execute = makeExecutor({ + nodeFlags: withoutAllowInspector, + isInspectorActive: () => true, + }); await expect( execute( makeRequest( @@ -800,8 +847,169 @@ describe('Local execution in a child process', () => { CALL_TIMEOUT_MS, ); }); + describe('Module loading over IPC', () => { const loadOverIpc = fixtureFunction('childSandbox', 'loadOverIpc'); + // Files these tests create live in their own readable folder: other suites copy the shared fixture project. + let readableDir = ''; + const makeReadingExecutor = (overrides: Partial = {}) => + makeExecutor({ + readRoots: [...server.config.server.fs.allow, readableDir], + ...overrides, + }); + + beforeAll(() => { + const createdDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dd-apps-readable-')); + readableDir = fs.realpathSync.native(createdDir); + fs.writeFileSync(path.join(readableDir, '.env'), 'DD_API_KEY=from-dotenv\n'); + }); + + afterAll(() => { + fs.rmSync(readableDir, { recursive: true, force: true }); + }); + const outsideFile = process.platform === 'win32' ? 'C:\\Windows\\win.ini' : '/etc/hosts'; + + test.each([ + { + description: 'an absolute path', + url: () => `${outsideFile}?raw`, + importer: undefined, + reason: OUTSIDE_READ_ROOTS_REASON, + }, + { + description: 'a /@fs/ path', + url: () => `/@fs${outsideFile}?raw`, + importer: undefined, + reason: OUTSIDE_READ_ROOTS_REASON, + }, + { + description: 'a .env file in a readable folder', + url: () => `${path.join(readableDir, '.env')}?raw`, + importer: undefined, + reason: DENIED_MODULE_REASON, + }, + { + description: 'a relative path climbing out of the project', + url: () => + `${path.relative(FIXTURE_ROOT, outsideFile)}?raw`.replace(/^(?!\.)/, './'), + importer: path.join(FIXTURE_ROOT, 'childSandbox.backend.ts'), + reason: OUTSIDE_READ_ROOTS_REASON, + }, + ])( + 'Should refuse a module named by $description, without reading it', + async ({ url: urlOf, importer, reason }) => { + const url = urlOf(); + const requestedUrls: unknown[] = []; + const { hot } = server.environments.ssr; + const execute = makeReadingExecutor({ + invokeChannel: { + handleInvoke: (payload: HotPayload) => { + requestedUrls.push(requestedUrlOf(payload)); + return hot.handleInvoke(payload); + }, + }, + }); + const result = await execute(makeRequest(loadOverIpc, [url, importer])); + expect(requestedUrls).not.toContain(url); + expect(JSON.stringify(result.data)).toContain(`: ${reason}.`); + expect(result.data).toEqual({ + error: { + name: 'Error', + message: expect.stringMatching(/^Local execution can't load ".+": /), + }, + }); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should refuse an extensionless path to a module outside the readable folders, without reading it', + async () => { + const outsideDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'dd-apps-outside-')), + ); + const requestedUrls: unknown[] = []; + const { hot } = server.environments.ssr; + try { + fs.writeFileSync(path.join(outsideDir, 'mod.js'), 'export const secret = 1;'); + const url = path.join(outsideDir, 'mod'); + const execute = makeExecutor({ + invokeChannel: { + handleInvoke: (payload: HotPayload) => { + requestedUrls.push(requestedUrlOf(payload)); + return hot.handleInvoke(payload); + }, + }, + }); + const result = await execute(makeRequest(loadOverIpc, [url])); + expect(requestedUrls).not.toContain(url); + expect(JSON.stringify(result.data)).toContain(OUTSIDE_READ_ROOTS_REASON); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }, + CALL_TIMEOUT_MS, + ); + + test( + "Should refuse a module matching the project's own server.fs.deny", + async () => { + const secretsDir = path.join(readableDir, 'secrets'); + const secretFile = path.join(secretsDir, 'prod.json'); + try { + fs.mkdirSync(secretsDir, { recursive: true }); + fs.writeFileSync(secretFile, '{"token":"x"}'); + const execute = makeReadingExecutor({ + denyPatterns: [...server.config.server.fs.deny, '**/secrets/**'], + }); + const result = await execute(makeRequest(loadOverIpc, [`${secretFile}?raw`])); + expect(JSON.stringify(result.data)).toContain(DENIED_MODULE_REASON); + } finally { + fs.rmSync(secretsDir, { recursive: true, force: true }); + } + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should refuse a denied name even when it links to a file with an allowed name', + async () => { + const targetDir = path.join(readableDir, 'linked-secret'); + const target = path.join(targetDir, 'dev.secrets'); + const link = path.join(targetDir, '.env.linked'); + try { + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(target, 'TOKEN=x'); + fs.symlinkSync(target, link); + const execute = makeReadingExecutor(); + const result = await execute(makeRequest(loadOverIpc, [`${link}?raw`])); + expect(JSON.stringify(result.data)).toContain(DENIED_MODULE_REASON); + } finally { + fs.rmSync(targetDir, { recursive: true, force: true }); + } + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should load a module outside the readable folders when server.fs.strict is off', + async () => { + const outsideDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'dd-apps-loose-')), + ); + try { + const looseFile = path.join(outsideDir, 'mod.js'); + fs.writeFileSync(looseFile, 'export const loose = true;'); + const execute = makeExecutor({ restrictModuleLoads: false }); + const result = await execute(makeRequest(loadOverIpc, [looseFile])); + expect(JSON.stringify(result.data)).not.toContain(OUTSIDE_READ_ROOTS_REASON); + expect(JSON.stringify(result.data)).toContain('loose'); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }, + CALL_TIMEOUT_MS, + ); test( "Should load a plugin's virtual module", @@ -815,6 +1023,18 @@ describe('Local execution in a child process', () => { CALL_TIMEOUT_MS, ); + test( + 'Should report a module that does not exist yet as missing, not as outside the readable folders', + async () => { + const execute = makeExecutor(); + const missing = execute( + makeRequest(fixtureFunction('childLaterImport', 'importLater'), []), + ); + await expect(missing).rejects.toThrow('Does the file exist?'); + }, + CALL_TIMEOUT_MS, + ); + test( 'Should load a module inside the project over the same channel', async () => { @@ -867,11 +1087,14 @@ export class ModuleRunner { `; test( - 'Should give a backend function a working import.meta.resolve', + 'Should give a backend function a working import.meta.resolve on a Node without the permission model', async () => { const fakeRunnerPath = path.join(runnerDir, 'module-runner.mjs'); fs.writeFileSync(fakeRunnerPath, FAKE_VITE_7_RUNNER); - const execute = makeExecutor({ moduleRunnerPath: fakeRunnerPath }); + const execute = makeExecutor({ + moduleRunnerPath: fakeRunnerPath, + nodeFlags: new Set(), + }); const func = fixtureFunction('childImportMeta', 'resolveSibling'); const result = await execute(makeRequest(func, [])); const siblingUrl = String(result.data); @@ -879,6 +1102,96 @@ export class ModuleRunner { }, CALL_TIMEOUT_MS, ); + + // Like Vite's, it falls back to `module.register`, whose hooks thread the permission model refuses. + const FAKE_VITE_7_RUNNER_WITH_HOOKS = ` +import nodeModule from 'node:module'; +import { pathToFileURL } from 'node:url'; +const createDefaultImportMeta = (modulePath) => ({ + url: pathToFileURL(modulePath).href, + resolve() { + throw new Error('[module runner] "import.meta.resolve" is not supported.'); + }, +}); +export const createNodeImportMeta = async (modulePath) => { + if (typeof nodeModule.registerHooks !== 'function') { + nodeModule.register('data:text/javascript,export const resolve = (s, c, next) => next(s, c);'); + } + return { + ...createDefaultImportMeta(modulePath), + resolve: (specifier) => new URL(specifier, pathToFileURL(modulePath)).href, + }; +}; +export class ESModulesEvaluator {} +export class ModuleRunner { + constructor(options) { + this.options = options; + } + async import(url) { + const createImportMeta = this.options.createImportMeta ?? createDefaultImportMeta; + const meta = await createImportMeta(url.split('?')[0]); + const resolveSibling = () => { + try { + return meta.resolve('./sibling.js'); + } catch (error) { + return error.message; + } + }; + return { resolveSibling }; + } +} +`; + + test( + 'Should still load modules where import.meta.resolve needs a hooks thread', + async () => { + const fakeRunnerPath = path.join(runnerDir, 'module-runner-hooks.mjs'); + fs.writeFileSync(fakeRunnerPath, FAKE_VITE_7_RUNNER_WITH_HOOKS); + const execute = makeExecutor({ moduleRunnerPath: fakeRunnerPath }); + const func = fixtureFunction('childImportMeta', 'resolveSibling'); + const result = await execute(makeRequest(func, [])); + const hasRegisterHooks = 'registerHooks' in nodeModule; + expect(String(result.data)).toMatch( + hasRegisterHooks + ? /\/sibling\.js$/ + : /"import\.meta\.resolve" is not supported/, + ); + }, + CALL_TIMEOUT_MS, + ); + + test.each([ + { + setup: 'without module.registerHooks under the sandbox', + overrides: { hasRegisterHooks: false }, + warnings: 1, + }, + { + setup: 'with module.registerHooks under the sandbox', + overrides: { hasRegisterHooks: true }, + warnings: 0, + }, + { + setup: 'without module.registerHooks or the sandbox', + overrides: { hasRegisterHooks: false, nodeFlags: new Set() }, + warnings: 0, + }, + ])( + 'Should warn about import.meta.resolve $warnings time(s) across calls $setup', + async ({ overrides, warnings }) => { + const warn = jest.fn(); + const execute = makeExecutor({ ...overrides, log: getMockLogger({ warn }) }); + const countCalls = fixtureFunction('childState', 'countCalls'); + await execute(makeRequest(countCalls, [0])); + await execute(makeRequest(countCalls, [0])); + expect(warn.mock.calls).toEqual( + Array.from({ length: warnings }, () => [ + IMPORT_META_RESOLVE_UNAVAILABLE_MESSAGE, + ]), + ); + }, + CALL_TIMEOUT_MS, + ); }); }); diff --git a/packages/plugins/apps/src/vite/local-execution-process.test.ts b/packages/plugins/apps/src/vite/local-execution-process.test.ts index 79f4d7b53..dc7ad85dc 100644 --- a/packages/plugins/apps/src/vite/local-execution-process.test.ts +++ b/packages/plugins/apps/src/vite/local-execution-process.test.ts @@ -84,6 +84,10 @@ describe('Local execution process helpers', () => { projectRoot: '/project', childEntryPath: '/unused/local-execution-child.mjs', moduleRunnerPath, + readRoots: [], + denyPatterns: [], + restrictModuleLoads: true, + resolveModule: async () => undefined, log, }); const call = () => diff --git a/packages/plugins/apps/src/vite/local-execution-process.ts b/packages/plugins/apps/src/vite/local-execution-process.ts index 104d0d952..e4e496cf0 100644 --- a/packages/plugins/apps/src/vite/local-execution-process.ts +++ b/packages/plugins/apps/src/vite/local-execution-process.ts @@ -2,14 +2,14 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -/** Runs each local backend function call in a fresh Node child process, the way production gives each call its own process. */ +/** Runs each local backend function call in a fresh, sandboxed Node child process, the way production gives each call its own process. */ import { readJsonSync, rm, rmSync } from '@dd/core/helpers/fs'; import type { Logger } from '@dd/core/types'; import { type ChildProcess, fork } from 'node:child_process'; import fs from 'node:fs'; import inspector from 'node:inspector'; -import { createRequire } from 'node:module'; +import nodeModule, { createRequire } from 'node:module'; import os from 'node:os'; import path from 'node:path'; import type { Readable } from 'node:stream'; @@ -23,6 +23,13 @@ import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; import type { LongPollingOptions } from '../types'; import type { ChildStartConfig, ParentMessage } from './local-execution-protocol'; +import { + collectReadPaths, + createModuleLoadGuard, + permissionModelArgv, + sharedTmpPaths, + withRealPath, +} from './local-execution-sandbox'; import { describeError, isIndexableRecord, validateActionCall } from './local-execution-shared'; import type { ExecuteAction, GetRuntimeContext } from './local-execution'; import { @@ -63,8 +70,18 @@ export interface ChildProcessExecutorOptions { projectRoot: string; childEntryPath: string; moduleRunnerPath: string | undefined; + /** Extra folders a call may read, such as Vite's `server.fs.allow`, which covers a monorepo's packages. */ + readRoots: string[]; + /** Vite's `server.fs.deny` globs, which no module load may read. */ + denyPatterns: string[]; + /** False when Vite's `server.fs.strict` is off, so module loads aren't limited to the readable folders. */ + restrictModuleLoads: boolean; + /** Vite's SSR resolver, which tells which file a module request would read before Vite reads it. */ + resolveModule: (url: string, importer: string | undefined) => Promise; log: Logger; writeOutput?: (stream: OutputStream, line: string) => void; + nodeFlags?: ReadonlySet; + hasRegisterHooks?: boolean; isInspectorActive?: () => boolean; /** How long a finished call waits for its output to flush before returning anyway. */ flushTimeoutMs?: number; @@ -128,6 +145,8 @@ export const YARN_PNP_UNSUPPORTED_MESSAGE = export const MODULE_RUNNER_UNRESOLVED_MESSAGE = "Local execution of backend functions couldn't resolve vite/module-runner from the project's Vite."; +export const IMPORT_META_RESOLVE_UNAVAILABLE_MESSAGE = `Local execution on Node.js ${process.versions.node} can't provide \`import.meta.resolve\` inside the sandbox; production runs Node 24. Upgrade to Node 22.15+ to match it.`; + /** Vite's module runner from the project's own Vite, which serves it modules over `handleInvoke`. */ export function resolveViteModuleRunner(projectRoot: string): string | undefined { try { @@ -227,7 +246,7 @@ function isActionMessage(message: Record): message is Record; allowedConnectionIds: string[]; executeAction: ExecuteAction; timeoutMs: number; @@ -242,8 +261,14 @@ export function createChildProcessExecutor( projectRoot, childEntryPath, moduleRunnerPath, + readRoots, + denyPatterns, + restrictModuleLoads, + resolveModule, log, writeOutput = writeToTerminal, + nodeFlags = process.allowedNodeEnvironmentFlags, + hasRegisterHooks = 'registerHooks' in nodeModule, isInspectorActive = isDebuggerAttached, flushTimeoutMs = DEFAULT_FLUSH_TIMEOUT_MS, } = options; @@ -263,9 +288,12 @@ export function createChildProcessExecutor( return unavailable(MODULE_RUNNER_UNRESOLVED_MESSAGE); } const moduleRunnerUrl = pathToFileURL(moduleRunnerPath).href; + // Node checks read access on each linked part of the entry's path while it starts. + const [realChildEntryPath = childEntryPath] = withRealPath(childEntryPath).slice(-1); const ownChildren: ChildTmpDirs = new Map(); let closed = false; + let warnedImportMetaResolve = false; const refuseIfClosed = (func: BackendFunction) => { if (closed) { throw new Error( @@ -286,14 +314,30 @@ export function createChildProcessExecutor( const createdTmpDir = fs.mkdtempSync(tmpPrefix); const tmpDir = fs.realpathSync.native(createdTmpDir); const inspect = isInspectorActive(); - const execArgv = [`--max-old-space-size=${CHILD_MAX_OLD_SPACE_MB}`]; - if (inspect) { - execArgv.push('--inspect=127.0.0.1:0'); - } const env = buildChildEnv(tmpDir); let child: ChildProcess; + let readPaths: string[]; + let sandboxArgv: string[] | undefined; try { - child = fork(childEntryPath, [], { + readPaths = collectReadPaths(projectRoot, childEntryPath, moduleRunnerPath, readRoots); + sandboxArgv = permissionModelArgv({ + readPaths, + writePaths: [tmpDir, ...sharedTmpPaths()], + nodeFlags, + inspect, + }); + if (sandboxArgv && !hasRegisterHooks && !warnedImportMetaResolve) { + warnedImportMetaResolve = true; + log.warn(IMPORT_META_RESOLVE_UNAVAILABLE_MESSAGE); + } + const execArgv = [ + `--max-old-space-size=${CHILD_MAX_OLD_SPACE_MB}`, + ...(sandboxArgv ?? []), + ]; + if (inspect) { + execArgv.push('--inspect=127.0.0.1:0'); + } + child = fork(realChildEntryPath, [], { // Not the project, so a dependency loading `.env` from the working directory finds none. cwd: tmpDir, env, @@ -323,6 +367,13 @@ export function createChildProcessExecutor( } const resolutionScope = new Set(); + const inResolutionScope = (work: () => T): T => + localExecutionResolutionContext.run(resolutionScope, work); + const moduleLoadGuard = createModuleLoadGuard({ + readPaths: restrictModuleLoads ? readPaths : undefined, + denyPatterns, + resolveModule: (url, importer) => inResolutionScope(() => resolveModule(url, importer)), + }); return new Promise((resolve, reject) => { let settled = false; let loaded = false; @@ -400,10 +451,15 @@ export function createChildProcessExecutor( } }; - const inResolutionScope = (work: () => T): T => - localExecutionResolutionContext.run(resolutionScope, work); - const onInvoke = async (id: number, request: InvokeRequest) => { + const [url, importer] = request.args; + if (request.name === 'fetchModule' && typeof url === 'string') { + const refusal = await moduleLoadGuard.refusalBeforeLoad(url, importer); + if (refusal) { + reply({ type: 'invoke-result', id, response: refusal }); + return; + } + } const hotPayload: HotPayload = { type: 'custom', event: 'vite:invoke', @@ -412,7 +468,8 @@ export function createChildProcessExecutor( const response = await inResolutionScope(() => invokeChannel.handleInvoke(hotPayload), ); - reply({ type: 'invoke-result', id, response }); + const checkedResponse = moduleLoadGuard.checkResponse(response); + reply({ type: 'invoke-result', id, response: checkedResponse }); }; const onAction = async ( @@ -537,7 +594,10 @@ export function createChildProcessExecutor( }); startLoadTimer(); - reply({ type: 'start', config }); + reply({ + type: 'start', + config: { ...config, installGuards: sandboxArgv === undefined }, + }); }); }; diff --git a/packages/plugins/apps/src/vite/local-execution-protocol.ts b/packages/plugins/apps/src/vite/local-execution-protocol.ts index 803948c4c..4ba0e3e6d 100644 --- a/packages/plugins/apps/src/vite/local-execution-protocol.ts +++ b/packages/plugins/apps/src/vite/local-execution-protocol.ts @@ -14,6 +14,7 @@ export interface ChildStartConfig { runtimeContext: Record; actionCatalogInstalled: boolean; appsBackendInstalled: boolean; + installGuards: boolean; flushTimeoutMs: number; } diff --git a/packages/plugins/apps/src/vite/local-execution-sandbox.test.ts b/packages/plugins/apps/src/vite/local-execution-sandbox.test.ts new file mode 100644 index 000000000..7f4469fd8 --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution-sandbox.test.ts @@ -0,0 +1,225 @@ +// 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 fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { FIXTURE_ROOT } from './local-execution-process.fixtures'; +import { + collectReadPaths, + createDenyFilter, + fileOfModuleId, + guardModuleLoadError, + OUTSIDE_READ_ROOTS_REASON, + permissionModelArgv, + unreadableModuleError, +} from './local-execution-sandbox'; + +describe('Local execution sandbox', () => { + describe('Readable folders', () => { + test("Should include where a project's linked packages point", () => { + const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'dd-apps-links-'))); + try { + const project = path.join(base, 'project'); + const linkedPackage = path.join(base, 'linked-package'); + const scopedPackage = path.join(base, 'scoped-package'); + fs.mkdirSync(path.join(project, 'node_modules', '@scope'), { recursive: true }); + fs.mkdirSync(linkedPackage); + fs.mkdirSync(scopedPackage); + fs.symlinkSync(linkedPackage, path.join(project, 'node_modules', 'linked')); + fs.symlinkSync(scopedPackage, path.join(project, 'node_modules', '@scope', 'pkg')); + + const readPaths = collectReadPaths( + project, + '/plugin/dist/child.mjs', + '/vite/dist/node/module-runner.js', + ); + + expect(readPaths).toEqual( + expect.arrayContaining([project, linkedPackage, scopedPackage]), + ); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } + }); + + test("Should include the node_modules a linked package's own dependencies are hoisted to, through link cycles", () => { + const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'dd-apps-links-'))); + try { + const project = path.join(base, 'app'); + const otherWorkspace = path.join(base, 'other'); + const linkedPackage = path.join(otherWorkspace, 'packages', 'lib'); + const hoistedNodeModules = path.join(otherWorkspace, 'node_modules'); + fs.mkdirSync(path.join(project, 'node_modules'), { recursive: true }); + fs.mkdirSync(path.join(hoistedNodeModules, 'dep'), { recursive: true }); + fs.mkdirSync(linkedPackage, { recursive: true }); + fs.symlinkSync(linkedPackage, path.join(project, 'node_modules', 'lib')); + // The other workspace links back to the app, so the walk must stop on a cycle. + fs.symlinkSync(project, path.join(hoistedNodeModules, 'app')); + + const readPaths = collectReadPaths( + project, + '/plugin/dist/child.mjs', + '/vite/dist/node/module-runner.js', + ); + + expect(readPaths).toEqual( + expect.arrayContaining([project, linkedPackage, hoistedNodeModules]), + ); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } + }); + }); + + describe('Read path list', () => { + test('Should list no folder another listed folder already covers', () => { + const isInside = (root: string, candidate: string) => { + const relativePath = path.relative(root, candidate); + return !relativePath.startsWith('..') && !path.isAbsolute(relativePath); + }; + const readPaths = collectReadPaths( + FIXTURE_ROOT, + '/plugin/dist/child.mjs', + '/vite/dist/node/m.js', + [path.dirname(FIXTURE_ROOT)], + ); + const nested = readPaths.filter((candidate) => + readPaths.some((root) => root !== candidate && isInside(root, candidate)), + ); + expect(nested).toEqual([]); + expect(readPaths.some((root) => isInside(root, FIXTURE_ROOT))).toBe(true); + }); + }); + + describe('Deny filter', () => { + test.each([ + { file: '/repo/.env', denied: true }, + { file: '/repo/app/.env.local', denied: true }, + { file: '/repo/certs/server.PEM', denied: true }, + { file: '/repo/.git/config', denied: true }, + { file: '/repo/src/env.ts', denied: false }, + ])("Should match $file against Vite's default deny list: $denied", ({ file, denied }) => { + const isDenied = createDenyFilter(['.env', '.env.*', '*.{crt,pem}', '**/.git/**']); + expect(isDenied(file)).toBe(denied); + }); + }); + + describe('Module load errors', () => { + const parseFailure = (file: string) => ({ + error: { + name: 'RollupError', + message: `Parse failure: Unexpected token (1:4)\nAt file: ${file}\nContents of line 1: SECRET=value`, + id: file, + frame: '1: SECRET=value', + loc: { file, line: 1, column: 4 }, + }, + }); + const refusalOf = (file: string) => + file.startsWith('/project/') ? undefined : OUTSIDE_READ_ROOTS_REASON; + + test("Should keep a readable module's error as Vite reported it", () => { + const response = parseFailure('/project/src/broken.ts'); + expect(guardModuleLoadError(response, refusalOf)).toBe(response); + }); + + test('Should refuse, without its contents, an error about a module outside the readable folders', () => { + const guarded = guardModuleLoadError(parseFailure('/secrets/key.txt'), refusalOf); + expect(JSON.stringify(guarded)).not.toContain('SECRET'); + expect(guarded).toEqual( + unreadableModuleError('/secrets/key.txt', OUTSIDE_READ_ROOTS_REASON), + ); + }); + + test('Should keep only the first line and the code of an error that names no file', () => { + const response = { + error: { + name: 'Error', + message: 'Failed to load url x\nContents of line 1: SECRET=value', + code: 'ERR_LOAD_URL', + frame: '1: SECRET=value', + }, + }; + expect(guardModuleLoadError(response, refusalOf)).toEqual({ + error: { name: 'Error', message: 'Failed to load url x', code: 'ERR_LOAD_URL' }, + }); + }); + + test('Should pass a successful load through', () => { + const response = { result: { file: '/project/src/ok.ts', code: '' } }; + expect(guardModuleLoadError(response, refusalOf)).toBe(response); + }); + }); + + describe('Module ids', () => { + test.each([ + { id: '/@fs/C:/repo/node_modules/a.js', file: 'C:/repo/node_modules/a.js' }, + { id: '/repo/node_modules/a.js?raw', file: '/repo/node_modules/a.js' }, + { id: 'file:///repo/a.js', file: '/repo/a.js' }, + { id: '\0virtual:dd', file: undefined }, + { id: '/@id/__x00__virtual:dd', file: undefined }, + { id: 'node:fs', file: undefined }, + ])('Should read $id as $file', ({ id, file }) => { + expect(fileOfModuleId(id)).toBe(file); + }); + }); + + describe('Permission model flags', () => { + const readPaths = ['/project', '/project/node_modules']; + const tmpDir = '/tmp/dd-apps-exec-1'; + const writePaths = [tmpDir, '/tmp']; + const node18 = new Set(['--inspect']); + const node20 = new Set(['--experimental-permission', '--disable-warning']); + const node24 = new Set(['--permission', '--allow-inspector', '--disable-warning']); + const node26 = new Set([...node24, '--allow-net']); + const permissionArgv = (flag: string) => [ + '--disable-warning=ExperimentalWarning', + flag, + '--allow-fs-read=/project', + '--allow-fs-read=/project/node_modules', + `--allow-fs-read=${tmpDir}`, + '--allow-fs-read=/tmp', + `--allow-fs-write=${tmpDir}`, + '--allow-fs-write=/tmp', + ]; + + test.each([ + { + description: 'give none on a Node without a permission model', + nodeFlags: node18, + inspect: false, + argv: undefined, + }, + { + description: 'use the experimental permission flag on Node 20', + nodeFlags: node20, + inspect: false, + argv: permissionArgv('--experimental-permission'), + }, + { + description: 'give none while debugging on Node 20, which has no --allow-inspector', + nodeFlags: node20, + inspect: true, + argv: undefined, + }, + { + description: 'keep the permission model while debugging on Node 24', + nodeFlags: node24, + inspect: true, + argv: [...permissionArgv('--permission'), '--allow-inspector'], + }, + { + description: 'allow the network on a Node whose permission model covers it', + nodeFlags: node26, + inspect: false, + argv: [...permissionArgv('--permission'), '--allow-net'], + }, + ])('Should $description', ({ nodeFlags, inspect, argv }) => { + expect(permissionModelArgv({ readPaths, writePaths, nodeFlags, inspect })).toEqual( + argv, + ); + }); + }); +}); diff --git a/packages/plugins/apps/src/vite/local-execution-sandbox.ts b/packages/plugins/apps/src/vite/local-execution-sandbox.ts new file mode 100644 index 000000000..1629b1f62 --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution-sandbox.ts @@ -0,0 +1,302 @@ +// 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. + +/** What a local execution child process may read and write: Node permission flags and the checks on modules it loads over IPC. */ + +import { existsSync } from '@dd/core/helpers/fs'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import picomatch from 'picomatch'; + +import { isOutsideRoot } from '../backend/backend-sources'; + +import { isIndexableRecord } from './local-execution-shared'; + +function permissionFlag(nodeFlags: ReadonlySet): string | undefined { + return ['--permission', '--experimental-permission'].find((flag) => nodeFlags.has(flag)); +} + +/** Node's permission model flags for a call, or undefined when this Node has none, or has no `--allow-inspector` while debugging; the child then installs the in-process guards instead. */ +export function permissionModelArgv(options: { + readPaths: string[]; + writePaths: string[]; + nodeFlags: ReadonlySet; + inspect: boolean; +}): string[] | undefined { + const { readPaths, writePaths, nodeFlags, inspect } = options; + const permission = permissionFlag(nodeFlags); + if (!permission || (inspect && !nodeFlags.has('--allow-inspector'))) { + return undefined; + } + const argv: string[] = []; + if (nodeFlags.has('--disable-warning')) { + // Node 20's permission model and Node 25+'s --allow-net print an ExperimentalWarning per call. + argv.push('--disable-warning=ExperimentalWarning'); + } + argv.push(permission); + for (const readPath of [...readPaths, ...writePaths]) { + argv.push(`--allow-fs-read=${readPath}`); + } + for (const writePath of writePaths) { + argv.push(`--allow-fs-write=${writePath}`); + } + if (nodeFlags.has('--allow-net')) { + argv.push('--allow-net'); + } + if (inspect) { + argv.push('--allow-inspector'); + } + return argv; +} + +export function withRealPath(filePath: string): string[] { + try { + const realPath = fs.realpathSync.native(filePath); + return realPath === filePath ? [filePath] : [filePath, realPath]; + } catch { + return [filePath]; + } +} + +function isWithin(root: string, filePath: string): boolean { + const relativePath = path.relative(root, filePath); + return !isOutsideRoot(relativePath); +} + +function readDirEntries(dir: string): fs.Dirent[] { + try { + return fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } +} + +/** Where `npm link` or a `file:` dependency points, outside `node_modules`. */ +function linkedPackages(nodeModules: string): string[] { + const links: string[] = []; + for (const entry of readDirEntries(nodeModules)) { + const entryPath = path.join(nodeModules, entry.name); + if (entry.isSymbolicLink()) { + links.push(entryPath); + } else if (entry.name.startsWith('@') && entry.isDirectory()) { + const scopedEntries = readDirEntries(entryPath); + const scopedLinks = scopedEntries.filter((child) => child.isSymbolicLink()); + const scopedLinkPaths = scopedLinks.map((child) => path.join(entryPath, child.name)); + links.push(...scopedLinkPaths); + } + } + // A pnpm link into node_modules/.pnpm is already readable. + const targets = links.flatMap((link) => withRealPath(link).slice(1)); + return targets.filter((target) => !isWithin(nodeModules, target)); +} + +// Each path is a separate flag on the child's command line, which Windows caps at 32,767 characters. +function withoutNestedPaths(paths: string[]): string[] { + const byLength = [...new Set(paths)].sort((a, b) => a.length - b.length); + const kept: string[] = []; + for (const candidate of byLength) { + if (!kept.some((root) => isWithin(root, candidate))) { + kept.push(candidate); + } + } + return kept; +} + +/** The project, the child entry, Vite's module runner, `extraRoots`, and every `node_modules` up from the project and from each package linked into one, which a hoisted dependency can live in. */ +export function collectReadPaths( + projectRoot: string, + childEntryPath: string, + moduleRunnerPath: string, + extraRoots: string[] = [], +): string[] { + const childEntryDir = path.dirname(childEntryPath); + const moduleRunnerDir = path.dirname(moduleRunnerPath); + const paths = [projectRoot, childEntryDir, moduleRunnerDir, ...extraRoots]; + const walkedNodeModules = new Set(); + const startDirs = new Set([projectRoot]); + for (const startDir of startDirs) { + for (let dir = startDir; ; dir = path.dirname(dir)) { + const nodeModules = path.join(dir, 'node_modules'); + if (!walkedNodeModules.has(nodeModules) && existsSync(nodeModules)) { + walkedNodeModules.add(nodeModules); + const links = linkedPackages(nodeModules); + paths.push(nodeModules, ...links); + links.forEach((link) => startDirs.add(link)); + } + if (path.dirname(dir) === dir) { + break; + } + } + } + const pathsWithRealPaths = paths.flatMap(withRealPath); + return withoutNestedPaths(pathsWithRealPaths); +} + +// Production's os.tmpdir() is /tmp, and some dependencies write there directly. +export function sharedTmpPaths(): string[] { + return process.platform !== 'win32' && existsSync('/tmp') ? withRealPath('/tmp') : []; +} + +type FileFilter = (filePath: string) => boolean; + +// Vite's own server.fs.deny matching: a pattern without a slash matches at any depth. +export function createDenyFilter(denyPatterns: string[]): FileFilter { + const globs = denyPatterns.map((pattern) => + pattern.includes('/') ? pattern : `**/${pattern}`, + ); + const matchesDeny = picomatch(globs, { matchBase: false, nocase: true, dot: true }); + return (filePath) => { + const posixPath = filePath.split(path.sep).join('/'); + return matchesDeny(posixPath); + }; +} + +/** Undoes Vite's `/@id/` wrapping of a module id it couldn't keep as a URL, `\0` included. */ +function unwrapModuleId(url: string): string { + return url.startsWith('/@id/') ? url.slice('/@id/'.length).replace('__x00__', '\0') : url; +} + +/** The file behind a resolved module id; undefined for a virtual id. */ +export function fileOfModuleId(id: string): string | undefined { + const unwrappedId = unwrapModuleId(id); + const cleanId = unwrappedId.replace(/[?#].*$/, ''); + if (cleanId.startsWith('\0')) { + return undefined; + } + if (cleanId.startsWith('file://')) { + return fileURLToPath(cleanId); + } + const fsPath = cleanId.startsWith('/@fs/') ? cleanId.slice('/@fs'.length) : cleanId; + // Vite writes a Windows path as /@fs/C:/... + const filePath = /^\/[A-Za-z]:\//.test(fsPath) ? fsPath.slice(1) : fsPath; + return path.isAbsolute(filePath) || path.win32.isAbsolute(filePath) ? filePath : undefined; +} + +export const OUTSIDE_READ_ROOTS_REASON = "it's outside the folders a backend function can read"; +export const DENIED_MODULE_REASON = + "it matches Vite's `server.fs.deny`, which a backend function can't load"; + +/** Why a call can't load a file, or undefined when it can. */ +export type ModuleRefusal = (file: string) => string | undefined; + +export function unreadableModuleError(target: string, reason: string) { + return { + error: { + name: 'Error', + message: `Local execution can't load "${target}": ${reason}.`, + }, + }; +} + +/** + * A module that fails to load comes back with Vite's error, which can quote its source (a code frame, + * or a line of it in the message). Only an error about a file a call can read keeps those details. + */ +export function guardModuleLoadError(response: unknown, refusalOf: ModuleRefusal): unknown { + const error = isIndexableRecord(response) ? response.error : undefined; + if (!isIndexableRecord(error)) { + return response; + } + const loc = isIndexableRecord(error.loc) ? error.loc : undefined; + const candidates = [error.id, loc?.file, error.file]; + const errorId = candidates.find((candidate) => typeof candidate === 'string'); + const file = typeof errorId === 'string' ? fileOfModuleId(errorId) : undefined; + if (file !== undefined) { + const reason = refusalOf(file); + return reason === undefined ? response : unreadableModuleError(file, reason); + } + const name = typeof error.name === 'string' ? error.name : 'Error'; + const message = typeof error.message === 'string' ? error.message : 'The module failed to load'; + const [firstLine] = message.split('\n'); + // Kept so a caller can still tell a missing optional import by its code. + const code = typeof error.code === 'string' ? { code: error.code } : {}; + return { error: { name, message: firstLine, ...code } }; +} + +export interface ModuleLoadGuardOptions { + /** The folders module loads are limited to; undefined when Vite's `server.fs.strict` is off. */ + readPaths: string[] | undefined; + denyPatterns: string[]; + resolveModule: (url: string, importer: string | undefined) => Promise; +} + +export interface ModuleLoadGuard { + /** Refuses a `fetchModule` request before Vite reads the file. */ + refusalBeforeLoad(url: string, importer: unknown): Promise; + checkResponse(response: unknown): unknown; +} + +export function createModuleLoadGuard(options: ModuleLoadGuardOptions): ModuleLoadGuard { + const { readPaths, resolveModule } = options; + const isDenied = createDenyFilter(options.denyPatterns); + const realPaths = new Map(); + const resolvedFiles = new Map(); + + // The real path decides, since that's the file Vite reads. A path with nothing on disk passes: + // there's nothing to read there, and a plugin's virtual module can look like one. + const refusalOf: ModuleRefusal = (filePath) => { + let realPath = realPaths.get(filePath); + if (realPath === undefined) { + try { + realPath = fs.realpathSync.native(filePath); + } catch { + return undefined; + } + realPaths.set(filePath, realPath); + } + const checkedPath = realPath; + // Vite denies by the requested name, so a denied name linking elsewhere stays denied. + if (isDenied(filePath) || isDenied(checkedPath)) { + return DENIED_MODULE_REASON; + } + const isReadable = !readPaths || readPaths.some((root) => isWithin(root, checkedPath)); + return isReadable ? undefined : OUTSIDE_READ_ROOTS_REASON; + }; + + const fileRequestedBy = async (url: string, importer: unknown) => { + const moduleId = unwrapModuleId(url); + // Like Vite's fetchModule, only a bare specifier resolves from its importer. + const isBareSpecifier = !moduleId.startsWith('.') && !moduleId.startsWith('/'); + const importerId = isBareSpecifier && typeof importer === 'string' ? importer : undefined; + const cacheKey = `${importerId ?? ''}\0${moduleId}`; + if (resolvedFiles.has(cacheKey)) { + return resolvedFiles.get(cacheKey); + } + const resolvedId = await resolveModule(moduleId, importerId).catch(() => undefined); + // An id Vite can't resolve yet fails to load on its own, and may resolve once created. + if (resolvedId === undefined) { + return undefined; + } + const file = fileOfModuleId(resolvedId); + resolvedFiles.set(cacheKey, file); + return file; + }; + + return { + refusalBeforeLoad: async (url, importer) => { + // Vite's resolver follows links, so the name asked for is checked on its own. + const namedFile = fileOfModuleId(url); + if (namedFile && isDenied(namedFile)) { + return unreadableModuleError(namedFile, DENIED_MODULE_REASON); + } + const requestedFile = await fileRequestedBy(url, importer); + const reason = requestedFile && refusalOf(requestedFile); + return requestedFile && reason + ? unreadableModuleError(requestedFile, reason) + : undefined; + }, + checkResponse: (response) => { + const fetched = isIndexableRecord(response) ? response.result : undefined; + const fetchedId = isIndexableRecord(fetched) ? fetched.file : undefined; + const fetchedFile = + typeof fetchedId === 'string' ? fileOfModuleId(fetchedId) : undefined; + const reason = fetchedFile === undefined ? undefined : refusalOf(fetchedFile); + if (fetchedFile !== undefined && reason !== undefined) { + return unreadableModuleError(fetchedFile, reason); + } + return guardModuleLoadError(response, refusalOf); + }, + }; +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childLaterImport.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childLaterImport.backend.ts new file mode 100644 index 000000000..acfe5f8fb --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childLaterImport.backend.ts @@ -0,0 +1,9 @@ +// 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. + +// /childLater.ts doesn't exist. +export async function importLater() { + const later = await import('/childLater.ts'); + return later.value; +} diff --git a/yarn.lock b/yarn.lock index 5a81e9e8c..c36ff2842 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1957,11 +1957,13 @@ __metadata: "@types/eslint-scope": "npm:3.7.7" "@types/estree": "npm:1.0.8" "@types/node": "npm:^20" + "@types/picomatch": "npm:4.0.2" esbuild: "npm:0.25.8" eslint-scope: "npm:7.2.2" glob: "npm:11.1.0" graceful-fs: "npm:4.2.11" jszip: "npm:3.10.1" + picomatch: "npm:4.0.7" rollup: "npm:4.60.4" typescript: "npm:5.4.3" vite: "npm:6.4.3"