diff --git a/packages/chronicle/src/lib/image-utils.test.ts b/packages/chronicle/src/lib/image-utils.test.ts index 5af4a7f..aa3a180 100644 --- a/packages/chronicle/src/lib/image-utils.test.ts +++ b/packages/chronicle/src/lib/image-utils.test.ts @@ -53,6 +53,14 @@ describe('buildOptimizedUrl', () => { }); }); +describe('buildOptimizedUrl with backslashes', () => { + test('backslashes in input are not double-encoded', () => { + const url = buildOptimizedUrl('/_content/docs/imgs\\screenshot.png', 640); + expect(url).toContain('imgs%5Cscreenshot.png'); + expect(url).not.toContain('%255C'); + }); +}); + describe('constants', () => { test('ALLOWED_WIDTHS is sorted ascending', () => { for (let i = 1; i < ALLOWED_WIDTHS.length; i++) { diff --git a/packages/chronicle/src/lib/remark-resolve-images.ts b/packages/chronicle/src/lib/remark-resolve-images.ts index 25d6571..50401af 100644 --- a/packages/chronicle/src/lib/remark-resolve-images.ts +++ b/packages/chronicle/src/lib/remark-resolve-images.ts @@ -8,13 +8,14 @@ import { MdxNodeType } from './mdx-utils' import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from './image-utils' function resolveUrl(src: string, dir: string): string { - if (/^[a-z][a-z0-9+\-.]*:/i.test(src)) return src - if (src.startsWith('//')) return src - if (src.startsWith('#')) return src - if (src.startsWith('/_content/')) return src + const normalized = src.replace(/\\/g, '/') + if (/^[a-z][a-z0-9+\-.]*:/i.test(normalized)) return normalized + if (normalized.startsWith('//')) return normalized + if (normalized.startsWith('#')) return normalized + if (normalized.startsWith('/_content/')) return normalized - if (src.startsWith('/')) return `/_content${src}` - return `/_content/${path.posix.normalize(path.posix.join(dir, src))}` + if (normalized.startsWith('/')) return `/_content${normalized}` + return `/_content/${path.posix.normalize(path.posix.join(dir, normalized))}` } interface RemarkResolveImagesOptions { diff --git a/packages/chronicle/src/server/api/image.ts b/packages/chronicle/src/server/api/image.ts index b0d517d..0ba7812 100644 --- a/packages/chronicle/src/server/api/image.ts +++ b/packages/chronicle/src/server/api/image.ts @@ -52,14 +52,16 @@ async function evictIfNeeded(storage: ReturnType) { export default defineHandler(async event => { const storage = useStorage(STORAGE_KEY) - const url = event.url.searchParams.get('url') + const rawUrl = event.url.searchParams.get('url') const wParam = event.url.searchParams.get('w') const qParam = event.url.searchParams.get('q') - if (!url || !wParam) { + if (!rawUrl || !wParam) { throw new HTTPError({ status: StatusCodes.BAD_REQUEST, message: 'Missing url or w parameter' }) } + const url = rawUrl.replace(/\\/g, '/') + if (!url.startsWith('/_content/')) { throw new HTTPError({ status: StatusCodes.BAD_REQUEST, message: 'Only local content images allowed' }) } diff --git a/packages/chronicle/src/server/utils/safe-path.test.ts b/packages/chronicle/src/server/utils/safe-path.test.ts new file mode 100644 index 0000000..bddff84 --- /dev/null +++ b/packages/chronicle/src/server/utils/safe-path.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test'; +import path from 'node:path'; +import { safePath } from '@/server/utils/safe-path'; + +describe('safePath', () => { + const base = '/app/content'; + + test('resolves valid path within base', () => { + expect(safePath(base, '/docs/intro.mdx')).toBe(path.resolve(base, 'docs/intro.mdx')); + }); + + test('returns null for path traversal', () => { + expect(safePath(base, '/../etc/passwd')).toBeNull(); + }); + + test('normalizes backslashes to forward slashes', () => { + const result = safePath(base, '/docs\\imgs\\screenshot.png'); + expect(result).toBe(path.resolve(base, 'docs/imgs/screenshot.png')); + }); + + test('decodes URI-encoded characters', () => { + const result = safePath(base, '/docs/my%20image.png'); + expect(result).toBe(path.resolve(base, 'docs/my image.png')); + }); + + test('strips query string before resolving', () => { + const result = safePath(base, '/docs/img.png?v=1'); + expect(result).toBe(path.resolve(base, 'docs/img.png')); + }); + + test('returns null for malformed percent-encoding', () => { + expect(safePath(base, '/docs/%E0%A4%')).toBeNull(); + }); +}); diff --git a/packages/chronicle/src/server/utils/safe-path.ts b/packages/chronicle/src/server/utils/safe-path.ts index 4d3867d..adbd957 100644 --- a/packages/chronicle/src/server/utils/safe-path.ts +++ b/packages/chronicle/src/server/utils/safe-path.ts @@ -5,7 +5,12 @@ import path from 'node:path'; * Returns null if the resolved path escapes the base directory. */ export function safePath(baseDir: string, urlPath: string): string | null { - const decoded = decodeURIComponent(urlPath.split('?')[0]); + let decoded: string; + try { + decoded = decodeURIComponent(urlPath.split('?')[0]).replace(/\\/g, '/'); + } catch { + return null; + } const resolved = path.resolve(baseDir, '.' + decoded); if ( !resolved.startsWith(path.resolve(baseDir) + path.sep) &&