fix: reject traversal in migration paths

This commit is contained in:
msa46 2026-08-02 16:17:26 +02:00
parent 745554e2fb
commit 6791b9b96c
2 changed files with 20 additions and 2 deletions

View file

@ -5,6 +5,10 @@ import { join } from 'node:path';
const SOURCE_COUNT = 29; const SOURCE_COUNT = 29;
const DOCS_ROOT = 'src/content/docs/'; const DOCS_ROOT = 'src/content/docs/';
function hasParentTraversal(path) {
return path.split('/').includes('..');
}
export async function loadManifest(path) { export async function loadManifest(path) {
return JSON.parse(await readFile(path, 'utf8')); return JSON.parse(await readFile(path, 'utf8'));
} }
@ -36,10 +40,10 @@ export function validateManifest(manifest) {
if (source.startsWith('pages/wiki/')) { if (source.startsWith('pages/wiki/')) {
throw new Error(`Generic DokuWiki source is excluded: ${source}`); throw new Error(`Generic DokuWiki source is excluded: ${source}`);
} }
if (!(source === 'pages/start.txt' || source === 'pages/study.txt' || source.startsWith('pages/study/'))) { if (hasParentTraversal(source) || !(source === 'pages/start.txt' || source === 'pages/study.txt' || source.startsWith('pages/study/'))) {
throw new Error(`Source is outside the selected migration scope: ${source}`); throw new Error(`Source is outside the selected migration scope: ${source}`);
} }
if (!destination.startsWith(DOCS_ROOT)) { if (hasParentTraversal(destination) || !destination.startsWith(DOCS_ROOT)) {
throw new Error(`Destination is outside ${DOCS_ROOT}: ${destination}`); throw new Error(`Destination is outside ${DOCS_ROOT}: ${destination}`);
} }
if (sources.has(source)) { if (sources.has(source)) {

View file

@ -17,6 +17,20 @@ test('generic DokuWiki pages are excluded', async () => {
assert.equal(manifest.entries.some(({ source }) => source.startsWith('pages/wiki/')), false); assert.equal(manifest.entries.some(({ source }) => source.startsWith('pages/wiki/')), false);
}); });
test('validation rejects source traversal outside the selected scope', async () => {
const manifest = await loadManifest('docs/migration-manifest.json');
manifest.entries[2].source = 'pages/study/../../wiki/x.txt';
assert.throws(() => validateManifest(manifest), /outside the selected migration scope/);
});
test('validation rejects destination traversal outside the docs root', async () => {
const manifest = await loadManifest('docs/migration-manifest.json');
manifest.entries[2].destination = 'src/content/docs/../../../outside.md';
assert.throws(() => validateManifest(manifest), /outside src\/content\/docs/);
});
test('validation rejects duplicate destinations unless the later entry is a merge', async () => { test('validation rejects duplicate destinations unless the later entry is a merge', async () => {
const manifest = await loadManifest('docs/migration-manifest.json'); const manifest = await loadManifest('docs/migration-manifest.json');
manifest.entries[1].mode = 'page'; manifest.entries[1].mode = 'page';