maestro/src/bridge/share-api.subtask-wildcard-traversal.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

136 lines
6.0 KiB
TypeScript

/**
* APIS-019 — Shared-subtask wildcard traversal hardening.
*
* Public token route: GET /api/shared/:token/subtasks/:jobId/files/*
* Containment relies on a separator-bounded prefix check:
*
* const base = resolve(job.worktreePath);
* const resolved = resolve(base, req.params[0]);
* if (resolved !== base && !resolved.startsWith(base + sep)) -> 403
*
* share-api.test.ts already asserts one `..` escape, cross-task containment,
* and a sibling prefix-collision at the WORKTREE level. This file targets the
* WILDCARD path segment (`req.params[0]`) directly with a battery of escape
* payloads — encoded `..`, deep `..`, absolute-path injection, and a sibling
* prefix-collision reached THROUGH the wildcard — asserting each is rejected
* (403/404) and the out-of-bounds secret is never served.
*
* Public route: no auth middleware mounted (token is the only credential).
*/
import { afterEach, describe, expect, it } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import express from 'express';
import request from 'supertest';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { mountShareApi } from './share-api.js';
const SECRET = 'TOP-SECRET-DO-NOT-LEAK';
/**
* Seed a shared task whose subtask worktree sits under the task workspace,
* plus a sibling directory `<taskWs>/subtasks/1-evil` that shares a string
* prefix with the legit subtask worktree `<taskWs>/subtasks/1`.
*/
async function seed(repo: Repository, tempDir: string) {
const task = await repo.createLocalTask({ title: 'parent', body: 'b' });
const taskWs = join(tempDir, 'ws');
mkdirSync(taskWs, { recursive: true });
await repo.updateLocalTask(task.id, { workspacePath: taskWs });
const repoName = localTaskRepoName(task.id);
const parent = await repo.createJob({ repo: repoName, issueNumber: task.id, instruction: 'parent' });
const sub = await repo.createJob({ repo: repoName, issueNumber: 1, instruction: 'sub', parentJobId: parent.id });
const subWs = join(taskWs, 'subtasks', '1');
mkdirSync(join(subWs, 'output'), { recursive: true });
writeFileSync(join(subWs, 'output', 'sub-result.md'), '# legit sub');
await repo.updateJob(sub.id, { worktreePath: subWs });
// Secret OUTSIDE the subtask worktree but inside the task workspace.
writeFileSync(join(taskWs, 'secret.txt'), SECRET);
// Sibling whose path is a string-prefix of the subtask worktree (1 vs 1-evil).
const siblingDir = join(taskWs, 'subtasks', '1-evil');
mkdirSync(siblingDir, { recursive: true });
writeFileSync(join(siblingDir, 'leak.txt'), SECRET);
// Secret well outside the whole task tree.
writeFileSync(join(tempDir, 'host-secret.txt'), SECRET);
const token = (await repo.shareLocalTask(task.id)) as string;
return { token, subJobId: sub.id, taskWs };
}
function setup() {
const tempDir = mkdtempSync(join(tmpdir(), 'maestro-share-wildcard-'));
const repo = new Repository(join(tempDir, 'test.db'));
const app = express();
app.use(express.json());
// Public share route — intentionally NO auth middleware.
mountShareApi(app, repo);
return { app, repo, tempDir };
}
describe('APIS-019 shared-subtask wildcard traversal', () => {
let tempDir = '';
let repo: Repository | null = null;
afterEach(() => {
if (repo) { repo.close(); repo = null; }
if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; }
});
it('serves a legitimate in-bounds file (control)', async () => {
const ctx = setup();
tempDir = ctx.tempDir; repo = ctx.repo;
const { token, subJobId } = await seed(ctx.repo, tempDir);
const res = await request(ctx.app).get(`/api/shared/${token}/subtasks/${subJobId}/files/output/sub-result.md`);
expect(res.status).toBe(200);
expect(res.text).toContain('# legit sub');
});
// Each payload must be rejected (403 containment, or 404 not-found) and must
// NOT return the secret in the body.
const payloads: Array<{ label: string; seg: string }> = [
{ label: 'encoded single-level ..', seg: '..%2Fsecret.txt' },
{ label: 'encoded deep ..', seg: '..%2F..%2Fhost-secret.txt' },
{ label: 'mixed encoded + literal ..', seg: 'output%2F..%2F..%2Fsecret.txt' },
{ label: 'sibling prefix-collision via wildcard', seg: '..%2F1-evil%2Fleak.txt' },
{ label: 'double-encoded ..', seg: '..%252Fsecret.txt' },
{ label: 'trailing traversal back into parent', seg: 'output%2F..%2F..%2F..%2Fhost-secret.txt' },
];
for (const { label, seg } of payloads) {
it(`rejects traversal payload: ${label}`, async () => {
const ctx = setup();
tempDir = ctx.tempDir; repo = ctx.repo;
const { token, subJobId } = await seed(ctx.repo, tempDir);
const res = await request(ctx.app).get(`/api/shared/${token}/subtasks/${subJobId}/files/${seg}`);
// Acceptable rejections: 403 (containment) or 404 (resolved-but-not-found).
expect([403, 404], `status for ${label}`).toContain(res.status);
// The secret must never be served.
expect(res.text ?? '', `body for ${label}`).not.toContain(SECRET);
});
}
it('an absolute-path wildcard segment cannot escape the worktree', async () => {
const ctx = setup();
tempDir = ctx.tempDir; repo = ctx.repo;
const { token, subJobId } = await seed(ctx.repo, tempDir);
// resolve(base, '/etc/passwd') => '/etc/passwd' (absolute wins), which is
// outside base → must be rejected, not served.
const res = await request(ctx.app).get(`/api/shared/${token}/subtasks/${subJobId}/files/%2Fetc%2Fpasswd`);
expect([403, 404]).toContain(res.status);
expect(res.text ?? '').not.toContain('root:');
});
it('unknown token on the wildcard route → 404 (not a 500/leak)', async () => {
const ctx = setup();
tempDir = ctx.tempDir; repo = ctx.repo;
await seed(ctx.repo, tempDir);
const res = await request(ctx.app).get('/api/shared/bogus-token/subtasks/999/files/output/sub-result.md');
expect(res.status).toBe(404);
});
});