This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { documentRoutes } from '../../src/local/routes/documents.js';
|
||||
|
||||
/**
|
||||
* R2-005 regression — path traversal via the workspace :id param and the
|
||||
* document :name segment in documents.ts. A malicious segment must be
|
||||
* rejected with 400 BEFORE it reaches the workspaces/<id>/documents.json
|
||||
* filesystem path; a normal segment must pass the guard.
|
||||
*/
|
||||
|
||||
async function buildServer(): Promise<FastifyInstance> {
|
||||
const s = Fastify({ logger: false });
|
||||
await s.register(documentRoutes);
|
||||
await s.ready();
|
||||
return s;
|
||||
}
|
||||
|
||||
describe('R2-005 documents.ts path-traversal guard', () => {
|
||||
let server: FastifyInstance;
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) await server.close();
|
||||
});
|
||||
|
||||
it('rejects a traversal :id on GET list with 400', async () => {
|
||||
server = await buildServer();
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/workspaces/..%2f..%2fevil/documents',
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a traversal :id on POST register with 400', async () => {
|
||||
server = await buildServer();
|
||||
const res = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/workspaces/..%2fevil/documents',
|
||||
payload: { name: 'doc', path: '/tmp/doc.txt' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a traversal document name on POST register with 400, writing NO out-of-root file', async () => {
|
||||
server = await buildServer();
|
||||
const evilName = '..%2f..%2fevil';
|
||||
const res = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/workspaces/ws-safe/documents',
|
||||
payload: { name: '../../evil', path: '/tmp/doc.txt' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
|
||||
// Confirm no registry file leaked outside the workspace root.
|
||||
const outOfRoot = path.join(os.homedir(), '.waggle', 'evil');
|
||||
expect(fs.existsSync(outOfRoot)).toBe(false);
|
||||
expect(evilName).toContain('evil'); // keep the literal in scope
|
||||
});
|
||||
|
||||
it('rejects a traversal :id on GET versions with 400', async () => {
|
||||
server = await buildServer();
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/workspaces/..%2fevil/documents/doc/versions',
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a traversal :name on GET versions with 400', async () => {
|
||||
server = await buildServer();
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/workspaces/ws-safe/documents/..%2f..%2fevil/versions',
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('does NOT reject a valid :id on GET list', async () => {
|
||||
server = await buildServer();
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/workspaces/ws-valid_1/documents',
|
||||
});
|
||||
expect(res.statusCode).not.toBe(400);
|
||||
});
|
||||
|
||||
it('does NOT reject a valid :id + :name on GET versions', async () => {
|
||||
server = await buildServer();
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/workspaces/ws-valid_1/documents/my-doc_v1/versions',
|
||||
});
|
||||
expect(res.statusCode).not.toBe(400);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user