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 add focused coverage for both cases.
This commit is contained in:
Haileyesus
2026-07-06 23:03:52 +03:00
parent 98e6cf2ba9
commit baf45b7282
2 changed files with 106 additions and 6 deletions

View File

@@ -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: {

View File

@@ -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<boolean> {
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);