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

237 lines
8.8 KiB
TypeScript

/**
* Regression tests for restore path containment, including R01-C006 and R01-C007.
*
* The restore loop in packages/server/src/local/routes/backup.ts validates each
* manifest entry's target path before writing it to dataDir. The original guard
* if (!resolved.startsWith(path.resolve(dataDir))) { skip }
* lacked a path-separator boundary, so a SIBLING directory whose name shares the
* dataDir prefix (root '/data', resolved '/data-evil/x') passed the check and
* escaped the root. It also wrote to a raw `targetPath` rather than the confirmed
* `resolved` path.
*
* These tests drive the real route via Fastify inject and prove:
* (a) a classic '../' traversal and a sibling-prefix escape are both rejected,
* and NO out-of-root file is written;
* (b) preview never probes an out-of-root path;
* (c) restore does not follow a pre-existing symlink or Windows junction;
* (d) normal in-root preview and restore behavior remains available.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import * as zlib from 'node:zlib';
import { backupRoutes } from '../../src/local/routes/backup.js';
const MAGIC_HEADER = 'WAGGLE-BACKUP-V1';
const IV_LENGTH = 16;
interface FileEntry {
relativePath: string;
content: string; // base64
sizeBytes: number;
}
/** Build an unencrypted .waggle-backup archive (base64) the restore route accepts. */
function buildBackupBase64(files: FileEntry[]): string {
const manifest = {
version: 1 as const,
createdAt: new Date().toISOString(),
fileCount: files.length,
files,
};
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(manifest), 'utf-8'));
const header = Buffer.from(MAGIC_HEADER, 'utf-8');
const zeroIv = Buffer.alloc(IV_LENGTH, 0); // all-zero IV = unencrypted sentinel
const zeroTag = Buffer.alloc(16, 0);
return Buffer.concat([header, zeroIv, zeroTag, compressed]).toString('base64');
}
function entry(relativePath: string, text: string): FileEntry {
const buf = Buffer.from(text, 'utf-8');
return { relativePath, content: buf.toString('base64'), sizeBytes: buf.length };
}
describe('/api/restore path containment', () => {
let server: FastifyInstance;
let rootBase: string;
let dataDir: string;
beforeEach(async () => {
// dataDir is a child of rootBase so we can detect sibling-prefix escapes.
rootBase = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-restore-'));
dataDir = path.join(rootBase, 'data');
fs.mkdirSync(dataDir, { recursive: true });
server = Fastify({ logger: false });
server.decorate('localConfig', { dataDir } as never);
await server.register(backupRoutes);
await server.ready();
});
afterEach(async () => {
await server.close();
fs.rmSync(rootBase, { recursive: true, force: true });
});
it('rejects a classic ../ traversal entry and writes nothing out of root', async () => {
const backup = buildBackupBase64([entry('../evil.txt', 'pwned')]);
const res = await server.inject({
method: 'POST',
url: '/api/restore',
payload: { backup },
});
expect(res.statusCode).toBe(400);
const json = res.json();
expect(json.restored).toBe(false);
expect(json.filesRestored).toBe(0);
expect(json.errors).toBeDefined();
expect(json.errors.some((e: string) => e.includes('path traversal'))).toBe(true);
// The escaped file must NOT exist outside dataDir.
expect(fs.existsSync(path.join(rootBase, 'evil.txt'))).toBe(false);
});
it('rejects a sibling-prefix escape (the bug the bare startsWith allowed)', async () => {
// resolves to <rootBase>/data-evil/x.txt — shares the 'data' prefix but is
// a sibling of dataDir, so the bare startsWith check would have let it pass.
const backup = buildBackupBase64([entry('../data-evil/x.txt', 'sibling-escape')]);
const res = await server.inject({
method: 'POST',
url: '/api/restore',
payload: { backup },
});
expect(res.statusCode).toBe(400);
const json = res.json();
expect(json.restored).toBe(false);
expect(json.filesRestored).toBe(0);
expect(json.errors.some((e: string) => e.includes('path traversal'))).toBe(true);
// Nothing must be written to the sibling-prefixed directory.
expect(fs.existsSync(path.join(rootBase, 'data-evil'))).toBe(false);
expect(fs.existsSync(path.join(rootBase, 'data-evil', 'x.txt'))).toBe(false);
});
it('rejects preview traversal before probing whether the out-of-root path exists', async () => {
const outsidePath = path.join(rootBase, 'preview-probe.txt');
fs.writeFileSync(outsidePath, 'outside', 'utf-8');
const backup = buildBackupBase64([entry('../preview-probe.txt', 'ignored')]);
const res = await server.inject({
method: 'POST',
url: '/api/restore',
payload: { backup, preview: true },
});
expect(res.statusCode).toBe(400);
const json = res.json();
expect(json.error).toContain('Invalid backup path');
expect(json.existingFiles).toBeUndefined();
expect(json.newFiles).toBeUndefined();
});
it('rejects Windows-style traversal separators in preview manifests', async () => {
const outsidePath = path.join(rootBase, 'windows-preview-probe.txt');
fs.writeFileSync(outsidePath, 'outside', 'utf-8');
const backup = buildBackupBase64([entry('..\\windows-preview-probe.txt', 'ignored')]);
const res = await server.inject({
method: 'POST',
url: '/api/restore',
payload: { backup, preview: true },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('Invalid backup path');
});
it('rejects preview probes through a pre-existing symlink or Windows junction', async () => {
const outsideDir = path.join(rootBase, 'outside-preview');
fs.mkdirSync(outsideDir);
fs.writeFileSync(path.join(outsideDir, 'secret.txt'), 'outside', 'utf-8');
fs.symlinkSync(outsideDir, path.join(dataDir, 'linked-preview'), process.platform === 'win32' ? 'junction' : 'dir');
const backup = buildBackupBase64([entry('linked-preview/secret.txt', 'ignored')]);
const res = await server.inject({
method: 'POST',
url: '/api/restore',
payload: { backup, preview: true },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('Invalid backup path');
});
it('does not write through a pre-existing symlink or Windows junction', async () => {
const outsideDir = path.join(rootBase, 'outside-restore');
fs.mkdirSync(outsideDir);
const outsidePath = path.join(outsideDir, 'target.txt');
fs.writeFileSync(outsidePath, 'original', 'utf-8');
fs.symlinkSync(outsideDir, path.join(dataDir, 'linked-restore'), process.platform === 'win32' ? 'junction' : 'dir');
const backup = buildBackupBase64([entry('linked-restore/target.txt', 'overwritten')]);
const res = await server.inject({
method: 'POST',
url: '/api/restore',
payload: { backup },
});
expect(res.statusCode).toBe(400);
const json = res.json();
expect(json.restored).toBe(false);
expect(json.filesRestored).toBe(0);
expect(json.errors.some((error: string) => error.includes('path traversal'))).toBe(true);
expect(fs.readFileSync(outsidePath, 'utf-8')).toBe('original');
});
it('previews normal nested in-root files without modifying them', async () => {
const existingPath = path.join(dataDir, 'mind', 'existing.mind');
fs.mkdirSync(path.dirname(existingPath), { recursive: true });
fs.writeFileSync(existingPath, 'existing', 'utf-8');
const backup = buildBackupBase64([
entry('mind/existing.mind', 'replacement'),
entry('mind/new.mind', 'new'),
]);
const res = await server.inject({
method: 'POST',
url: '/api/restore',
payload: { backup, preview: true },
});
expect(res.statusCode).toBe(200);
const json = res.json();
expect(json.existingFiles).toEqual(['mind/existing.mind']);
expect(json.newFiles).toEqual(['mind/new.mind']);
expect(fs.readFileSync(existingPath, 'utf-8')).toBe('existing');
});
it('restores a normal in-root file (valid path is NOT rejected)', async () => {
const backup = buildBackupBase64([
entry('notes.txt', 'hello'),
entry('mind/personal.mind', 'sub-dir content'),
]);
const res = await server.inject({
method: 'POST',
url: '/api/restore',
payload: { backup },
});
expect(res.statusCode).toBe(200);
const json = res.json();
expect(json.filesRestored).toBe(2);
expect(json.errors).toBeUndefined();
// Files land inside dataDir with their content intact.
expect(fs.readFileSync(path.join(dataDir, 'notes.txt'), 'utf-8')).toBe('hello');
expect(fs.readFileSync(path.join(dataDir, 'mind', 'personal.mind'), 'utf-8')).toBe('sub-dir content');
});
});