mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-07 06:02:43 +08:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user