maestro/src/bridge/local-api-helpers.test.ts
oss-sync b857c33ef6
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (f6d625db)
2026-06-26 03:35:45 +00:00

200 lines
8.6 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { Response } from 'express';
import * as fs from 'fs';
import * as os from 'os';
import { join, posix, win32 } from 'path';
import { setUntrustedFileResponseHeaders, ensurePathWithin, isPathEscapeError, isNotFoundError, safeZipEntryName, isJobWithinWorkspace, isProtectedWorkspaceDir, collectZipFiles } from './local-api-helpers.js';
describe('isJobWithinWorkspace (separator-bounded containment)', () => {
it('accepts the workspace itself and descendants', () => {
expect(isJobWithinWorkspace('/wt/local/12', '/wt/local/12')).toBe(true);
expect(isJobWithinWorkspace('/wt/local/12', '/wt/local/12/subtasks/1')).toBe(true);
});
it('rejects a sibling sharing only a string prefix (the /12 vs /123 越境)', () => {
expect(isJobWithinWorkspace('/wt/local/12', '/wt/local/123')).toBe(false);
expect(isJobWithinWorkspace('/wt/local/12', '/wt/local/123/subtasks/1')).toBe(false);
expect(isJobWithinWorkspace('/wt/ws', '/wt/ws-evil/output')).toBe(false);
});
it('rejects null/empty inputs', () => {
expect(isJobWithinWorkspace(null, '/wt/local/12')).toBe(false);
expect(isJobWithinWorkspace('/wt/local/12', null)).toBe(false);
expect(isJobWithinWorkspace('', '/wt/local/12')).toBe(false);
});
});
describe('safeZipEntryName', () => {
const root = '/srv/ws';
it('keeps a plain relative path as the entry name', () => {
expect(safeZipEntryName(root, '/srv/ws/a.txt')).toBe('a.txt');
expect(safeZipEntryName(root, '/srv/ws/sub/b.txt')).toBe('sub/b.txt');
});
it('produces names that are never absolute (POSIX or Windows), never contain .. or backslash', () => {
// Each is a single literal filename living directly inside root — the
// dangerous forms an attacker could create on POSIX and weaponize on extract.
const evilNames = [
'a\\..\\evil.txt', // backslash + .. (Windows traversal)
'C:\\evil.txt', // drive-letter absolute
'\\\\server\\share\\x.txt', // UNC
'a/../../evil.txt',
];
for (const name of evilNames) {
const entry = safeZipEntryName(root, `/srv/ws/${name}`);
expect(entry.includes('\\'), `${name} -> ${entry}`).toBe(false);
expect(entry.split('/').includes('..'), `${name} -> ${entry}`).toBe(false);
expect(posix.isAbsolute(entry), `${name} -> ${entry}`).toBe(false);
expect(win32.isAbsolute(entry), `${name} -> ${entry}`).toBe(false);
}
});
it('neutralizes a Windows drive-letter segment (C: -> C_)', () => {
expect(safeZipEntryName(root, '/srv/ws/C:\\evil.txt')).toBe('C_/evil.txt');
});
});
describe('isNotFoundError', () => {
it('matches ENOENT and ENOTDIR (missing file / non-dir in path)', () => {
expect(isNotFoundError(Object.assign(new Error('x'), { code: 'ENOENT' }))).toBe(true);
expect(isNotFoundError(Object.assign(new Error('x'), { code: 'ENOTDIR' }))).toBe(true);
});
it('does not match other errors (EACCES, plain Error, null)', () => {
expect(isNotFoundError(Object.assign(new Error('x'), { code: 'EACCES' }))).toBe(false);
expect(isNotFoundError(new Error('boom'))).toBe(false);
expect(isNotFoundError(null)).toBe(false);
});
});
function fakeRes(): { res: Response; headers: Record<string, string> } {
const headers: Record<string, string> = {};
const res = {
setHeader(name: string, value: string) {
headers[name] = value;
},
} as unknown as Response;
return { res, headers };
}
describe('setUntrustedFileResponseHeaders', () => {
it('disables MIME sniffing and sandboxes the document', () => {
const { res, headers } = fakeRes();
setUntrustedFileResponseHeaders(res);
// nosniff: a text/* file cannot be re-interpreted as executable HTML
expect(headers['X-Content-Type-Options']).toBe('nosniff');
// CSP sandbox (no allow-scripts token) => scripts disabled + opaque origin,
// so HTML/SVG written into a workspace cannot run on our origin or ride the
// viewer's session (stored XSS, incl. the unauthenticated share endpoint).
expect(headers['Content-Security-Policy']).toBe('sandbox');
expect(headers['Content-Security-Policy']).not.toContain('allow-scripts');
});
});
describe('ensurePathWithin (regression: no escape, no sibling-prefix bypass)', () => {
it('allows a path inside the base', () => {
const base = '/tmp/ws';
expect(ensurePathWithin(base, 'output/a.txt')).toBe('/tmp/ws/output/a.txt');
});
it('rejects traversal out of the base', () => {
expect(() => ensurePathWithin('/tmp/ws', '../etc/passwd')).toThrow();
});
it('rejects a sibling dir sharing the base prefix', () => {
// /tmp/ws-evil must not pass the /tmp/ws containment check
expect(() => ensurePathWithin('/tmp/ws', '../ws-evil/x')).toThrow();
});
});
describe('ensurePathWithin (symlink escape hardening)', () => {
let root: string;
let outside: string;
beforeEach(() => {
const base = fs.mkdtempSync(join(os.tmpdir(), 'maestro-helpers-'));
root = join(base, 'root');
outside = join(base, 'outside');
fs.mkdirSync(root, { recursive: true });
fs.mkdirSync(outside, { recursive: true });
fs.writeFileSync(join(outside, 'secret.txt'), 'secret');
});
afterEach(() => {
try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
try { fs.rmSync(outside, { recursive: true, force: true }); } catch { /* ignore */ }
});
it('rejects a symlink inside root that points outside (isPathEscapeError matches)', () => {
// An agent-created symlink resolves lexically "within" root but escapes via realpath.
fs.symlinkSync(outside, join(root, 'evil'));
let err: unknown;
try { ensurePathWithin(root, 'evil/x'); } catch (e) { err = e; }
expect(err).toBeInstanceOf(Error);
expect(isPathEscapeError(err)).toBe(true);
});
it('still resolves a normal in-root path when root exists', () => {
expect(ensurePathWithin(root, 'sub/file.txt')).toBe(join(root, 'sub', 'file.txt'));
});
it('still rejects ../escape when root exists', () => {
let err: unknown;
try { ensurePathWithin(root, '../escape'); } catch (e) { err = e; }
expect(err).toBeInstanceOf(Error);
expect(isPathEscapeError(err)).toBe(true);
});
});
describe('isProtectedWorkspaceDir (構造フォルダの削除ガード)', () => {
let wsRoot: string;
beforeEach(() => { wsRoot = fs.mkdtempSync(join(os.tmpdir(), 'protect-dir-')); });
afterEach(() => { fs.rmSync(wsRoot, { recursive: true, force: true }); });
it('protects every top-level structure dir', () => {
for (const d of ['input', 'output', 'logs', 'apps', 'readonly', 'source', 'skills', 'subtasks']) {
expect(isProtectedWorkspaceDir(wsRoot, join(wsRoot, d))).toBe(true);
}
});
it('does not protect nested dirs or user-created top-level dirs', () => {
expect(isProtectedWorkspaceDir(wsRoot, join(wsRoot, 'output', 'sub'))).toBe(false);
expect(isProtectedWorkspaceDir(wsRoot, join(wsRoot, 'myfolder'))).toBe(false);
});
it('does not protect the workspace root itself, and rejects escapes', () => {
expect(isProtectedWorkspaceDir(wsRoot, wsRoot)).toBe(false);
expect(isProtectedWorkspaceDir(wsRoot, join(wsRoot, '..', 'output'))).toBe(false);
});
});
describe('collectZipFiles (フォルダの再帰 zip 収集)', () => {
let zroot: string;
beforeEach(() => { zroot = fs.mkdtempSync(join(os.tmpdir(), 'collect-zip-')); });
afterEach(() => { fs.rmSync(zroot, { recursive: true, force: true }); });
it('returns a single entry for a file (name relative to root)', () => {
fs.writeFileSync(join(zroot, 'a.txt'), 'x');
expect(collectZipFiles(zroot, join(zroot, 'a.txt')).map(g => g.entryName)).toEqual(['a.txt']);
});
it('recurses into a directory and keeps the folder structure in entry names', () => {
fs.mkdirSync(join(zroot, 'd', 'e'), { recursive: true });
fs.writeFileSync(join(zroot, 'd', 'top.txt'), '1');
fs.writeFileSync(join(zroot, 'd', 'e', 'deep.txt'), '2');
const got = collectZipFiles(zroot, join(zroot, 'd')).map(g => g.entryName).sort();
expect(got).toEqual(['d/e/deep.txt', 'd/top.txt']);
});
it('skips symlinks so a link cannot pull in content from outside', () => {
fs.mkdirSync(join(zroot, 'd'), { recursive: true });
fs.writeFileSync(join(zroot, 'secret.txt'), 's');
try {
fs.symlinkSync(join(zroot, 'secret.txt'), join(zroot, 'd', 'link.txt'));
} catch {
return; // symlink 不可な環境ではスキップ
}
expect(collectZipFiles(zroot, join(zroot, 'd')).map(g => g.entryName)).toEqual([]);
});
it('returns [] for a missing path', () => {
expect(collectZipFiles(zroot, join(zroot, 'nope'))).toEqual([]);
});
});