Skip to content
Merged
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 @@ -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`, so a project inside `/tmp` is writable too. A refused read or write throws `ERR_ACCESS_DENIED`, not `ENOENT`, so code that tolerates only a missing file fails locally. 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`), plus `SystemRoot` on Windows, 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`.

Expand Down
2 changes: 2 additions & 0 deletions packages/plugins/apps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,14 @@
"eslint-scope": "7.2.2",
"glob": "11.1.0",
"jszip": "3.10.1",
"picomatch": "4.0.7",
"semver": "7.7.2"
},
"devDependencies": {
"@types/eslint-scope": "3.7.7",
"@types/estree": "1.0.8",
"@types/node": "^20",
"@types/picomatch": "4.0.2",
"@types/semver": "7.5.8",
"graceful-fs": "4.2.11",
"rollup": "4.60.4",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {},
});
Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/apps/src/vite/dev-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
Expand Down
10 changes: 10 additions & 0 deletions packages/plugins/apps/src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,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;
Expand Down
12 changes: 9 additions & 3 deletions packages/plugins/apps/src/vite/local-execution-child.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -29,7 +30,7 @@ type CreateImportMeta = (modulePath: string) => unknown;
type ModuleRunnerOptions = {
transport: { invoke(payload: unknown): Promise<unknown> };
hmr: false;
createImportMeta: CreateImportMeta;
createImportMeta?: CreateImportMeta;
};
type ModuleRunnerModule = {
ModuleRunner: new (options: ModuleRunnerOptions, evaluator: unknown) => ModuleRunnerInstance;
Expand Down Expand Up @@ -168,11 +169,13 @@ async function load(config: ChildStartConfig): Promise<unknown> {
// 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);
Expand All @@ -195,8 +198,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 +203 to +204

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
Loading
Loading