105 lines
3.6 KiB
TypeScript
105 lines
3.6 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 });
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|