Skip to content

Commit ce2568c

Browse files
authored
fix(export): sanitize URIs before using them as directory paths (#1471)
## 🧰 Changes Addresses a bug in the new `export docs` and `export reference` commands where if the `parent` or `category` URI we get back from our API call contains directory separators we wouldn't do any sanitization on them, potentially leading to destructive file mutations. To prevent this this adds in some checks to ensure that these URIs are valid URIs, containing valid slugs, and do not ever traverse outside of the directory the user supplies to the export command to export their files to.
1 parent d427b68 commit ce2568c

5 files changed

Lines changed: 258 additions & 14 deletions

File tree

src/lib/exportDocs.ts

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { isRecord } from '../utils.js';
1313

1414
import { parse as parseFrontmatter } from './frontmatter.js';
1515
import { oraOptions } from './logger.js';
16+
import { decodeURILastSegment, isSafePathSegment, resolvePathWithinRoot } from './safePath.js';
1617

1718
type ChangelogsRequestSchema = PageRequestSchema<'changelogs'>;
1819
type CustomPagesRequestSchema = PageRequestSchema<'custom_pages'>;
@@ -47,7 +48,7 @@ interface FileMapEntry {
4748
* Scrub out unnecessary data from the API and simplify fields.
4849
*
4950
*/
50-
function scrub(data: GeneralRequestSchema): Record<string, unknown> | undefined {
51+
function scrub(data: GeneralRequestSchema): Record<string, unknown> | null | undefined {
5152
const denylist = new Set(['api', 'href', 'links', 'project', 'renderable', 'updated_at', 'uri']);
5253

5354
/** API defaults (to ensure we omit these when they're unchanged) */
@@ -79,10 +80,14 @@ function scrub(data: GeneralRequestSchema): Record<string, unknown> | undefined
7980

8081
scrubbed[key] = filtered;
8182
} else if (key === 'category' && isRecord(value) && typeof value.uri === 'string') {
82-
const name = decodeURIComponent(value.uri.split('/').pop() || '');
83+
const name = decodeURILastSegment(value.uri);
84+
if (name === null) return null;
85+
8386
scrubbed[key] = { uri: name };
8487
} else if (key === 'parent' && isRecord(value) && typeof value.uri === 'string') {
85-
const slugPart = decodeURIComponent(value.uri.split('/').pop() || '');
88+
const slugPart = decodeURILastSegment(value.uri);
89+
if (slugPart === null) return null;
90+
8691
scrubbed[key] = { uri: slugPart };
8792
} else if (!(key in DEFAULTS && value === DEFAULTS[key as keyof typeof DEFAULTS])) {
8893
scrubbed[key] = value;
@@ -121,7 +126,7 @@ function buildFileMap(this: APIv2PageExportCommands, files: string[]): Map<strin
121126
}
122127
} catch (err) {
123128
const message = err instanceof Error ? err.message : String(err);
124-
this.error(`Error parsing frontmatter in ${filePath}: ${message}`);
129+
throw new Error(`Error parsing frontmatter in ${filePath}: ${message}`, { cause: err });
125130
}
126131

127132
if (frontmatter) {
@@ -159,14 +164,13 @@ function buildPath(
159164
visited: Set<string> = new Set(),
160165
): string | null {
161166
if (visited.has(slug)) {
162-
this.error(`Circular reference detected for slug: ${slug}`);
167+
throw new Error(`Circular reference detected for slug: ${slug}`);
163168
}
164169
visited.add(slug);
165170

166171
const fileInfo = fileMap.get(slug);
167172
if (!fileInfo) {
168-
this.error(`File not found for slug: ${slug}`);
169-
return null;
173+
throw new Error(`File not found for slug: ${slug}`);
170174
}
171175

172176
const parts: string[] = [];
@@ -230,7 +234,11 @@ function restructureFiles(this: APIv2PageExportCommands, tempFolder: string, fin
230234
this.debug('Copying files to final structure:');
231235

232236
for (const r of results) {
233-
const dest = path.join(finalFolder, r.newPath);
237+
const dest = resolvePathWithinRoot(finalFolder, r.newPath);
238+
if (!dest) {
239+
throw new Error(`Refusing to write outside export directory: ${r.newPath}`);
240+
}
241+
234242
fs.mkdirSync(path.dirname(dest), { recursive: true });
235243
fs.copyFileSync(r.oldPath, dest);
236244
}
@@ -329,12 +337,35 @@ export async function exportDocs(this: APIv2PageExportCommands): Promise<FullExp
329337
parentCounts[parentKey] += 1;
330338
}
331339

332-
const frontmatter = scrub(output);
333-
const yamlFront = dumpYAML(frontmatter, { sortKeys: true });
334-
const md = `---\n${yamlFront}---\n${body}`;
335-
336-
fs.writeFileSync(path.join(tempFolder, `${output.slug}.md`), md, { encoding: 'utf-8' });
337-
downloads.completed.push(output);
340+
const pageSlug = output.slug;
341+
if (!pageSlug || !isSafePathSegment(pageSlug)) {
342+
this.warn(`Skipping page "${String(pageSlug)}": slug is invalid.`);
343+
this.debug(
344+
`"${String(pageSlug)}" contains directory separators and is not safe to use within the filesystem.`,
345+
);
346+
downloads.failed.push(String(pageSlug));
347+
} else {
348+
const frontmatter = scrub(output);
349+
if (!frontmatter) {
350+
this.warn(`Skipping page "${pageSlug}": category or parent URI in API response is invalid.`);
351+
this.debug(
352+
`"${String(pageSlug)}" is invalid because its category or parent URI contains directory separators and is not safe to use within the filesystem.`,
353+
);
354+
downloads.failed.push(pageSlug);
355+
} else {
356+
const yamlFront = dumpYAML(frontmatter, { sortKeys: true });
357+
const md = `---\n${yamlFront}---\n${body}`;
358+
const tempPath = resolvePathWithinRoot(tempFolder, `${pageSlug}.md`);
359+
360+
if (!tempPath) {
361+
this.warn(`Skipping page "${pageSlug}": refused to write outside the temporary export folder.`);
362+
downloads.failed.push(pageSlug);
363+
} else {
364+
fs.writeFileSync(tempPath, md, { encoding: 'utf-8' });
365+
downloads.completed.push(output);
366+
}
367+
}
368+
}
338369
}
339370
}
340371
} finally {

src/lib/safePath.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import path from 'node:path';
2+
3+
/**
4+
* Determine if a URL path segment is safe to use as a filesystem path component, preventing us
5+
* from treating a path segment as a potential directory traversal attack.
6+
*
7+
*/
8+
export function isSafePathSegment(segment: unknown): boolean {
9+
if (!segment || typeof segment !== 'string') return false;
10+
if (segment === '.' || segment === '..') return false;
11+
12+
// oxlint-disable-next-line no-control-regex
13+
if (/[\0/\\]/.test(segment)) return false;
14+
15+
if (segment.includes('..')) return false;
16+
17+
return true;
18+
}
19+
20+
/**
21+
* Decode the last path segment of a URI and validate it as a safe path component.
22+
*
23+
*/
24+
export function decodeURILastSegment(uri: string): string | null {
25+
const encoded = uri.split('/').pop() || '';
26+
let decoded: string;
27+
28+
try {
29+
decoded = decodeURIComponent(encoded);
30+
} catch {
31+
return null;
32+
}
33+
34+
return isSafePathSegment(decoded) ? decoded : null;
35+
}
36+
37+
/**
38+
* Resolve the segments of a supplied path within a root directory and only return the path if it
39+
* is contained within the supplied root.
40+
*
41+
*/
42+
export function resolvePathWithinRoot(root: string, ...segments: string[]): string | null {
43+
const resolvedRoot = path.resolve(root);
44+
const dest = path.resolve(resolvedRoot, ...segments);
45+
46+
if (dest === resolvedRoot || dest.startsWith(`${resolvedRoot}${path.sep}`)) {
47+
return dest;
48+
}
49+
50+
return null;
51+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`rdme docs upload > should not write outside the export directory when category URI contains path traversal 1`] = `
4+
"- 📩 Exporting files from ReadMe...
5+
› Warning: Skipping page "rdme-cat-poc": category or parent URI in API
6+
› response is invalid.
7+
✖ 📩 Exporting files from ReadMe... 1 file(s) failed.
8+
"
9+
`;
10+
11+
exports[`rdme docs upload > should not write outside the export directory when slug contains path traversal 1`] = `
12+
"- 📩 Exporting files from ReadMe...
13+
› Warning: Skipping page "../../evil": slug is invalid.
14+
✖ 📩 Exporting files from ReadMe... 1 file(s) failed.
15+
"
16+
`;
17+
18+
exports[`rdme reference upload > should not write outside the export directory when category URI contains path traversal 1`] = `
19+
"- 📩 Exporting files from ReadMe...
20+
› Warning: Skipping page "rdme-cat-poc": category or parent URI in API
21+
› response is invalid.
22+
✖ 📩 Exporting files from ReadMe... 1 file(s) failed.
23+
"
24+
`;
25+
26+
exports[`rdme reference upload > should not write outside the export directory when slug contains path traversal 1`] = `
27+
"- 📩 Exporting files from ReadMe...
28+
› Warning: Skipping page "../../evil": slug is invalid.
29+
✖ 📩 Exporting files from ReadMe... 1 file(s) failed.
30+
"
31+
`;

test/commands/pages/export.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { OclifOutput } from '../../helpers/oclif.js';
22

3+
import { randomUUID } from 'node:crypto';
34
import fs from 'node:fs';
45
import os from 'node:os';
56
import path from 'node:path';
@@ -171,4 +172,85 @@ Child body`),
171172
fs.rmSync(tmpDir, { recursive: true, force: true });
172173
}
173174
});
175+
176+
it('should not write outside the export directory when category URI contains path traversal', async () => {
177+
const tmpDir = tempExportDir();
178+
const parentDir = path.dirname(tmpDir);
179+
const escapeDir = path.join(parentDir, `escape-${randomUUID()}`);
180+
181+
try {
182+
const evilCategory = '%2e%2e%2fescape';
183+
const mock = getAPIv2Mock({ authorization })
184+
.get(`/branches/stable/categories/${route}`)
185+
.reply(200, { data: [{ title: evilCategory }] })
186+
.get(`/branches/stable/categories/${route}/${encodeURIComponent(evilCategory)}/pages`)
187+
.reply(200, { data: [{ slug: 'rdme-cat-poc' }] })
188+
.get(`/branches/stable/${route}/rdme-cat-poc`)
189+
.reply(200, {
190+
data: {
191+
slug: 'rdme-cat-poc',
192+
title: 'PoC',
193+
type: 'basic',
194+
content: { body: 'TRAVERSAL POC FILE.' },
195+
category: { uri: `/branches/stable/categories/${route}/${evilCategory}` },
196+
},
197+
});
198+
199+
const output = await run([tmpDir, '--key', key]);
200+
201+
expect(fs.writeFileSync).not.toHaveBeenCalled();
202+
expect(fs.copyFileSync).not.toHaveBeenCalled();
203+
expect(fs.existsSync(escapeDir)).toBe(false);
204+
205+
expect(output.stderr).toMatchSnapshot();
206+
expect(output.result).toMatchObject({ failed: ['rdme-cat-poc'] });
207+
208+
mock.done();
209+
} finally {
210+
if (fs.existsSync(escapeDir)) {
211+
fs.rmSync(escapeDir, { recursive: true, force: true });
212+
}
213+
fs.rmSync(tmpDir, { recursive: true, force: true });
214+
}
215+
});
216+
217+
it('should not write outside the export directory when slug contains path traversal', async () => {
218+
const tmpDir = tempExportDir();
219+
const parentDir = path.dirname(tmpDir);
220+
const evilDir = path.join(parentDir, `evil-${randomUUID()}`);
221+
222+
try {
223+
const mock = getAPIv2Mock({ authorization })
224+
.get(`/branches/stable/categories/${route}`)
225+
.reply(200, { data: [{ title: 'Main' }] })
226+
.get(`/branches/stable/categories/${route}/Main/pages`)
227+
.reply(200, { data: [{ slug: '../../evil' }] })
228+
.get(`/branches/stable/${route}/${encodeURIComponent('../../evil')}`)
229+
.reply(200, {
230+
data: {
231+
slug: '../../evil',
232+
title: 'Evil',
233+
type: 'basic',
234+
content: { body: 'malicious body' },
235+
category: { uri: `https://api.readme.com/v2/branches/stable/categories/${route}/main` },
236+
},
237+
});
238+
239+
const output = await run([tmpDir, '--key', key]);
240+
241+
expect(fs.writeFileSync).not.toHaveBeenCalled();
242+
expect(fs.copyFileSync).not.toHaveBeenCalled();
243+
expect(fs.existsSync(evilDir)).toBe(false);
244+
245+
expect(output.stderr).toMatchSnapshot();
246+
expect(output.result).toMatchObject({ failed: ['../../evil'] });
247+
248+
mock.done();
249+
} finally {
250+
if (fs.existsSync(evilDir)) {
251+
fs.rmSync(evilDir, { recursive: true, force: true });
252+
}
253+
fs.rmSync(tmpDir, { recursive: true, force: true });
254+
}
255+
});
174256
});

test/lib/safePath.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import path from 'node:path';
2+
3+
import { describe, expect, it } from 'vitest';
4+
5+
import { decodeURILastSegment, isSafePathSegment, resolvePathWithinRoot } from '../../src/lib/safePath.js';
6+
7+
describe('#isSafePathSegment', () => {
8+
it.each([
9+
['main', true],
10+
['my-category', true],
11+
['intro', true],
12+
['', false],
13+
['.', false],
14+
['..', false],
15+
['../escape', false],
16+
['..\\escape', false],
17+
['foo/../bar', false],
18+
['foo/bar', false],
19+
['foo\\bar', false],
20+
])('isSafePathSegment(%j) -> %s', (segment, expected) => {
21+
expect(isSafePathSegment(segment)).toBe(expected);
22+
});
23+
});
24+
25+
describe('#decodeURILastSegment', () => {
26+
it('decodes a normal category segment', () => {
27+
expect(decodeURILastSegment('/branches/1.0/categories/guides/main')).toBe('main');
28+
});
29+
30+
it('rejects encoded path traversal', () => {
31+
expect(decodeURILastSegment('/branches/1.0/categories/guides/%2e%2e%2fescape')).toBeNull();
32+
});
33+
});
34+
35+
describe('#resolvePathWithinRoot', () => {
36+
const root = path.resolve('/tmp/export');
37+
38+
it('resolves paths inside the root', () => {
39+
expect(resolvePathWithinRoot(root, 'main', 'intro.md')).toBe(path.resolve(root, 'main', 'intro.md'));
40+
});
41+
42+
it('returns null when traversal escapes the root', () => {
43+
expect(resolvePathWithinRoot(root, '../outside', 'intro.md')).toBeNull();
44+
});
45+
46+
it('allows the root directory itself', () => {
47+
expect(resolvePathWithinRoot(root)).toBe(root);
48+
});
49+
});

0 commit comments

Comments
 (0)