Skip to content
Closed
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
138 changes: 137 additions & 1 deletion packages/server/src/annotation.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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('<html><body>ui</body></html>'),
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('<html><body>ui</body></html>'),
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('<html><body>ui</body></html>'),
payload,
config,
});
const res = await app.fetch(new Request('http://localhost/nonexistent/image.png'));
expect(res.status).toBe(404);
});
});
36 changes: 36 additions & 0 deletions packages/server/src/annotation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { extname, normalize } from 'node:path';
import {
type AnnotationPayload,
type AnnotationSubmission,
Expand Down Expand Up @@ -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 });
},
};
Expand All @@ -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<Response | null> {
// 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);
}
Loading