Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/plugins/apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion packages/plugins/apps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions packages/plugins/apps/src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion packages/plugins/apps/src/vite/local-execution-child.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,11 @@ async function load(config: ChildStartConfig): Promise<unknown> {
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<unknown> {
if (!config.installGuards) {
return load(config);
Comment on lines +198 to +199

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted as a known limitation: Node's permission model follows symlinks out of granted paths, as its docs note. Local execution is a parity aid, not a security boundary, so this is listed under Out of Scope and in the README instead of re-adding the JavaScript write guard.

}
installGuards();
return runBlocked(() => load(config));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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, [], {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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',
Expand All @@ -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(
Expand Down Expand Up @@ -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<ChildProcessExecutorOptions> = {}) =>
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",
Expand All @@ -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 () => {
Expand Down
Loading
Loading