Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/chronicle/src/lib/image-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand Down
13 changes: 7 additions & 6 deletions packages/chronicle/src/lib/remark-resolve-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions packages/chronicle/src/server/api/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,16 @@ async function evictIfNeeded(storage: ReturnType<typeof useStorage>) {
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' })
}
Expand Down
34 changes: 34 additions & 0 deletions packages/chronicle/src/server/utils/safe-path.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
7 changes: 6 additions & 1 deletion packages/chronicle/src/server/utils/safe-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) &&
Expand Down
Loading