/** * 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 `/subtasks/1-evil` that shares a string * prefix with the legit subtask worktree `/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); }); });