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, '/');
|
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 {
|
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)) {
|
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 {
|
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*$/;
|
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[] {
|
function parseNumberedImageEntries(inner: string): ParsedImageAttachment[] {
|
||||||
const attachments: ParsedImageAttachment[] = [];
|
const attachments: ParsedImageAttachment[] = [];
|
||||||
for (const entryMatch of inner.matchAll(IMAGES_INPUT_ENTRY_PATTERN)) {
|
for (const entryMatch of inner.matchAll(IMAGES_INPUT_ENTRY_PATTERN)) {
|
||||||
@@ -282,7 +310,11 @@ export async function buildClaudeUserContent(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedPath = resolveImageAbsolutePath(cwd, descriptor.path);
|
const resolvedPath = tryResolveImageAbsolutePath(cwd, descriptor);
|
||||||
|
if (!resolvedPath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAllowedImageSourcePath(resolvedPath, cwd)) {
|
if (!isAllowedImageSourcePath(resolvedPath, cwd)) {
|
||||||
console.warn(`[Images] Refusing to read image outside allowed roots: ${descriptor.path}`);
|
console.warn(`[Images] Refusing to read image outside allowed roots: ${descriptor.path}`);
|
||||||
continue;
|
continue;
|
||||||
@@ -325,7 +357,11 @@ type CodexInputItem =
|
|||||||
export function buildCodexInputItems(prompt: string, images: unknown, cwd?: string): CodexInputItem[] {
|
export function buildCodexInputItems(prompt: string, images: unknown, cwd?: string): CodexInputItem[] {
|
||||||
const items: CodexInputItem[] = [{ type: 'text', text: prompt }];
|
const items: CodexInputItem[] = [{ type: 'text', text: prompt }];
|
||||||
for (const descriptor of normalizeImageDescriptors(images)) {
|
for (const descriptor of normalizeImageDescriptors(images)) {
|
||||||
const resolvedPath = resolveImageAbsolutePath(cwd, descriptor.path);
|
const resolvedPath = tryResolveImageAbsolutePath(cwd, descriptor);
|
||||||
|
if (!resolvedPath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAllowedImageSourcePath(resolvedPath, cwd)) {
|
if (!isAllowedImageSourcePath(resolvedPath, cwd)) {
|
||||||
// Same trust boundary as buildClaudeUserContent — the Codex runtime
|
// Same trust boundary as buildClaudeUserContent — the Codex runtime
|
||||||
// reads this file, so it must stay within the allowed roots.
|
// reads this file, so it must stay within the allowed roots.
|
||||||
|
|||||||
Reference in New Issue
Block a user