136 lines
6.0 KiB
TypeScript
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);
|
|
});
|
|
});
|