diff --git a/packages/server/src/annotation.test.ts b/packages/server/src/annotation.test.ts index d32914f..7014ff9 100644 --- a/packages/server/src/annotation.test.ts +++ b/packages/server/src/annotation.test.ts @@ -1,10 +1,13 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { fakeBaseContext } from '@contextbridge/context/testHelpers'; import type { AnnotationPayload } from '@contextbridge/shared/annotationSchema'; import type { FrontendConfig } from '@contextbridge/shared/frontendConfigSchema'; import { annotationThread, globalThread } from '@contextbridge/shared/testFactories'; import type { UpdateNotice } from '@contextbridge/shared/updateNoticeSchema'; import { describe, expect, it } from 'bun:test'; -import { createAnnotationServerApp, resolveListenPort, startServer } from './annotation.ts'; +import { createAnnotationServerApp, resolveListenPort, serveLocalImage, startServer } from './annotation.ts'; describe('createAnnotationServerApp', () => { const payload: AnnotationPayload = { @@ -235,3 +238,136 @@ describe('startServer', () => { } }); }); + +describe('serveLocalImage', () => { + it('returns null for non-absolute paths', async () => { + expect(await serveLocalImage('relative/path.png')).toBeNull(); + }); + + it('returns null for paths without an image extension', async () => { + expect(await serveLocalImage('/some/path/file.txt')).toBeNull(); + expect(await serveLocalImage('/some/path/file.js')).toBeNull(); + expect(await serveLocalImage('/some/path/file')).toBeNull(); + }); + + it('returns null for image paths that do not exist on disk', async () => { + expect(await serveLocalImage('/nonexistent/path/image.png')).toBeNull(); + }); + + it('serves an existing image file from disk', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'cb-server-img-test-')); + const imgPath = join(tmp, 'test-image.png'); + const imgContent = Buffer.from([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes + writeFileSync(imgPath, imgContent); + + try { + const response = await serveLocalImage(imgPath); + expect(response).not.toBeNull(); + const body = await response!.arrayBuffer(); + expect(Buffer.from(body)).toEqual(imgContent); + } finally { + rmSync(tmp, { recursive: true }); + } + }); + + it('handles percent-encoded paths', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'cb-server-img-test-')); + const imgPath = join(tmp, 'my image.png'); + const imgContent = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + writeFileSync(imgPath, imgContent); + + try { + const encodedPath = imgPath.replace(/ /g, '%20'); + const response = await serveLocalImage(encodedPath); + expect(response).not.toBeNull(); + const body = await response!.arrayBuffer(); + expect(Buffer.from(body)).toEqual(imgContent); + } finally { + rmSync(tmp, { recursive: true }); + } + }); + + it('supports various image extensions', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'cb-server-img-test-')); + const extensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp']; + + try { + for (const ext of extensions) { + const imgPath = join(tmp, `image${ext}`); + writeFileSync(imgPath, 'img-data'); + const response = await serveLocalImage(imgPath); + expect(response).not.toBeNull(); + } + } finally { + rmSync(tmp, { recursive: true }); + } + }); + + it('rejects paths with traversal segments that escape root', async () => { + // Even though the traversal resolves to a valid path after normalize, + // it should still have a / prefix and be handled normally + const tmp = mkdtempSync(join(tmpdir(), 'cb-server-img-test-')); + const imgPath = join(tmp, 'test.png'); + writeFileSync(imgPath, 'data'); + + try { + // A traversal that still resolves to an absolute path is fine + const traversalPath = join(tmp, 'sub', '..', 'test.png'); + const response = await serveLocalImage(traversalPath); + expect(response).not.toBeNull(); + } finally { + rmSync(tmp, { recursive: true }); + } + }); +}); + +describe('createAnnotationServerApp local image serving', () => { + const payload: AnnotationPayload = { + content: '# plan', + contentKind: 'plan', + metadata: { entrypoint: 'plan_command' }, + }; + const config: FrontendConfig = { distinctId: 'test-distinct-id', telemetryDisabled: false }; + const ctx = fakeBaseContext(); + + it('serves a local image file via the annotation server', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'cb-server-img-route-')); + const imgPath = join(tmp, 'generated.png'); + const imgContent = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + writeFileSync(imgPath, imgContent); + + try { + const app = createAnnotationServerApp(ctx, { + html: Promise.resolve('ui'), + payload, + config, + }); + const res = await app.fetch(new Request(`http://localhost${imgPath}`)); + expect(res.status).toBe(200); + const body = await res.arrayBuffer(); + expect(Buffer.from(body)).toEqual(imgContent); + } finally { + rmSync(tmp, { recursive: true }); + } + }); + + it('returns 404 for non-image file paths', async () => { + const app = createAnnotationServerApp(ctx, { + html: Promise.resolve('ui'), + payload, + config, + }); + const res = await app.fetch(new Request('http://localhost/etc/passwd')); + expect(res.status).toBe(404); + }); + + it('returns 404 for nonexistent image paths', async () => { + const app = createAnnotationServerApp(ctx, { + html: Promise.resolve('ui'), + payload, + config, + }); + const res = await app.fetch(new Request('http://localhost/nonexistent/image.png')); + expect(res.status).toBe(404); + }); +}); diff --git a/packages/server/src/annotation.ts b/packages/server/src/annotation.ts index b692738..7b2c625 100644 --- a/packages/server/src/annotation.ts +++ b/packages/server/src/annotation.ts @@ -1,3 +1,4 @@ +import { extname, normalize } from 'node:path'; import { type AnnotationPayload, type AnnotationSubmission, @@ -104,6 +105,15 @@ export function createAnnotationServerApp(ctx: ServerContext, opts: StartServerO setTimeout(() => resolveResult(parsed.data), 0); return new Response(null, { status: 204, headers: { connection: 'close' } }); } + // Serve local image files referenced by absolute paths in plan content. + // When a plan contains markdown images like ![alt](/absolute/path/to/image.png), + // the browser resolves them relative to the server origin. This route + // serves those files directly from disk so images render correctly. + if (req.method === 'GET') { + const localFileResponse = await serveLocalImage(url.pathname); + if (localFileResponse) return localFileResponse; + } + return new Response('not found', { status: 404 }); }, }; @@ -125,3 +135,29 @@ async function resolveUpdateNotice(checkForUpdate: CheckForUpdate | undefined): return null; } } + +const ALLOWED_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.avif', '.ico', '.bmp']); + +/** + * Serves a local image file if the pathname resolves to an existing image on + * disk. Returns `null` if the path is not an absolute file path, does not have + * an image extension, or does not exist on disk. + */ +export async function serveLocalImage(pathname: string): Promise { + // Only handle paths that look like absolute file paths + if (!pathname.startsWith('/')) return null; + + const ext = extname(pathname).toLowerCase(); + if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) return null; + + // Normalize to prevent path traversal via .. segments + const filePath = normalize(decodeURIComponent(pathname)); + + // Must still be an absolute path after normalization (no escaping root) + if (!filePath.startsWith('/')) return null; + + const file = Bun.file(filePath); + if (!(await file.exists())) return null; + + return new Response(file); +}