mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 20:15:40 +00:00
fix(security): constrain image fallback hook to workspace (#1232)
This commit is contained in:
1 parent
349db830df
commit
40300c286e
3 files changed
+106
-5
No files matched your search
@@ -379,6 +379,47 @@ function outputSuccess(filePath, description, model, fileSize) {
|
|||||||
/**
|
/**
|
||||||
* Determine if hook should skip, with debug logging
|
* Determine if hook should skip, with debug logging
|
||||||
*/
|
*/
|
||||||
|
function normalizePathForComparison(value) {
|
||||||
|
return process.platform === 'win32' ? value.toLowerCase() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathWithinWorkspace(workspaceRoot, candidatePath) {
|
||||||
|
const relativePath = path.relative(workspaceRoot, candidatePath);
|
||||||
|
return (
|
||||||
|
relativePath === '' ||
|
||||||
|
(!relativePath.startsWith('..') &&
|
||||||
|
!relativePath.startsWith(`..${path.sep}`) &&
|
||||||
|
!path.isAbsolute(relativePath))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorkspaceFilePath(filePath) {
|
||||||
|
const workspaceRoot = (() => {
|
||||||
|
try {
|
||||||
|
return fs.realpathSync(process.cwd());
|
||||||
|
} catch {
|
||||||
|
return path.resolve(process.cwd());
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const absolutePath = path.resolve(process.cwd(), filePath);
|
||||||
|
const realFilePath = fs.realpathSync(absolutePath);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isPathWithinWorkspace(
|
||||||
|
normalizePathForComparison(workspaceRoot),
|
||||||
|
normalizePathForComparison(realFilePath)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
debugLog('Skipping: file is outside the current workspace', {
|
||||||
|
workspaceRoot,
|
||||||
|
filePath: realFilePath,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return realFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
function shouldSkipHook() {
|
function shouldSkipHook() {
|
||||||
if (process.env.CCS_IMAGE_ANALYSIS_SKIP_HOOK === '1') {
|
if (process.env.CCS_IMAGE_ANALYSIS_SKIP_HOOK === '1') {
|
||||||
debugLog('Skipping: CCS_IMAGE_ANALYSIS_SKIP_HOOK=1');
|
debugLog('Skipping: CCS_IMAGE_ANALYSIS_SKIP_HOOK=1');
|
||||||
@@ -484,11 +525,16 @@ async function processHook() {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const debugContext = getDebugContext(filePath, null);
|
const workspaceFilePath = resolveWorkspaceFilePath(filePath);
|
||||||
|
if (!workspaceFilePath) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const debugContext = getDebugContext(workspaceFilePath, null);
|
||||||
debugLog('Image analysis runtime prepared', debugContext);
|
debugLog('Image analysis runtime prepared', debugContext);
|
||||||
|
|
||||||
const result = await analyzeFile(filePath);
|
const result = await analyzeFile(workspaceFilePath);
|
||||||
outputSuccess(filePath, result.description, result.model, result.fileSize);
|
outputSuccess(workspaceFilePath, result.description, result.model, result.fileSize);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (process.env.CCS_DEBUG) {
|
if (process.env.CCS_DEBUG) {
|
||||||
console.error('[CCS Hook] Error:', err.message);
|
console.error('[CCS Hook] Error:', err.message);
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ function invokeHook(
|
|||||||
input: JSON.stringify(input),
|
input: JSON.stringify(input),
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
env: buildHookEnv(env),
|
env: buildHookEnv(env),
|
||||||
|
cwd: TEST_DIR,
|
||||||
timeout: 10000, // 10 second timeout per test
|
timeout: 10000, // 10 second timeout per test
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,6 +188,7 @@ function invokeHookAsync(
|
|||||||
const child = spawn('node', [HOOK_PATH], {
|
const child = spawn('node', [HOOK_PATH], {
|
||||||
env: buildHookEnv(env),
|
env: buildHookEnv(env),
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
cwd: TEST_DIR,
|
||||||
});
|
});
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
@@ -531,6 +533,7 @@ describe('Image Analyzer Hook', () => {
|
|||||||
input: 'not valid json',
|
input: 'not valid json',
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
|
cwd: TEST_DIR,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
|
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ function enqueueResponses(...responses: MockResponse[]): void {
|
|||||||
queuedResponses = responses;
|
queuedResponses = responses;
|
||||||
}
|
}
|
||||||
|
|
||||||
function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
|
function invokeHook(
|
||||||
|
env: Record<string, string> = {},
|
||||||
|
options: { filePath?: string; cwd?: string } = {}
|
||||||
|
): Promise<HookResult> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn('node', [HOOK_PATH], {
|
const child = spawn('node', [HOOK_PATH], {
|
||||||
env: {
|
env: {
|
||||||
@@ -68,6 +71,7 @@ function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
|
|||||||
...env,
|
...env,
|
||||||
},
|
},
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
cwd: options.cwd ?? TEST_DIR,
|
||||||
});
|
});
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
@@ -98,7 +102,7 @@ function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
|
|||||||
child.stdin.end(
|
child.stdin.end(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
tool_name: 'Read',
|
tool_name: 'Read',
|
||||||
tool_input: { file_path: TEST_PNG_PATH },
|
tool_input: { file_path: options.filePath ?? TEST_PNG_PATH },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -198,6 +202,54 @@ describe('image analyzer hook regression coverage', () => {
|
|||||||
expect((requests[0].body as { model: string }).model).toBe('gpt-5.1-codex-mini');
|
expect((requests[0].body as { model: string }).model).toBe('gpt-5.1-codex-mini');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not analyze files outside the current workspace', async () => {
|
||||||
|
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-hook-outside-'));
|
||||||
|
const outsidePath = path.join(outsideDir, 'sensitive-outside-workspace.png');
|
||||||
|
createTestPng(outsidePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await invokeHook({}, { filePath: outsidePath });
|
||||||
|
|
||||||
|
expect(result.code).toBe(0);
|
||||||
|
expect(requests).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.if(process.platform === 'win32')(
|
||||||
|
'analyzes Windows-style relative paths inside the current workspace',
|
||||||
|
async () => {
|
||||||
|
const nestedDir = path.join(TEST_DIR, 'windows-style-fixture-dir');
|
||||||
|
const nestedPath = path.join(nestedDir, 'inside-workspace.png');
|
||||||
|
fs.mkdirSync(nestedDir, { recursive: true });
|
||||||
|
createTestPng(nestedPath);
|
||||||
|
|
||||||
|
const result = await invokeHook({}, { filePath: path.win32.relative(TEST_DIR, nestedPath) });
|
||||||
|
|
||||||
|
expect(result.code).toBe(2);
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('does not analyze workspace symlinks that resolve outside the current workspace', async () => {
|
||||||
|
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-hook-symlink-'));
|
||||||
|
const outsidePath = path.join(outsideDir, 'sensitive-symlink-target.png');
|
||||||
|
const linkPath = path.join(TEST_DIR, 'linked-sensitive.png');
|
||||||
|
createTestPng(outsidePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.symlinkSync(outsidePath, linkPath);
|
||||||
|
const result = await invokeHook({}, { filePath: linkPath });
|
||||||
|
|
||||||
|
expect(result.code).toBe(0);
|
||||||
|
expect(requests).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(linkPath, { force: true });
|
||||||
|
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('skips analysis before contacting CLIProxy when the current provider has no mapped vision model', async () => {
|
it('skips analysis before contacting CLIProxy when the current provider has no mapped vision model', async () => {
|
||||||
const result = await invokeHook({
|
const result = await invokeHook({
|
||||||
CCS_CURRENT_PROVIDER: 'unknown-provider',
|
CCS_CURRENT_PROVIDER: 'unknown-provider',
|
||||||
|
|||||||
Reference in new issue
Block a user