diff --git a/server/shared/image-attachments.ts b/server/shared/image-attachments.ts index 94cc85a6..752be34e 100644 --- a/server/shared/image-attachments.ts +++ b/server/shared/image-attachments.ts @@ -1,4 +1,4 @@ -import { promises as fs } from 'node:fs'; +import { promises as fs, realpathSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -99,6 +99,18 @@ function isPathInsideDirectory(candidate: string, directory: string): boolean { return path.resolve(candidate).startsWith(resolvedRoot); } +function getDirectoryPathVariants(directory: string): string[] { + const resolvedDirectory = path.resolve(directory); + try { + const canonicalDirectory = path.resolve(realpathSync(directory)); + return canonicalDirectory === resolvedDirectory + ? [resolvedDirectory] + : [resolvedDirectory, canonicalDirectory]; + } catch { + return [resolvedDirectory]; + } +} + /** * Second layer of the image trust boundary (the first is the chat.send filter * in the websocket gateway): provider builders only reference files that live @@ -107,9 +119,10 @@ function isPathInsideDirectory(candidate: string, directory: string): boolean { * refused, so a caller-supplied descriptor can never leak arbitrary files. */ export function isAllowedImageSourcePath(resolvedPath: string, cwd?: string): boolean { - return ( - isPathInsideDirectory(resolvedPath, getGlobalImageAssetsDir()) || - isPathInsideDirectory(resolvedPath, cwd || process.cwd()) + return [getGlobalImageAssetsDir(), cwd || process.cwd()].some((directory) => + getDirectoryPathVariants(directory).some((directoryVariant) => + isPathInsideDirectory(resolvedPath, directoryVariant) + ) ); } @@ -276,7 +289,13 @@ export async function buildClaudeUserContent( } try { - const bytes = await fs.readFile(resolvedPath); + const canonicalPath = await fs.realpath(resolvedPath); + if (!isAllowedImageSourcePath(canonicalPath, cwd)) { + console.warn(`[Images] Refusing to read symlinked image outside allowed roots: ${descriptor.path}`); + continue; + } + + const bytes = await fs.readFile(canonicalPath); blocks.push({ type: 'image', source: { diff --git a/server/shared/tests/image-attachments.test.ts b/server/shared/tests/image-attachments.test.ts index c4f4abbd..0ddba881 100644 --- a/server/shared/tests/image-attachments.test.ts +++ b/server/shared/tests/image-attachments.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -21,6 +21,32 @@ const PNG_BYTES = Buffer.from( 'base64', ); +const SYMLINK_UNSUPPORTED_CODES = new Set(['EACCES', 'EINVAL', 'ENOSYS', 'ENOTSUP', 'EPERM']); + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error; +} + +async function createSymlinkIfSupported( + target: string, + linkPath: string, + type: 'dir' | 'file' | 'junction', +): Promise { + try { + await symlink(target, linkPath, type); + return true; + } catch (error) { + if ( + isErrnoException(error) && + typeof error.code === 'string' && + SYMLINK_UNSUPPORTED_CODES.has(error.code) + ) { + return false; + } + throw error; + } +} + test('normalizeImageDescriptors accepts objects and bare paths, drops junk', () => { const descriptors = normalizeImageDescriptors([ { path: '.cloudcli/assets/a.png', name: 'a.png', mimeType: 'image/png' }, @@ -176,6 +202,61 @@ test('buildClaudeUserContent skips unsupported types and unreadable files', asyn } }); +test('buildClaudeUserContent refuses symlinked images outside allowed roots', async (t) => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-')); + const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-outside-')); + try { + const outsideFile = path.join(outsideDir, 'secret.png'); + await writeFile(outsideFile, PNG_BYTES); + + const linkPath = path.join(tempDir, 'linked-secret.png'); + if (!(await createSymlinkIfSupported(outsideFile, linkPath, 'file'))) { + t.skip('Symlink creation is not supported in this environment'); + return; + } + + const content = await buildClaudeUserContent( + 'prompt', + [{ path: 'linked-secret.png', mimeType: 'image/png' }], + tempDir, + ); + + assert.deepEqual(content, [{ type: 'text', text: 'prompt' }]); + } finally { + await rm(tempDir, { recursive: true, force: true }); + await rm(outsideDir, { recursive: true, force: true }); + } +}); + +test('buildClaudeUserContent accepts images under a symlinked cwd', async (t) => { + const realProjectDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-project-')); + const linkParentDir = await mkdtemp(path.join(os.tmpdir(), 'image-attachments-link-')); + try { + await writeFile(path.join(realProjectDir, 'shot.png'), PNG_BYTES); + + const linkCwd = path.join(linkParentDir, 'project-link'); + const linkType = process.platform === 'win32' ? 'junction' : 'dir'; + if (!(await createSymlinkIfSupported(realProjectDir, linkCwd, linkType))) { + t.skip('Symlink creation is not supported in this environment'); + return; + } + + const content = await buildClaudeUserContent( + 'prompt', + [{ path: 'shot.png', mimeType: 'image/png' }], + linkCwd, + ); + + assert.equal(content.length, 2); + assert.equal(content[1].type, 'image'); + const imageBlock = content[1] as Extract<(typeof content)[number], { type: 'image' }>; + assert.equal(imageBlock.source.data, PNG_BYTES.toString('base64')); + } finally { + await rm(linkParentDir, { recursive: true, force: true }); + await rm(realProjectDir, { recursive: true, force: true }); + } +}); + test('buildCodexInputItems emits text plus absolute local_image paths', () => { const cwd = path.join(os.tmpdir(), 'codex-project'); const items = buildCodexInputItems('Describe this image:', [{ path: '.cloudcli/assets/pic.jpg' }], cwd);