fix(security): canonicalize Claude image attachment reads

Resolve image attachment paths with realpath before reading so symlinks inside allowed roots cannot escape to arbitrary files. Preserve support for symlinked project/upload roots and skip invalid descriptors instead of failing the provider turn.
This commit is contained in:
Verify User
2026-07-06 23:24:17 +03:00
parent baf45b7282
commit 17b8a9a561

View File

@@ -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.