Harden remote API security boundaries
This commit is contained in:
@@ -1,6 +1,80 @@
|
||||
import { createRealpathCache } from '../path-realpath-cache.js';
|
||||
import nodeFsPromises from 'node:fs/promises';
|
||||
import nodePath from 'node:path';
|
||||
|
||||
const EXEC_JOB_TTL_MS = 30 * 60 * 1000;
|
||||
const OUTSIDE_FILE_GRANT_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
const outsideFileGrants = new Map();
|
||||
|
||||
const pruneOutsideFileGrants = () => {
|
||||
const now = Date.now();
|
||||
for (const [token, grant] of outsideFileGrants.entries()) {
|
||||
if (!grant || grant.expiresAt <= now) {
|
||||
outsideFileGrants.delete(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const mintOutsideFileGrant = async (targetPath, {
|
||||
scopes = ['stat', 'read', 'raw'],
|
||||
fsPromises = nodeFsPromises,
|
||||
path = nodePath,
|
||||
crypto = globalThis.crypto,
|
||||
} = {}) => {
|
||||
const raw = typeof targetPath === 'string' ? targetPath.trim() : '';
|
||||
if (!raw) {
|
||||
throw new Error('Path is required');
|
||||
}
|
||||
const canonicalPath = await fsPromises.realpath(raw);
|
||||
const stats = await fsPromises.stat(canonicalPath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error('Outside file grants require a file path');
|
||||
}
|
||||
pruneOutsideFileGrants();
|
||||
const token = typeof crypto?.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const normalizedScopes = new Set(
|
||||
(Array.isArray(scopes) ? scopes : [])
|
||||
.filter((scope) => typeof scope === 'string' && scope.trim())
|
||||
.map((scope) => scope.trim())
|
||||
);
|
||||
if (normalizedScopes.size === 0) {
|
||||
normalizedScopes.add('read');
|
||||
}
|
||||
const grant = {
|
||||
canonicalPath,
|
||||
base: path.dirname(canonicalPath),
|
||||
scopes: normalizedScopes,
|
||||
expiresAt: Date.now() + OUTSIDE_FILE_GRANT_TTL_MS,
|
||||
};
|
||||
outsideFileGrants.set(token, grant);
|
||||
return {
|
||||
path: canonicalPath,
|
||||
outsideFileGrant: token,
|
||||
expiresAt: grant.expiresAt,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveOutsideFileGrant = async ({ token, targetPath, scope, fsPromises }) => {
|
||||
pruneOutsideFileGrants();
|
||||
if (typeof token !== 'string' || !token.trim()) {
|
||||
return { ok: false, error: 'Outside workspace file access requires a grant' };
|
||||
}
|
||||
const grant = outsideFileGrants.get(token.trim());
|
||||
if (!grant) {
|
||||
return { ok: false, error: 'Outside workspace file grant is invalid or expired' };
|
||||
}
|
||||
if (!grant.scopes.has(scope)) {
|
||||
return { ok: false, error: 'Outside workspace file grant does not allow this operation' };
|
||||
}
|
||||
const canonicalPath = await fsPromises.realpath(targetPath);
|
||||
if (canonicalPath !== grant.canonicalPath) {
|
||||
return { ok: false, error: 'Outside workspace file grant does not match requested path' };
|
||||
}
|
||||
return { ok: true, base: grant.base, resolved: canonicalPath, granted: true };
|
||||
};
|
||||
|
||||
const createCommandTimeoutMs = () => {
|
||||
const raw = Number(process.env.OPENCHAMBER_FS_EXEC_TIMEOUT_MS);
|
||||
@@ -175,14 +249,19 @@ const escapeCloneSshKeyPath = (sshKeyPath) => {
|
||||
return `'${normalized.replace(/'/g, "'\\''")}'`;
|
||||
};
|
||||
|
||||
const resolveReadPathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
|
||||
const resolveReadPathFromContext = async ({ req, targetPath, scope, resolveProjectDirectory, path, os, fsPromises, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
|
||||
if (req.query?.allowOutsideWorkspace === 'true') {
|
||||
const normalized = normalizeDirectoryPath(targetPath);
|
||||
if (!normalized || typeof normalized !== 'string') {
|
||||
return { ok: false, error: 'Path is required' };
|
||||
}
|
||||
const resolved = path.resolve(normalized);
|
||||
return { ok: true, base: path.dirname(resolved), resolved };
|
||||
return resolveOutsideFileGrant({
|
||||
token: req.query?.outsideFileGrant,
|
||||
targetPath: resolved,
|
||||
scope,
|
||||
fsPromises,
|
||||
});
|
||||
}
|
||||
|
||||
return resolveWorkspacePathFromContext({
|
||||
@@ -451,7 +530,8 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
|
||||
let resolvedPath = '';
|
||||
if (allowOutsideWorkspace) {
|
||||
resolvedPath = path.resolve(normalizeDirectoryPath(dirPath));
|
||||
console.warn('Rejected outside-workspace mkdir without trusted directory grant');
|
||||
return res.status(403).json({ error: 'Outside workspace directory creation requires a grant' });
|
||||
} else {
|
||||
const resolved = await resolveWorkspacePathFromContext({
|
||||
req,
|
||||
@@ -595,13 +675,18 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
const resolved = await resolveReadPathFromContext({
|
||||
req,
|
||||
targetPath: filePath,
|
||||
scope: 'stat',
|
||||
resolveProjectDirectory,
|
||||
path,
|
||||
os,
|
||||
fsPromises,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
if (req.query?.allowOutsideWorkspace === 'true') {
|
||||
console.warn(`Rejected outside-workspace stat: ${resolved.error}`);
|
||||
}
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
}
|
||||
|
||||
@@ -644,13 +729,18 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
const resolved = await resolveReadPathFromContext({
|
||||
req,
|
||||
targetPath: filePath,
|
||||
scope: 'read',
|
||||
resolveProjectDirectory,
|
||||
path,
|
||||
os,
|
||||
fsPromises,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
if (req.query?.allowOutsideWorkspace === 'true') {
|
||||
console.warn(`Rejected outside-workspace read: ${resolved.error}`);
|
||||
}
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
}
|
||||
|
||||
@@ -710,13 +800,18 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
const resolved = await resolveReadPathFromContext({
|
||||
req,
|
||||
targetPath: filePath,
|
||||
scope: 'raw',
|
||||
resolveProjectDirectory,
|
||||
path,
|
||||
os,
|
||||
fsPromises,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
if (req.query?.allowOutsideWorkspace === 'true') {
|
||||
console.warn(`Rejected outside-workspace raw read: ${resolved.error}`);
|
||||
}
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
}
|
||||
|
||||
@@ -756,6 +851,9 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
|
||||
const content = await fsPromises.readFile(canonicalPath);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (resolved.granted) {
|
||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||
}
|
||||
return res.type(mimeType).send(content);
|
||||
} catch (error) {
|
||||
const err = error;
|
||||
@@ -989,7 +1087,25 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
pruneGitReadCache();
|
||||
|
||||
try {
|
||||
const resolvedCwd = path.resolve(normalizeDirectoryPath(cwd));
|
||||
if (background === true) {
|
||||
console.warn('Rejected background /api/fs/exec request');
|
||||
return res.status(400).json({ error: 'Background command execution is not allowed' });
|
||||
}
|
||||
const resolvedCwdCandidate = path.resolve(normalizeDirectoryPath(cwd));
|
||||
const resolvedForWorkspace = await resolveWorkspacePathFromContext({
|
||||
req,
|
||||
targetPath: resolvedCwdCandidate,
|
||||
resolveProjectDirectory,
|
||||
path,
|
||||
os,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
});
|
||||
if (!resolvedForWorkspace.ok) {
|
||||
console.warn(`Rejected /api/fs/exec outside workspace: ${resolvedForWorkspace.error}`);
|
||||
return res.status(403).json({ error: resolvedForWorkspace.error });
|
||||
}
|
||||
const resolvedCwd = resolvedForWorkspace.resolved;
|
||||
const stats = await fsPromises.stat(resolvedCwd);
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Specified cwd is not a directory' });
|
||||
@@ -1015,7 +1131,7 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
|
||||
execJobs.set(jobId, job);
|
||||
|
||||
const isBackground = background === true;
|
||||
const isBackground = false;
|
||||
if (isBackground) {
|
||||
void runExecJob(job).catch((error) => {
|
||||
job.status = 'done';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerFsRoutes } from './routes.js';
|
||||
import { mintOutsideFileGrant, registerFsRoutes } from './routes.js';
|
||||
|
||||
const createRouteRegistry = () => {
|
||||
const routes = new Map();
|
||||
@@ -24,6 +24,7 @@ const createRouteRegistry = () => {
|
||||
const createMockResponse = () => {
|
||||
let statusCode = 200;
|
||||
let body = null;
|
||||
const headers = new Map();
|
||||
return {
|
||||
status(code) {
|
||||
statusCode = code;
|
||||
@@ -40,6 +41,13 @@ const createMockResponse = () => {
|
||||
body = payload;
|
||||
return this;
|
||||
},
|
||||
setHeader(name, value) {
|
||||
headers.set(name.toLowerCase(), value);
|
||||
return this;
|
||||
},
|
||||
getHeader(name) {
|
||||
return headers.get(name.toLowerCase());
|
||||
},
|
||||
get statusCode() {
|
||||
return statusCode;
|
||||
},
|
||||
@@ -152,6 +160,46 @@ const registerRead = (fsPromises) => {
|
||||
return getRoute('GET', '/api/fs/read');
|
||||
};
|
||||
|
||||
const registerRaw = (fsPromises) => {
|
||||
const { app, getRoute } = createRouteRegistry();
|
||||
registerFsRoutes(app, {
|
||||
os: { homedir: () => '/home/user' },
|
||||
path: path.posix,
|
||||
fsPromises: {
|
||||
realpath: async (targetPath) => targetPath,
|
||||
...fsPromises,
|
||||
},
|
||||
spawn: vi.fn(),
|
||||
crypto: { randomUUID: () => 'job-0' },
|
||||
normalizeDirectoryPath: (p) => p,
|
||||
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
||||
buildAugmentedPath: () => '/usr/bin',
|
||||
resolveGitBinaryForSpawn: () => 'git',
|
||||
openchamberUserConfigRoot: '/home/user/.config',
|
||||
});
|
||||
return getRoute('GET', '/api/fs/raw');
|
||||
};
|
||||
|
||||
const registerMkdir = (fsPromises) => {
|
||||
const { app, getRoute } = createRouteRegistry();
|
||||
registerFsRoutes(app, {
|
||||
os: { homedir: () => '/home/user' },
|
||||
path: path.posix,
|
||||
fsPromises: {
|
||||
realpath: async (targetPath) => targetPath,
|
||||
...fsPromises,
|
||||
},
|
||||
spawn: vi.fn(),
|
||||
crypto: { randomUUID: () => 'job-0' },
|
||||
normalizeDirectoryPath: (p) => p,
|
||||
resolveProjectDirectory: async () => ({ directory: '/repo' }),
|
||||
buildAugmentedPath: () => '/usr/bin',
|
||||
resolveGitBinaryForSpawn: () => 'git',
|
||||
openchamberUserConfigRoot: '/home/user/.config',
|
||||
});
|
||||
return getRoute('POST', '/api/fs/mkdir');
|
||||
};
|
||||
|
||||
const callExec = async (handler, body) => {
|
||||
const res = createMockResponse();
|
||||
await handler({ body }, res);
|
||||
@@ -170,6 +218,18 @@ const callRead = async (handler, query) => {
|
||||
return res;
|
||||
};
|
||||
|
||||
const callRaw = async (handler, query) => {
|
||||
const res = createMockResponse();
|
||||
await handler({ query }, res);
|
||||
return res;
|
||||
};
|
||||
|
||||
const callMkdir = async (handler, body) => {
|
||||
const res = createMockResponse();
|
||||
await handler({ body }, res);
|
||||
return res;
|
||||
};
|
||||
|
||||
describe('fs write', () => {
|
||||
it('does not rewrite a file when content is unchanged', async () => {
|
||||
const fsPromises = {
|
||||
@@ -254,6 +314,106 @@ describe('fs write', () => {
|
||||
});
|
||||
|
||||
describe('fs read', () => {
|
||||
it('rejects outside workspace reads without a grant', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const fsPromises = {
|
||||
stat: vi.fn(async () => ({ isFile: () => true, size: 3 })),
|
||||
readFile: vi.fn(async () => 'secret'),
|
||||
};
|
||||
const handler = registerRead(fsPromises);
|
||||
|
||||
const res = await callRead(handler, { path: '/etc/passwd', allowOutsideWorkspace: 'true' });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Outside workspace file access requires a grant' });
|
||||
expect(fsPromises.readFile).not.toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('allows outside workspace reads with an exact-path grant', async () => {
|
||||
const fsPromises = {
|
||||
realpath: vi.fn(async (targetPath) => targetPath),
|
||||
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
|
||||
readFile: vi.fn(async () => 'secret'),
|
||||
};
|
||||
const grant = await mintOutsideFileGrant('/outside/plan.txt', {
|
||||
fsPromises,
|
||||
path: path.posix,
|
||||
crypto: { randomUUID: () => 'grant-read' },
|
||||
});
|
||||
const handler = registerRead(fsPromises);
|
||||
|
||||
const res = await callRead(handler, {
|
||||
path: '/outside/plan.txt',
|
||||
allowOutsideWorkspace: 'true',
|
||||
outsideFileGrant: grant.outsideFileGrant,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe('secret');
|
||||
});
|
||||
|
||||
it('rejects outside workspace grants for a different canonical path', async () => {
|
||||
const fsPromises = {
|
||||
realpath: vi.fn(async (targetPath) => targetPath),
|
||||
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
|
||||
readFile: vi.fn(async () => 'secret'),
|
||||
};
|
||||
const grant = await mintOutsideFileGrant('/outside/a.txt', {
|
||||
fsPromises,
|
||||
path: path.posix,
|
||||
crypto: { randomUUID: () => 'grant-mismatch' },
|
||||
});
|
||||
const handler = registerRead(fsPromises);
|
||||
|
||||
const res = await callRead(handler, {
|
||||
path: '/outside/b.txt',
|
||||
allowOutsideWorkspace: 'true',
|
||||
outsideFileGrant: grant.outsideFileGrant,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Outside workspace file grant does not match requested path' });
|
||||
expect(fsPromises.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets no-referrer on raw responses served through outside file grants', async () => {
|
||||
const fsPromises = {
|
||||
realpath: vi.fn(async (targetPath) => targetPath),
|
||||
stat: vi.fn(async () => ({ isFile: () => true, size: 6 })),
|
||||
readFile: vi.fn(async () => Buffer.from('secret')),
|
||||
};
|
||||
const grant = await mintOutsideFileGrant('/outside/image.png', {
|
||||
scopes: ['raw'],
|
||||
fsPromises,
|
||||
path: path.posix,
|
||||
crypto: { randomUUID: () => 'grant-raw' },
|
||||
});
|
||||
const handler = registerRaw(fsPromises);
|
||||
|
||||
const res = await callRaw(handler, {
|
||||
path: '/outside/image.png',
|
||||
allowOutsideWorkspace: 'true',
|
||||
outsideFileGrant: grant.outsideFileGrant,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.getHeader('referrer-policy')).toBe('no-referrer');
|
||||
});
|
||||
|
||||
it('rejects outside workspace mkdir without a trusted directory grant', async () => {
|
||||
const fsPromises = {
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
};
|
||||
const handler = registerMkdir(fsPromises);
|
||||
|
||||
const res = await callMkdir(handler, { path: '/tmp/staging', allowOutsideWorkspace: true });
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'Outside workspace directory creation requires a grant' });
|
||||
expect(fsPromises.mkdir).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs when empty-read retries are exhausted after non-empty stat', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const fsPromises = {
|
||||
@@ -280,6 +440,30 @@ describe('fs exec git-read cache', () => {
|
||||
delete process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS;
|
||||
});
|
||||
|
||||
it('rejects background command execution', async () => {
|
||||
const { spawn } = createSpawn();
|
||||
const handler = registerExec({ spawn });
|
||||
|
||||
const res = await callExec(handler, { commands: ['id'], cwd: '/repo', background: true });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Background command execution is not allowed' });
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects command execution outside the workspace', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { spawn } = createSpawn();
|
||||
const handler = registerExec({ spawn });
|
||||
|
||||
const res = await callExec(handler, { commands: ['id'], cwd: '/' });
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'Path is outside of active workspace' });
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('caches an allowlisted git rev-parse across identical requests', async () => {
|
||||
const command = 'git rev-parse --absolute-git-dir --git-common-dir';
|
||||
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/repo/.git\n.git\n' } });
|
||||
@@ -333,8 +517,8 @@ describe('fs exec git-read cache', () => {
|
||||
const { spawn, calls } = createSpawn({ stdoutByCommand: { [command]: '/x/.git\n' } });
|
||||
const handler = registerExec({ spawn });
|
||||
|
||||
await callExec(handler, { commands: [command], cwd: '/repo-a' });
|
||||
await callExec(handler, { commands: [command], cwd: '/repo-b' });
|
||||
await callExec(handler, { commands: [command], cwd: '/repo/a' });
|
||||
await callExec(handler, { commands: [command], cwd: '/repo/b' });
|
||||
|
||||
expect(calls.length).toBe(2);
|
||||
});
|
||||
@@ -355,8 +539,8 @@ describe('fs exec git-read cache', () => {
|
||||
const { spawn, calls } = createSpawn({ stdoutByCommand: {}, exitCode: 128 });
|
||||
const handler = registerExec({ spawn });
|
||||
|
||||
await callExec(handler, { commands: [command], cwd: '/not-a-repo' });
|
||||
await callExec(handler, { commands: [command], cwd: '/not-a-repo' });
|
||||
await callExec(handler, { commands: [command], cwd: '/repo/not-a-repo' });
|
||||
await callExec(handler, { commands: [command], cwd: '/repo/not-a-repo' });
|
||||
|
||||
expect(calls.length).toBe(2);
|
||||
});
|
||||
@@ -398,16 +582,16 @@ describe('fs exec git-read cache', () => {
|
||||
|
||||
// Fill to the 500-entry ceiling with distinct working directories.
|
||||
for (let i = 0; i < 500; i += 1) {
|
||||
await callExec(handler, { commands: [command], cwd: `/repo-${i}` });
|
||||
await callExec(handler, { commands: [command], cwd: `/repo/worktree-${i}` });
|
||||
}
|
||||
const afterFill = calls.length;
|
||||
expect(afterFill).toBe(500);
|
||||
|
||||
// One more distinct dir evicts the oldest entry (/repo-0).
|
||||
await callExec(handler, { commands: [command], cwd: '/repo-overflow' });
|
||||
// One more distinct dir evicts the oldest entry (/repo/worktree-0).
|
||||
await callExec(handler, { commands: [command], cwd: '/repo/worktree-overflow' });
|
||||
// Evicted entry must re-run; a surviving entry must still be served.
|
||||
await callExec(handler, { commands: [command], cwd: '/repo-0' }); // evicted -> spawns
|
||||
await callExec(handler, { commands: [command], cwd: '/repo-499' }); // cached -> no spawn
|
||||
await callExec(handler, { commands: [command], cwd: '/repo/worktree-0' }); // evicted -> spawns
|
||||
await callExec(handler, { commands: [command], cwd: '/repo/worktree-499' }); // cached -> no spawn
|
||||
|
||||
expect(calls.length).toBe(afterFill + 2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user