diff --git a/server/shared/image-attachments.ts b/server/shared/image-attachments.ts index 752be34e..7a160c33 100644 --- a/server/shared/image-attachments.ts +++ b/server/shared/image-attachments.ts @@ -82,12 +82,30 @@ export function toPosixPath(value: string): string { return value.replace(/\\/g, '/'); } -/** Resolves a project-relative image path against the run's working directory. */ +/** Resolves an allowed image path against the run's working directory. */ export function resolveImageAbsolutePath(cwd: string | undefined, imagePath: string): string { + const baseDir = path.resolve(cwd || process.cwd()); + const globalAssetsDir = path.resolve(getGlobalImageAssetsDir()); + if (path.isAbsolute(imagePath)) { - return imagePath; + const absoluteInput = path.resolve(imagePath); + if (isPathInsideDirectory(absoluteInput, globalAssetsDir)) { + return absoluteInput; + } } - return path.resolve(cwd || process.cwd(), imagePath); + + const resolved = path.resolve(baseDir, imagePath); + const relative = path.relative(baseDir, resolved); + const escapesBase = + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative); + + if (escapesBase) { + throw new Error(`Image path escapes working directory: ${imagePath}`); + } + + return resolved; } function isPathInsideDirectory(candidate: string, directory: string): boolean { @@ -196,6 +214,16 @@ const IMAGES_INPUT_ENTRY_PATTERN = /\d+\.\s+(.+?)(?=\s+\d+\.\s+|\s*$)/g; const ORIGINAL_NAME_SUFFIX_PATTERN = /\(original name: ([^)]*)\)\s*$/; +function tryResolveImageAbsolutePath(cwd: string | undefined, descriptor: ImageAttachmentDescriptor): string | null { + try { + return resolveImageAbsolutePath(cwd, descriptor.path); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[Images] Refusing image outside allowed roots: ${message}`); + return null; + } +} + function parseNumberedImageEntries(inner: string): ParsedImageAttachment[] { const attachments: ParsedImageAttachment[] = []; for (const entryMatch of inner.matchAll(IMAGES_INPUT_ENTRY_PATTERN)) { @@ -282,7 +310,11 @@ export async function buildClaudeUserContent( continue; } - const resolvedPath = resolveImageAbsolutePath(cwd, descriptor.path); + const resolvedPath = tryResolveImageAbsolutePath(cwd, descriptor); + if (!resolvedPath) { + continue; + } + if (!isAllowedImageSourcePath(resolvedPath, cwd)) { console.warn(`[Images] Refusing to read image outside allowed roots: ${descriptor.path}`); continue; @@ -325,7 +357,11 @@ type CodexInputItem = export function buildCodexInputItems(prompt: string, images: unknown, cwd?: string): CodexInputItem[] { const items: CodexInputItem[] = [{ type: 'text', text: prompt }]; for (const descriptor of normalizeImageDescriptors(images)) { - const resolvedPath = resolveImageAbsolutePath(cwd, descriptor.path); + const resolvedPath = tryResolveImageAbsolutePath(cwd, descriptor); + if (!resolvedPath) { + continue; + } + if (!isAllowedImageSourcePath(resolvedPath, cwd)) { // Same trust boundary as buildClaudeUserContent — the Codex runtime // reads this file, so it must stay within the allowed roots.