diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md index 1e124c296..4042e8be8 100644 --- a/packages/plugins/apps/README.md +++ b/packages/plugins/apps/README.md @@ -65,10 +65,11 @@ On Vite 6.0.4 or later, `/__dd/executeAction` runs each backend function call th - **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**: in-process guards 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. 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`. `$.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. Vite 5 has no module runner transport, so there each call runs inside the dev server's process. Vite 6.0.0 to 6.0.3, whose dev server answers the module runner in an older shape, do the same with a warning at startup, as do a Yarn Plug'n'Play project, whose packages load through hooks a call's process doesn't get, and any Vite 6+ project where `vite/module-runner` can't be resolved. diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json index f51dd795b..f6e6e7d52 100644 --- a/packages/plugins/apps/package.json +++ b/packages/plugins/apps/package.json @@ -40,12 +40,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/index.ts b/packages/plugins/apps/src/vite/index.ts index 9b2fb9a56..fa7b43985 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -499,6 +499,17 @@ export const getVitePlugin = ({ projectRoot: context.buildRoot, childEntryPath: getLocalExecutionChildPath(), 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 d5ba5b5ac..c419728f3 100644 --- a/packages/plugins/apps/src/vite/local-execution-child.ts +++ b/packages/plugins/apps/src/vite/local-execution-child.ts @@ -193,8 +193,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 c3c691f30..58f0e7ac5 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 { fork } from 'child_process'; import fs from 'fs'; import http from 'http'; +import os from 'os'; import path from 'path'; import { pathToFileURL } from 'url'; import { build, createServer, type HotPayload, type Plugin, type ViteDevServer } from 'vite'; @@ -30,6 +31,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 { DEFAULT_LONG_POLLING_CONFIG, deriveActionTimeouts, @@ -74,6 +76,9 @@ const isProcessAlive = (pid: number) => { } }; +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) { @@ -142,6 +147,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, @@ -502,6 +512,7 @@ describe('Local execution in a child process', () => { runtimeContext: previewRuntimeContext, actionCatalogInstalled: false, appsBackendInstalled: false, + installGuards: false, flushTimeoutMs: 1000, }; const child = fork(childEntryPath, [], { @@ -585,7 +596,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; @@ -618,7 +631,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( @@ -628,6 +641,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', @@ -637,14 +679,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( @@ -698,8 +744,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", @@ -713,6 +920,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 () => { diff --git a/packages/plugins/apps/src/vite/local-execution-process.ts b/packages/plugins/apps/src/vite/local-execution-process.ts index a17883f9f..17f2051d4 100644 --- a/packages/plugins/apps/src/vite/local-execution-process.ts +++ b/packages/plugins/apps/src/vite/local-execution-process.ts @@ -2,7 +2,7 @@ // 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'; @@ -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 { isIndexableRecord, validateActionCall } from './local-execution-shared'; import type { ExecuteAction, GetRuntimeContext } from './local-execution'; import { @@ -63,8 +70,17 @@ export interface ChildProcessExecutorOptions { projectRoot: string; childEntryPath: string; moduleRunnerPath: string; + /** 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; isInspectorActive?: () => boolean; /** How long a finished call waits for its output to flush before returning anyway. */ flushTimeoutMs?: number; @@ -243,7 +259,7 @@ function isActionMessage(message: Record): message is Record; allowedConnectionIds: string[]; executeAction: ExecuteAction; timeoutMs: number; @@ -258,12 +274,19 @@ export function createChildProcessExecutor( projectRoot, childEntryPath, moduleRunnerPath, + readRoots, + denyPatterns, + restrictModuleLoads, + resolveModule, log, writeOutput = writeToTerminal, + nodeFlags = process.allowedNodeEnvironmentFlags, isInspectorActive = isDebuggerAttached, flushTimeoutMs = DEFAULT_FLUSH_TIMEOUT_MS, } = options; 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; @@ -287,13 +310,25 @@ 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'); - } 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, + }); + 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: buildChildEnv(tmpDir), @@ -323,6 +358,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; @@ -393,10 +435,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', @@ -405,7 +452,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 ( @@ -526,7 +574,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 8967d3664..c9d780e58 100644 --- a/packages/plugins/apps/src/vite/local-execution-protocol.ts +++ b/packages/plugins/apps/src/vite/local-execution-protocol.ts @@ -15,6 +15,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..e0e04b650 --- /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/ast-parsing/module-graph'; + +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"