fix(worker): contain workspace paths against symlinks

This commit is contained in:
Gabriel Brown
2026-07-10 17:11:00 -04:00
parent 2bc090c378
commit 57c175aecd
4 changed files with 107 additions and 23 deletions
+39
View File
@@ -0,0 +1,39 @@
import { lstat, realpath } from 'node:fs/promises';
import path from 'node:path';
const realpathOrDeepestExisting = async (target: string): Promise<string> => {
let current = target;
const tail: string[] = [];
for (;;) {
try {
const real = await realpath(current);
return path.join(real, ...tail.reverse());
} catch (error) {
if ((error as { code?: string }).code !== 'ENOENT') throw error;
const parent = path.dirname(current);
if (parent === current) throw error;
tail.push(path.basename(current));
current = parent;
}
}
};
export const assertContainedRealPath = async (
root: string,
requested: string,
opts: { forWrite?: boolean } = {},
): Promise<string> => {
const lexical = path.resolve(root, requested);
const realRoot = await realpath(root);
const resolved = await realpathOrDeepestExisting(lexical);
if (resolved !== realRoot && !resolved.startsWith(`${realRoot}${path.sep}`)) {
throw new Error(`Refusing to access path outside root: ${requested}`);
}
if (opts.forWrite) {
const info = await lstat(lexical).catch(() => null);
if (info?.isSymbolicLink()) {
throw new Error(`Refusing to write through a symlink: ${requested}`);
}
}
return resolved;
};
+4 -11
View File
@@ -7,6 +7,7 @@ import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { env } from './env';
import { assertContainedRealPath } from './path-containment';
import { runExecInContainer } from './runtime/docker';
const client = new ConvexHttpClient(env.convexUrl);
@@ -31,16 +32,6 @@ export const fetchUserEnvironment = async (
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
// Keep a written path inside the home directory.
const safeHomeJoin = (homeDir: string, relPath: string) => {
const target = path.resolve(homeDir, relPath);
const root = path.resolve(homeDir);
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
throw new Error(`Refusing to write dotfile outside home: ${relPath}`);
}
return target;
};
/**
* Materializes the persistent per-user home: a `.bash_profile` so login shells
* load `~/.bashrc`; (when configured and changed) a clone of the public dotfiles
@@ -111,7 +102,9 @@ export const materializeUserHome = async (args: {
// Editable overlay tree (wins over the repo/setup output).
for (const file of userEnv.files) {
const target = safeHomeJoin(homeDir, file.path);
const target = await assertContainedRealPath(homeDir, file.path, {
forWrite: true,
});
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, file.content);
if (file.isExecutable) await chmod(target, 0o755);
+10 -12
View File
@@ -34,6 +34,7 @@ import {
promptOpenCodeSession,
replyOpenCodePermission,
} from './opencode-session';
import { assertContainedRealPath } from './path-containment';
import { createRedactor, truncate } from './redact';
import {
listWorkspaceContainerNames,
@@ -1058,15 +1059,6 @@ const resolveWorkspace = (jobId: string) => {
return workspace;
};
const safeWorkspacePath = (repoDir: string, filePath: string) => {
const resolved = path.resolve(repoDir, filePath);
const root = path.resolve(repoDir);
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
throw new Error(`Refusing to access path outside repository: ${filePath}`);
}
return resolved;
};
const fileChangedType = async (repoDir: string, filePath: string) => {
const status = await run('git', ['status', '--short', '--', filePath], {
cwd: repoDir,
@@ -1169,7 +1161,11 @@ const recordChangedFiles = async (
const materializeEnvFile = async (workspace: ActiveWorkspace) => {
const { claim, repoDir } = workspace;
if (!claim.job.materializeEnvFile || !claim.job.envFilePath) return;
const envPath = safeWorkspacePath(repoDir, claim.job.envFilePath);
const envPath = await assertContainedRealPath(
repoDir,
claim.job.envFilePath,
{ forWrite: true },
);
await mkdir(path.dirname(envPath), { recursive: true });
const content = `${claim.secrets
.map((secret) => `${secret.name}=${JSON.stringify(secret.value)}`)
@@ -1503,7 +1499,7 @@ export const listWorkspaceTree = async (jobId: string) => {
export const readWorkspaceFile = async (jobId: string, filePath: string) => {
const workspace = resolveWorkspace(jobId);
const target = safeWorkspacePath(workspace.repoDir, filePath);
const target = await assertContainedRealPath(workspace.repoDir, filePath);
return await readFile(target, 'utf8');
};
@@ -1513,7 +1509,9 @@ export const writeWorkspaceFile = async (
content: string,
) => {
const workspace = resolveWorkspace(jobId);
const target = safeWorkspacePath(workspace.repoDir, filePath);
const target = await assertContainedRealPath(workspace.repoDir, filePath, {
forWrite: true,
});
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, content);
const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);