From 9b14547000a8ac57ce77a9b11a455c68d3a74027 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Fri, 22 May 2026 11:04:48 +0530 Subject: [PATCH 1/3] fix: normalize backslashes in image URLs Windows-style paths (adminimgs\project_1.png) caused 404s because backslashes aren't valid in URLs. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/lib/remark-resolve-images.ts | 13 +++++++------ packages/chronicle/src/server/api/image.ts | 6 ++++-- packages/chronicle/src/server/utils/safe-path.ts | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/chronicle/src/lib/remark-resolve-images.ts b/packages/chronicle/src/lib/remark-resolve-images.ts index 25d6571c..50401af6 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 b0d517d3..0ba78120 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.ts b/packages/chronicle/src/server/utils/safe-path.ts index 4d3867d7..031b59d9 100644 --- a/packages/chronicle/src/server/utils/safe-path.ts +++ b/packages/chronicle/src/server/utils/safe-path.ts @@ -5,7 +5,7 @@ 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]); + const decoded = decodeURIComponent(urlPath.split('?')[0]).replace(/\\/g, '/'); const resolved = path.resolve(baseDir, '.' + decoded); if ( !resolved.startsWith(path.resolve(baseDir) + path.sep) && From ccd29bb39776bc2fa47eddce91b0fb6a6aec6dd2 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Fri, 22 May 2026 11:11:52 +0530 Subject: [PATCH 2/3] test: add backslash normalization tests for Windows path support Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chronicle/src/lib/image-utils.test.ts | 8 +++++ .../src/server/utils/safe-path.test.ts | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 packages/chronicle/src/server/utils/safe-path.test.ts diff --git a/packages/chronicle/src/lib/image-utils.test.ts b/packages/chronicle/src/lib/image-utils.test.ts index 5af4a7fc..aa3a1801 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/server/utils/safe-path.test.ts b/packages/chronicle/src/server/utils/safe-path.test.ts new file mode 100644 index 00000000..4c37d653 --- /dev/null +++ b/packages/chronicle/src/server/utils/safe-path.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'bun:test'; +import path from 'node:path'; +import { safePath } from './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')); + }); +}); From b829383bbea62213c50713337ad330a6068c5bc5 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Fri, 22 May 2026 11:42:11 +0530 Subject: [PATCH 3/3] fix: guard decodeURIComponent and use @ alias in test Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/server/utils/safe-path.test.ts | 6 +++++- packages/chronicle/src/server/utils/safe-path.ts | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/chronicle/src/server/utils/safe-path.test.ts b/packages/chronicle/src/server/utils/safe-path.test.ts index 4c37d653..bddff84e 100644 --- a/packages/chronicle/src/server/utils/safe-path.test.ts +++ b/packages/chronicle/src/server/utils/safe-path.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; import path from 'node:path'; -import { safePath } from './safe-path'; +import { safePath } from '@/server/utils/safe-path'; describe('safePath', () => { const base = '/app/content'; @@ -27,4 +27,8 @@ describe('safePath', () => { 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 031b59d9..adbd9571 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]).replace(/\\/g, '/'); + 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) &&