Files
waggle-os/packages/server/tests/local/phase2-traversal-tasks.test.ts
Oleg Maslov b20b138fe4 moving
2026-09-02 10:14:22 +02:00

110 lines
3.7 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { taskRoutes } from '../../src/local/routes/tasks.js';
import type { LocalConfig } from '../../src/local/index.js';
// R6-002: the /api/workspaces/:id/tasks handlers used the :id route param
// directly in the tasks filesystem path (mkdir + write + read) with no
// validation, enabling directory-creation + write traversal. assertSafeSegment
// is now called at the top of every handler that uses :id in a path.
function buildServer(dataDir: string): FastifyInstance {
const server = Fastify({ logger: false });
// taskRoutes only needs localConfig.dataDir for the workspace-scoped routes;
// the rest are filled with inert defaults to satisfy the LocalConfig shape.
const localConfig: LocalConfig = { dataDir, port: 0, host: '127.0.0.1', litellmUrl: '' };
server.decorate('localConfig', localConfig);
server.register(taskRoutes);
return server;
}
describe('tasks routes — path traversal guard (R6-002)', () => {
let dataDir: string;
let server: FastifyInstance;
beforeEach(async () => {
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-tasks-test-'));
server = buildServer(dataDir);
await server.ready();
});
afterEach(async () => {
await server.close();
fs.rmSync(dataDir, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 100,
});
});
it('GET rejects a traversal :id with 400', async () => {
const res = await server.inject({
method: 'GET',
url: '/api/workspaces/..%2f..%2fevil/tasks',
});
expect(res.statusCode).toBe(400);
});
it('POST rejects a traversal :id with 400 and writes NO out-of-root file', async () => {
// Sentinel: a sibling of dataDir that a successful traversal would create.
const escapeTarget = path.join(path.dirname(dataDir), 'evil');
const res = await server.inject({
method: 'POST',
url: '/api/workspaces/..%2f..%2fevil/tasks',
payload: { title: 'pwned' },
});
expect(res.statusCode).toBe(400);
// No directory should have been created outside the data root.
expect(fs.existsSync(escapeTarget)).toBe(false);
expect(fs.existsSync(path.join(escapeTarget, 'tasks.jsonl'))).toBe(false);
});
it('PATCH rejects a traversal :id with 400', async () => {
const res = await server.inject({
method: 'PATCH',
url: '/api/workspaces/..%2fevil/tasks/some-task',
payload: { status: 'done' },
});
expect(res.statusCode).toBe(400);
});
it('DELETE rejects a traversal :id with 400', async () => {
const res = await server.inject({
method: 'DELETE',
url: '/api/workspaces/..%2fevil/tasks/some-task',
});
expect(res.statusCode).toBe(400);
});
it('accepts a normal valid :id (POST is NOT rejected as 400)', async () => {
const res = await server.inject({
method: 'POST',
url: '/api/workspaces/my-workspace_01/tasks',
payload: { title: 'real task' },
});
// 201 Created on success — and definitely not the 400 traversal rejection.
expect(res.statusCode).not.toBe(400);
expect(res.statusCode).toBe(201);
// The task file lands inside the data root, not outside it.
expect(
fs.existsSync(path.join(dataDir, 'workspaces', 'my-workspace_01', 'tasks.jsonl')),
).toBe(true);
});
it('GET accepts a normal valid :id (NOT 400)', async () => {
const res = await server.inject({
method: 'GET',
url: '/api/workspaces/my-workspace_01/tasks',
});
expect(res.statusCode).not.toBe(400);
expect(res.statusCode).toBe(200);
});
});