mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-07 14:12:40 +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 add focused coverage for both cases.
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user