fix: harden local data boundaries

Bind the capabilities dashboard exclusively to loopback and reject untrusted Host and Origin values. Constrain project-configured agent data paths to the Cursor data root, and harden lifecycle repair/uninstall operations against state-file traversal, symlink swaps, unsafe sources, and forged install-state destinations.\n\nCloses #2506
This commit is contained in:
Affaan Mustafa
2026-07-26 04:51:12 -04:00
parent ad8db87780
commit a8295c6bf7
10 changed files with 1745 additions and 187 deletions
+109 -14
View File
@@ -12,6 +12,26 @@
const fs = require('fs');
const path = require('path');
const http = require('http');
const {
LOOPBACK_HOSTNAMES,
buildAllowedHostnames,
isAllowedHostHeader,
isAllowedOrigin,
} = require('./lib/loopback-guard');
const DEFAULT_HOST = '127.0.0.1';
function resolveDashboardHost(env = process.env) {
const configured = String(env.ECC_DASHBOARD_HOST || '').trim().toLowerCase();
if (!configured) return DEFAULT_HOST;
if (!LOOPBACK_HOSTNAMES.has(configured)) {
throw new Error(
'[ECC] ECC_DASHBOARD_HOST must be loopback-only ' +
'(127.0.0.1, localhost, or ::1).'
);
}
return configured === '[::1]' ? '::1' : configured;
}
function parsePort(v) {
const n = parseInt(String(v), 10);
@@ -19,6 +39,7 @@ function parsePort(v) {
return n;
}
const PORT = parsePort(process.argv[2] || process.env.ECC_DASHBOARD_PORT || '3456');
const HOST = resolveDashboardHost();
const ROOT = path.resolve(__dirname, '..');
function readFrontmatter(p) {
@@ -786,21 +807,95 @@ handleRoute();
/* eslint-enable no-useless-escape */
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
if (url.pathname === '/api/data') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ agents: loadAgents(), skills: loadSkills(), commands: loadCommands(), rules: loadRules(), mcps: loadMcps(), hooks: loadHooks() }));
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(renderHTML({ agents: loadAgents(), skills: loadSkills(), commands: loadCommands(), rules: loadRules(), mcps: loadMcps(), hooks: loadHooks() }));
});
function sendJson(res, statusCode, payload) {
res.writeHead(statusCode, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify(payload));
}
if (require.main === module) {
server.listen(PORT, () => {
console.log(`\n ECC Capabilities → http://localhost:${PORT}\n`);
try { const { spawn } = require('child_process'); const p = process.platform; const c = p === 'darwin' ? 'open' : p === 'win32' ? 'start' : 'xdg-open'; if (c === 'start') spawn('cmd', ['/c', 'start', `http://localhost:${PORT}`], { stdio: 'ignore' }); else spawn(c, [`http://localhost:${PORT}`], { stdio: 'ignore' }); } catch { /* best-effort auto-open */ }
function sendHtml(res, statusCode, html) {
res.writeHead(statusCode, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
});
res.end(html);
}
function loadDashboardData(root) {
return {
agents: loadAgents(root),
skills: loadSkills(root),
commands: loadCommands(root),
rules: loadRules(root),
mcps: loadMcps(root),
hooks: loadHooks(root),
};
}
function createDashboardServer({ root = ROOT, host = HOST } = {}) {
const resolvedHost = resolveDashboardHost({ ECC_DASHBOARD_HOST: host });
const allowedHostnames = buildAllowedHostnames(resolvedHost);
return http.createServer((req, res) => {
if (!isAllowedHostHeader(req.headers.host, allowedHostnames)) {
return sendJson(res, 421, { error: 'Misdirected request' });
}
if (!isAllowedOrigin(req.headers.origin, allowedHostnames)) {
return sendJson(res, 403, { error: 'Forbidden origin' });
}
let url;
try {
url = new URL(req.url, `http://${DEFAULT_HOST}`);
} catch {
return sendJson(res, 400, { error: 'Bad request' });
}
if (url.pathname === '/api/data') {
return sendJson(res, 200, loadDashboardData(root));
}
return sendHtml(res, 200, renderHTML(loadDashboardData(root)));
});
}
module.exports = { parsePort, readFrontmatter, readSkill, loadAgents, loadSkills, loadCommands, loadRules, loadMcps, loadHooks, renderHTML, LANG, LANG_KEYS, server };
function listenDashboardServer(
dashboardServer,
{ port = PORT, host = HOST, onListening } = {}
) {
const resolvedHost = resolveDashboardHost({ ECC_DASHBOARD_HOST: host });
return dashboardServer.listen(port, resolvedHost, onListening);
}
const server = createDashboardServer();
if (require.main === module) {
listenDashboardServer(server, { port: PORT, host: HOST, onListening: () => {
const displayHost = HOST.includes(':') ? `[${HOST}]` : HOST;
const dashboardUrl = `http://${displayHost}:${PORT}`;
console.log(`\n ECC Capabilities → ${dashboardUrl}\n`);
try { const { spawn } = require('child_process'); const p = process.platform; const c = p === 'darwin' ? 'open' : p === 'win32' ? 'start' : 'xdg-open'; if (c === 'start') spawn('cmd', ['/c', 'start', dashboardUrl], { stdio: 'ignore' }); else spawn(c, [dashboardUrl], { stdio: 'ignore' }); } catch { /* best-effort auto-open */ }
} });
}
module.exports = {
DEFAULT_HOST,
HOST,
LANG,
LANG_KEYS,
createDashboardServer,
listenDashboardServer,
loadAgents,
loadCommands,
loadHooks,
loadMcps,
loadRules,
loadSkills,
parsePort,
readFrontmatter,
readSkill,
renderHTML,
resolveDashboardHost,
server,
};
+31 -1
View File
@@ -14,6 +14,7 @@
const fs = require('fs');
const path = require('path');
const { assertWithinTrustedRoot } = require('./path-safety');
const AGENT_DATA_HOME_ENV = 'ECC_AGENT_DATA_HOME';
const DEFAULT_CLAUDE_DIR_NAME = '.claude';
@@ -94,6 +95,21 @@ function getDefaultClaudeAgentDataHome() {
return path.join(getHomeDirFromEnv(), DEFAULT_CLAUDE_DIR_NAME);
}
function warnUnsafeProjectConfig() {
console.error(
'[ECC] Ignoring unsafe agent data project config: agentDataHome must stay ' +
'within the default Cursor data directory. Use ECC_AGENT_DATA_HOME for an ' +
'explicit trusted override.'
);
}
function isSafeProjectConfigSyntax(candidate) {
const trimmed = candidate.trim();
const isUserAnchored = trimmed.startsWith('~') || path.isAbsolute(trimmed);
const hasParentTraversal = trimmed.split(/[/\\]+/).includes('..');
return isUserAnchored && !hasParentTraversal;
}
function readProjectConfigAt(configPath) {
if (!configPath || typeof configPath !== 'string') return null;
if (!fs.existsSync(configPath)) return null;
@@ -103,8 +119,22 @@ function readProjectConfigAt(configPath) {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const candidate = parsed.agentDataHome || parsed.ECC_AGENT_DATA_HOME;
if (typeof candidate !== 'string' || !candidate.trim()) return null;
if (!isSafeProjectConfigSyntax(candidate)) {
warnUnsafeProjectConfig();
return null;
}
const projectRoot = resolveProjectRootFromConfigPath(configPath);
return expandHomePath(candidate, projectRoot);
const resolved = expandHomePath(candidate, projectRoot);
try {
return assertWithinTrustedRoot(
resolved,
getDefaultCursorAgentDataHome(),
'use project agent data home'
);
} catch {
warnUnsafeProjectConfig();
return null;
}
} catch (error) {
console.error(
`[ECC] Failed to read or parse agent data config at ${configPath}: ${error.message}`
+530 -105
View File
@@ -4,7 +4,7 @@ const os = require('os');
const path = require('path');
const { resolveInstallPlan, loadInstallManifests } = require('./install-manifests');
const { readInstallState, writeInstallState } = require('./install-state');
const { readInstallState, validateInstallState } = require('./install-state');
const { assertWithinTrustedRoot } = require('./path-safety');
const { createManifestInstallPlan } = require('./install-executor');
const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry');
@@ -79,32 +79,62 @@ function getManagedOperations(state) {
return Array.isArray(state && state.operations) ? state.operations.filter(operation => operation.ownership === 'managed') : [];
}
function createUnsafeRepairSourceError() {
return new Error(
'Refusing unsafe repair source metadata: sources must stay within the repository.'
);
}
function assertSafeRepairSourcePath(sourcePath, repoRoot) {
try {
return assertWithinTrustedRoot(sourcePath, repoRoot, 'read repair source');
} catch {
throw createUnsafeRepairSourceError();
}
}
function resolveOperationSourcePath(repoRoot, operation) {
if (operation.sourceRelativePath) {
return path.join(repoRoot, operation.sourceRelativePath);
if (typeof operation.sourceRelativePath !== 'string') {
throw createUnsafeRepairSourceError();
}
const sourceRelativePath = operation.sourceRelativePath;
const hasParentTraversal = sourceRelativePath
.split(/[/\\]+/)
.includes('..');
const isAbsolute = path.isAbsolute(sourceRelativePath)
|| path.win32.isAbsolute(sourceRelativePath);
if (isAbsolute || hasParentTraversal) {
throw createUnsafeRepairSourceError();
}
return assertSafeRepairSourcePath(
path.resolve(repoRoot, sourceRelativePath),
repoRoot
);
}
return operation.sourcePath || null;
if (!operation.sourcePath) {
return null;
}
if (
typeof operation.sourcePath !== 'string'
|| !path.isAbsolute(operation.sourcePath)
) {
throw createUnsafeRepairSourceError();
}
return assertSafeRepairSourcePath(operation.sourcePath, repoRoot);
}
function areFilesEqual(leftPath, rightPath) {
try {
const leftStat = fs.statSync(leftPath);
const rightStat = fs.statSync(rightPath);
if (!leftStat.isFile() || !rightStat.isFile()) {
return false;
}
return fs.readFileSync(leftPath).equals(fs.readFileSync(rightPath));
return readFileNoFollow(leftPath).equals(readFileNoFollow(rightPath));
} catch (_error) {
return false;
}
}
function readFileUtf8(filePath) {
return fs.readFileSync(filePath, 'utf8');
}
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
@@ -189,12 +219,193 @@ function formatJson(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
function readJsonFile(filePath) {
return JSON.parse(readFileUtf8(filePath));
function getManagedDestination(
destinationPath,
trustedRoot,
action,
{ allowFinalSymlink = false } = {}
) {
if (!destinationPath || typeof destinationPath !== 'string') {
throw new Error(`Refusing to ${action}: missing destination path.`);
}
const canonicalRoot = assertWithinTrustedRoot(trustedRoot, trustedRoot, action);
const resolvedDestination = path.resolve(destinationPath);
const canonicalParent = assertWithinTrustedRoot(
path.dirname(resolvedDestination),
canonicalRoot,
action
);
const managedPath = path.join(canonicalParent, path.basename(resolvedDestination));
let stat = null;
try {
stat = fs.lstatSync(managedPath);
} catch (error) {
if (!error || (error.code !== 'ENOENT' && error.code !== 'ENOTDIR')) {
throw error;
}
}
if (stat && stat.isSymbolicLink() && !allowFinalSymlink) {
const error = new Error(
`Refusing to ${action}: managed destination is a final symlink.`
);
error.code = 'ECC_FINAL_DESTINATION_SYMLINK';
throw error;
}
return {
canonicalRoot,
exists: stat !== null,
isFinalSymlink: Boolean(stat && stat.isSymbolicLink()),
managedPath
};
}
function ensureParentDir(filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
function ensureContainedParentDir(destinationPath, trustedRoot, action) {
const initialDestination = getManagedDestination(
destinationPath,
trustedRoot,
action
);
const { canonicalRoot, managedPath } = initialDestination;
const canonicalParent = path.dirname(managedPath);
const relativeParent = path.relative(canonicalRoot, canonicalParent);
const pathSegments = relativeParent
? relativeParent.split(path.sep).filter(Boolean)
: [];
let currentPath = canonicalRoot;
for (const segment of pathSegments) {
const validatedParent = assertWithinTrustedRoot(currentPath, canonicalRoot, action);
const nextPath = path.join(validatedParent, segment);
try {
fs.mkdirSync(nextPath);
} catch (error) {
if (!error || error.code !== 'EEXIST') {
throw error;
}
}
const validatedNext = assertWithinTrustedRoot(nextPath, canonicalRoot, action);
const nextStat = fs.lstatSync(validatedNext);
if (!nextStat.isDirectory() || nextStat.isSymbolicLink()) {
throw new Error(`Refusing to ${action}: destination parent is not a trusted directory.`);
}
currentPath = validatedNext;
}
return getManagedDestination(managedPath, canonicalRoot, action).managedPath;
}
function prepareContainedWriteDestination(destinationPath, trustedRoot, action) {
return ensureContainedParentDir(destinationPath, trustedRoot, action);
}
function getContainedExistingPath(
destinationPath,
trustedRoot,
action,
{ allowFinalSymlink = false } = {}
) {
const initialDestination = getManagedDestination(
destinationPath,
trustedRoot,
action,
{ allowFinalSymlink }
);
const followsToExistingPath = fs.existsSync(initialDestination.managedPath);
if (!followsToExistingPath && !initialDestination.isFinalSymlink) {
return null;
}
const finalDestination = getManagedDestination(
initialDestination.managedPath,
trustedRoot,
action,
{ allowFinalSymlink }
);
return finalDestination.exists ? finalDestination.managedPath : null;
}
function writeFileNoFollow(filePath, content, mode) {
const flags = fs.constants.O_WRONLY
| fs.constants.O_CREAT
| fs.constants.O_TRUNC
| (fs.constants.O_NOFOLLOW || 0);
const fileDescriptor = fs.openSync(filePath, flags, mode);
try {
fs.writeFileSync(fileDescriptor, content);
if (mode !== undefined) {
fs.fchmodSync(fileDescriptor, mode);
}
} finally {
fs.closeSync(fileDescriptor);
}
}
function readFileNoFollow(filePath, encoding) {
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
const fileDescriptor = fs.openSync(filePath, flags);
try {
if (!fs.fstatSync(fileDescriptor).isFile()) {
throw new Error(`Refusing to read non-file path: ${filePath}`);
}
return fs.readFileSync(fileDescriptor, encoding);
} finally {
fs.closeSync(fileDescriptor);
}
}
function readJsonNoFollow(filePath) {
return JSON.parse(readFileNoFollow(filePath, 'utf8'));
}
function writeContainedFile(destinationPath, content, trustedRoot, action, mode) {
const preparedDestination = prepareContainedWriteDestination(destinationPath, trustedRoot, action);
const finalDestination = getManagedDestination(
preparedDestination,
trustedRoot,
action
).managedPath;
writeFileNoFollow(finalDestination, content, mode);
return finalDestination;
}
function copyContainedFile(sourcePath, destinationPath, trustedRoot, action) {
const sourceStat = fs.statSync(sourcePath);
const sourceContent = fs.readFileSync(sourcePath);
return writeContainedFile(
destinationPath,
sourceContent,
trustedRoot,
action,
sourceStat.mode & 0o777
);
}
function removeContainedPath(destinationPath, trustedRoot, action, options = {}) {
const existingDestination = getContainedExistingPath(
destinationPath,
trustedRoot,
action,
{ allowFinalSymlink: true }
);
if (!existingDestination) {
return null;
}
const finalDestination = getManagedDestination(
existingDestination,
trustedRoot,
action,
{ allowFinalSymlink: true }
).managedPath;
fs.rmSync(finalDestination, options);
return finalDestination;
}
function deepMergeJson(baseValue, patchValue) {
@@ -313,17 +524,14 @@ function executeRepairOperation(repoRoot, operation, trustedRoot) {
// Install-state is attacker-controllable; never write/delete outside the
// adapter-derived trusted root, regardless of what the state file claims
// (GHSA-hfpv-w6mp-5g95).
assertWithinTrustedRoot(operation.destinationPath, trustedRoot, 'repair');
if (operation.kind === 'copy-file') {
const sourcePath = resolveOperationSourcePath(repoRoot, operation);
if (!sourcePath || !fs.existsSync(sourcePath)) {
throw new Error(`Missing source file for repair: ${sourcePath || operation.sourceRelativePath}`);
}
ensureParentDir(operation.destinationPath);
fs.copyFileSync(sourcePath, operation.destinationPath);
return;
copyContainedFile(sourcePath, operation.destinationPath, trustedRoot, 'repair');
return operation.destinationPath;
}
if (operation.kind === 'render-template') {
@@ -332,9 +540,8 @@ function executeRepairOperation(repoRoot, operation, trustedRoot) {
throw new Error(`Missing rendered content for repair: ${operation.destinationPath}`);
}
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, renderedContent);
return;
writeContainedFile(operation.destinationPath, renderedContent, trustedRoot, 'repair');
return operation.destinationPath;
}
if (operation.kind === 'merge-json') {
@@ -343,21 +550,26 @@ function executeRepairOperation(repoRoot, operation, trustedRoot) {
throw new Error(`Missing merge payload for repair: ${operation.destinationPath}`);
}
const currentValue = fs.existsSync(operation.destinationPath) ? readJsonFile(operation.destinationPath) : {};
const existingDestination = getContainedExistingPath(operation.destinationPath, trustedRoot, 'repair');
const currentValue = existingDestination
? readJsonNoFollow(
getManagedDestination(existingDestination, trustedRoot, 'repair').managedPath
)
: {};
const mergedValue = deepMergeJson(currentValue, payload);
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, formatJson(mergedValue));
return;
writeContainedFile(operation.destinationPath, formatJson(mergedValue), trustedRoot, 'repair');
return operation.destinationPath;
}
if (operation.kind === 'remove') {
if (!fs.existsSync(operation.destinationPath)) {
return;
}
fs.rmSync(operation.destinationPath, { recursive: true, force: true });
return;
const removedPath = removeContainedPath(
operation.destinationPath,
trustedRoot,
'repair',
{ recursive: true, force: true }
);
return removedPath ? operation.destinationPath : null;
}
throw new Error(`Unsupported repair operation kind: ${operation.kind}`);
@@ -365,28 +577,30 @@ function executeRepairOperation(repoRoot, operation, trustedRoot) {
function executeUninstallOperation(operation, trustedRoot) {
// Confine deletes to the trusted install root (GHSA-hfpv-w6mp-5g95).
assertWithinTrustedRoot(operation.destinationPath, trustedRoot, 'uninstall');
if (operation.kind === 'copy-file') {
if (!fs.existsSync(operation.destinationPath)) {
const removedPath = removeContainedPath(
operation.destinationPath,
trustedRoot,
'uninstall',
{ force: true }
);
if (!removedPath) {
return {
removedPaths: [],
cleanupTargets: []
};
}
fs.rmSync(operation.destinationPath, { force: true });
return {
removedPaths: [operation.destinationPath],
cleanupTargets: [operation.destinationPath]
cleanupTargets: [removedPath]
};
}
if (operation.kind === 'render-template') {
const previousContent = getOperationPreviousContent(operation);
if (previousContent !== null) {
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, previousContent);
writeContainedFile(operation.destinationPath, previousContent, trustedRoot, 'uninstall');
return {
removedPaths: [],
cleanupTargets: []
@@ -395,33 +609,36 @@ function executeUninstallOperation(operation, trustedRoot) {
const previousJson = getOperationPreviousJson(operation);
if (previousJson !== undefined) {
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, formatJson(previousJson));
writeContainedFile(operation.destinationPath, formatJson(previousJson), trustedRoot, 'uninstall');
return {
removedPaths: [],
cleanupTargets: []
};
}
if (!fs.existsSync(operation.destinationPath)) {
const removedPath = removeContainedPath(
operation.destinationPath,
trustedRoot,
'uninstall',
{ force: true }
);
if (!removedPath) {
return {
removedPaths: [],
cleanupTargets: []
};
}
fs.rmSync(operation.destinationPath, { force: true });
return {
removedPaths: [operation.destinationPath],
cleanupTargets: [operation.destinationPath]
cleanupTargets: [removedPath]
};
}
if (operation.kind === 'merge-json') {
const previousContent = getOperationPreviousContent(operation);
if (previousContent !== null) {
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, previousContent);
writeContainedFile(operation.destinationPath, previousContent, trustedRoot, 'uninstall');
return {
removedPaths: [],
cleanupTargets: []
@@ -430,15 +647,19 @@ function executeUninstallOperation(operation, trustedRoot) {
const previousJson = getOperationPreviousJson(operation);
if (previousJson !== undefined) {
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, formatJson(previousJson));
writeContainedFile(operation.destinationPath, formatJson(previousJson), trustedRoot, 'uninstall');
return {
removedPaths: [],
cleanupTargets: []
};
}
if (!fs.existsSync(operation.destinationPath)) {
const existingDestination = getContainedExistingPath(
operation.destinationPath,
trustedRoot,
'uninstall'
);
if (!existingDestination) {
return {
removedPaths: [],
cleanupTargets: []
@@ -450,18 +671,24 @@ function executeUninstallOperation(operation, trustedRoot) {
throw new Error(`Missing merge payload for uninstall: ${operation.destinationPath}`);
}
const currentValue = readJsonFile(operation.destinationPath);
const currentValue = readJsonNoFollow(
getManagedDestination(existingDestination, trustedRoot, 'uninstall').managedPath
);
const nextValue = deepRemoveJsonSubset(currentValue, payload);
if (nextValue === JSON_REMOVE_SENTINEL) {
fs.rmSync(operation.destinationPath, { force: true });
const removedPath = removeContainedPath(
operation.destinationPath,
trustedRoot,
'uninstall',
{ force: true }
);
return {
removedPaths: [operation.destinationPath],
cleanupTargets: [operation.destinationPath]
removedPaths: removedPath ? [operation.destinationPath] : [],
cleanupTargets: removedPath ? [removedPath] : []
};
}
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, formatJson(nextValue));
writeContainedFile(operation.destinationPath, formatJson(nextValue), trustedRoot, 'uninstall');
return {
removedPaths: [],
cleanupTargets: []
@@ -471,8 +698,7 @@ function executeUninstallOperation(operation, trustedRoot) {
if (operation.kind === 'remove') {
const previousContent = getOperationPreviousContent(operation);
if (previousContent !== null) {
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, previousContent);
writeContainedFile(operation.destinationPath, previousContent, trustedRoot, 'uninstall');
return {
removedPaths: [],
cleanupTargets: []
@@ -481,8 +707,7 @@ function executeUninstallOperation(operation, trustedRoot) {
const previousJson = getOperationPreviousJson(operation);
if (previousJson !== undefined) {
ensureParentDir(operation.destinationPath);
fs.writeFileSync(operation.destinationPath, formatJson(previousJson));
writeContainedFile(operation.destinationPath, formatJson(previousJson), trustedRoot, 'uninstall');
return {
removedPaths: [],
cleanupTargets: []
@@ -498,7 +723,7 @@ function executeUninstallOperation(operation, trustedRoot) {
throw new Error(`Unsupported uninstall operation kind: ${operation.kind}`);
}
function inspectManagedOperation(repoRoot, operation) {
function inspectManagedOperation(repoRoot, trustedRoot, operation) {
const destinationPath = operation.destinationPath;
if (!destinationPath) {
return {
@@ -507,8 +732,29 @@ function inspectManagedOperation(repoRoot, operation) {
};
}
let managedDestination;
try {
managedDestination = getManagedDestination(
destinationPath,
trustedRoot,
'inspect managed operation',
{ allowFinalSymlink: operation.kind === 'remove' }
);
} catch (error) {
return {
status: 'unsafe-destination',
operation,
destinationPath,
reason: error && error.code === 'ECC_FINAL_DESTINATION_SYMLINK'
? 'final-symlink'
: 'outside-root'
};
}
const inspectedPath = managedDestination.managedPath;
if (operation.kind === 'remove') {
if (fs.existsSync(destinationPath)) {
if (managedDestination.exists) {
return {
status: 'drifted',
operation,
@@ -523,7 +769,20 @@ function inspectManagedOperation(repoRoot, operation) {
};
}
if (!fs.existsSync(destinationPath)) {
let copySourcePath = null;
if (operation.kind === 'copy-file') {
try {
copySourcePath = resolveOperationSourcePath(repoRoot, operation);
} catch {
return {
status: 'unsafe-source',
operation,
destinationPath
};
}
}
if (!managedDestination.exists) {
return {
status: 'missing',
operation,
@@ -532,22 +791,21 @@ function inspectManagedOperation(repoRoot, operation) {
}
if (operation.kind === 'copy-file') {
const sourcePath = resolveOperationSourcePath(repoRoot, operation);
if (!sourcePath || !fs.existsSync(sourcePath)) {
if (!copySourcePath || !fs.existsSync(copySourcePath)) {
return {
status: 'missing-source',
operation,
destinationPath,
sourcePath
sourcePath: copySourcePath
};
}
if (!areFilesEqual(sourcePath, destinationPath)) {
if (!areFilesEqual(copySourcePath, inspectedPath)) {
return {
status: 'drifted',
operation,
destinationPath,
sourcePath
sourcePath: copySourcePath
};
}
@@ -555,7 +813,7 @@ function inspectManagedOperation(repoRoot, operation) {
status: 'ok',
operation,
destinationPath,
sourcePath
sourcePath: copySourcePath
};
}
@@ -569,7 +827,15 @@ function inspectManagedOperation(repoRoot, operation) {
};
}
if (readFileUtf8(destinationPath) !== renderedContent) {
try {
if (readFileNoFollow(inspectedPath, 'utf8') !== renderedContent) {
return {
status: 'drifted',
operation,
destinationPath
};
}
} catch {
return {
status: 'drifted',
operation,
@@ -595,7 +861,7 @@ function inspectManagedOperation(repoRoot, operation) {
}
try {
const currentValue = readJsonFile(destinationPath);
const currentValue = readJsonNoFollow(inspectedPath);
if (!jsonContainsSubset(currentValue, payload)) {
return {
status: 'drifted',
@@ -625,16 +891,20 @@ function inspectManagedOperation(repoRoot, operation) {
};
}
function summarizeManagedOperationHealth(repoRoot, operations) {
function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations) {
return operations.reduce(
(summary, operation) => {
const inspection = inspectManagedOperation(repoRoot, operation);
const inspection = inspectManagedOperation(repoRoot, trustedRoot, operation);
if (inspection.status === 'missing') {
summary.missing.push(inspection);
} else if (inspection.status === 'drifted') {
summary.drifted.push(inspection);
} else if (inspection.status === 'missing-source') {
summary.missingSource.push(inspection);
} else if (inspection.status === 'unsafe-source') {
summary.unsafeSource.push(inspection);
} else if (inspection.status === 'unsafe-destination') {
summary.unsafeDestination.push(inspection);
} else if (inspection.status === 'unverified' || inspection.status === 'invalid-destination') {
summary.unverified.push(inspection);
}
@@ -644,11 +914,23 @@ function summarizeManagedOperationHealth(repoRoot, operations) {
missing: [],
drifted: [],
missingSource: [],
unsafeSource: [],
unsafeDestination: [],
unverified: []
}
);
}
function getUnsafeManagedDestinationError(operationHealth) {
const hasFinalSymlink = operationHealth.unsafeDestination.some(
inspection => inspection.reason === 'final-symlink'
);
if (hasFinalSymlink) {
return 'Refusing unsafe managed destination: final symlink detected.';
}
return 'Refusing unsafe managed destination outside adapter-derived install root.';
}
function buildDiscoveryRecord(adapter, context) {
const installTargetInput = {
homeDir: context.homeDir,
@@ -782,9 +1064,33 @@ function analyzeRecord(record, context) {
}
const managedOperations = getManagedOperations(state);
const operationHealth = summarizeManagedOperationHealth(context.repoRoot, managedOperations);
const operationHealth = summarizeManagedOperationHealth(
context.repoRoot,
record.targetRoot,
managedOperations
);
const missingManagedOperations = operationHealth.missing;
if (operationHealth.unsafeDestination.length > 0) {
issues.push(
buildIssue(
'error',
'unsafe-managed-destination',
`${operationHealth.unsafeDestination.length} managed operation(s) target an unsafe destination`
)
);
}
if (operationHealth.unsafeSource.length > 0) {
issues.push(
buildIssue(
'error',
'unsafe-repair-source',
`${operationHealth.unsafeSource.length} managed operation(s) reference unsafe repair source metadata`
)
);
}
if (missingManagedOperations.length > 0) {
issues.push(
buildIssue('error', 'missing-managed-files', `${missingManagedOperations.length} managed file(s) are missing`, {
@@ -951,6 +1257,43 @@ function createRepairPlanFromRecord(record, context, options = {}) {
};
}
function buildAdapterDerivedStatePreview(statePreview, record) {
return {
...statePreview,
target: {
...statePreview.target,
id: record.adapter.id,
target: record.adapter.target,
kind: record.adapter.kind,
root: record.targetRoot,
installStatePath: record.installStatePath
}
};
}
function assertValidInstallStateForWrite(state, label) {
const validation = validateInstallState(state);
if (validation.valid) {
return;
}
const details = validation.errors
.map(error => `${error.instancePath || '/'} ${error.message}`)
.join('; ');
throw new Error(`Invalid install-state (${label}): ${details}`);
}
function writeRefreshedInstallState(record, statePreview) {
const trustedStatePreview = buildAdapterDerivedStatePreview(statePreview, record);
assertValidInstallStateForWrite(trustedStatePreview, record.installStatePath);
return writeContainedFile(
record.installStatePath,
formatJson(trustedStatePreview),
record.targetRoot,
'repair'
);
}
function repairInstalledStates(options = {}) {
const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT;
const manifests = loadInstallManifests({ repoRoot });
@@ -991,7 +1334,33 @@ function repairInstalledStates(options = {}) {
const desiredPlan = createRepairPlanFromRecord(record, context, {
exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE],
});
const operationHealth = summarizeManagedOperationHealth(context.repoRoot, desiredPlan.operations);
const operationHealth = summarizeManagedOperationHealth(
context.repoRoot,
record.targetRoot,
desiredPlan.operations
);
if (operationHealth.unsafeDestination.length > 0) {
return {
adapter: record.adapter,
status: 'error',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
stateRefreshed: false,
error: getUnsafeManagedDestinationError(operationHealth)
};
}
if (operationHealth.unsafeSource.length > 0) {
return {
adapter: record.adapter,
status: 'error',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
stateRefreshed: false,
error: createUnsafeRepairSourceError().message
};
}
const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))];
const plannedRepairs = [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)];
@@ -1022,7 +1391,35 @@ function repairInstalledStates(options = {}) {
}
const desiredPlan = createRepairPlanFromRecord(record, context);
const operationHealth = summarizeManagedOperationHealth(context.repoRoot, desiredPlan.operations);
const operationHealth = summarizeManagedOperationHealth(
context.repoRoot,
record.targetRoot,
desiredPlan.operations
);
if (operationHealth.unsafeDestination.length > 0) {
return {
adapter: record.adapter,
status: 'error',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
stateRefreshed: false,
error: getUnsafeManagedDestinationError(operationHealth)
};
}
if (operationHealth.unsafeSource.length > 0) {
return {
adapter: record.adapter,
status: 'error',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
stateRefreshed: false,
error: createUnsafeRepairSourceError().message
};
}
if (operationHealth.missingSource.length > 0) {
return {
@@ -1052,20 +1449,24 @@ function repairInstalledStates(options = {}) {
};
}
if (repairOperations.length > 0) {
for (const operation of repairOperations) {
executeRepairOperation(context.repoRoot, operation, record.targetRoot);
const repairedPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : [];
for (const operation of repairOperations) {
const repairedPath = executeRepairOperation(
context.repoRoot,
operation,
record.targetRoot
);
if (repairedPath) {
repairedPaths.push(repairedPath);
}
writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview);
} else {
writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview);
}
writeRefreshedInstallState(record, desiredPlan.statePreview);
return {
adapter: record.adapter,
status: (repairOperations.length > 0 || needsOpencodeBuild) ? 'repaired' : 'ok',
installStatePath: record.installStatePath,
repairedPaths: plannedRepairs,
repairedPaths,
plannedRepairs: [],
stateRefreshed: true,
error: null
@@ -1106,22 +1507,39 @@ function repairInstalledStates(options = {}) {
}
function cleanupEmptyParentDirs(filePath, stopAt) {
let currentPath = path.dirname(filePath);
const normalizedStopAt = path.resolve(stopAt);
const trustedStopAt = assertWithinTrustedRoot(stopAt, stopAt, 'clean up');
const trustedFilePath = assertWithinTrustedRoot(filePath, trustedStopAt, 'clean up');
let currentPath = path.dirname(trustedFilePath);
while (currentPath && path.resolve(currentPath).startsWith(normalizedStopAt) && path.resolve(currentPath) !== normalizedStopAt) {
if (!fs.existsSync(currentPath)) {
currentPath = path.dirname(currentPath);
continue;
}
const stat = fs.lstatSync(currentPath);
if (!stat.isDirectory() || fs.readdirSync(currentPath).length > 0) {
while (currentPath) {
const relativePath = path.relative(trustedStopAt, currentPath);
const isContained = relativePath !== '..'
&& !relativePath.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relativePath);
if (!isContained || relativePath === '') {
break;
}
fs.rmdirSync(currentPath);
currentPath = path.dirname(currentPath);
let validatedPath = assertWithinTrustedRoot(currentPath, trustedStopAt, 'clean up');
if (!fs.existsSync(validatedPath)) {
currentPath = path.dirname(validatedPath);
continue;
}
validatedPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up');
const stat = fs.lstatSync(validatedPath);
if (!stat.isDirectory() || stat.isSymbolicLink()) {
break;
}
validatedPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up');
if (fs.readdirSync(validatedPath).length > 0) {
break;
}
const finalPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up');
fs.rmdirSync(finalPath);
currentPath = path.dirname(finalPath);
}
}
@@ -1145,7 +1563,10 @@ function uninstallInstalledStates(options = {}) {
}
const state = record.state;
const plannedRemovals = Array.from(new Set([...getManagedOperations(state).map(operation => operation.destinationPath), state.target.installStatePath]));
const plannedRemovals = Array.from(new Set([
...getManagedOperations(state).map(operation => operation.destinationPath),
record.installStatePath
]));
if (options.dryRun) {
return {
@@ -1169,15 +1590,19 @@ function uninstallInstalledStates(options = {}) {
cleanupTargets.push(...outcome.cleanupTargets);
}
if (fs.existsSync(state.target.installStatePath)) {
assertWithinTrustedRoot(state.target.installStatePath, record.targetRoot, 'uninstall');
fs.rmSync(state.target.installStatePath, { force: true });
removedPaths.push(state.target.installStatePath);
cleanupTargets.push(state.target.installStatePath);
const removedStatePath = removeContainedPath(
record.installStatePath,
record.targetRoot,
'uninstall',
{ force: true }
);
if (removedStatePath) {
removedPaths.push(record.installStatePath);
cleanupTargets.push(removedStatePath);
}
for (const cleanupTarget of cleanupTargets) {
cleanupEmptyParentDirs(cleanupTarget, state.target.root);
cleanupEmptyParentDirs(cleanupTarget, record.targetRoot);
}
return {
+5 -1
View File
@@ -15,8 +15,12 @@ function parseHostHeader(value) {
if (!value || typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
const match = trimmed.match(/^(\[[^\]]+\]|[^:]+)(?::\d+)?$/);
const match = trimmed.match(/^(\[[^\]]+\]|[^:]+)(?::(\d+))?$/);
if (!match) return null;
if (match[2] !== undefined) {
const port = Number(match[2]);
if (!Number.isInteger(port) || port > 65535) return null;
}
return match[1].toLowerCase();
}
+40 -14
View File
@@ -13,11 +13,15 @@ const path = require('path');
* root - never trusted from the state file itself (GHSA-hfpv-w6mp-5g95).
*/
function safeRealpath(target) {
function pathEntryExists(target) {
try {
return fs.realpathSync(path.resolve(target));
} catch {
return path.resolve(target);
fs.lstatSync(target);
return true;
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
return false;
}
throw error;
}
}
@@ -29,7 +33,7 @@ function safeRealpath(target) {
function realpathNearestExisting(target) {
let current = path.resolve(target);
const tail = [];
while (!fs.existsSync(current)) {
while (!pathEntryExists(current)) {
const parent = path.dirname(current);
if (parent === current) {
break;
@@ -37,7 +41,7 @@ function realpathNearestExisting(target) {
tail.unshift(path.basename(current));
current = parent;
}
const real = safeRealpath(current);
const real = fs.realpathSync(current);
return tail.length > 0 ? path.join(real, ...tail) : real;
}
@@ -45,17 +49,32 @@ function realpathNearestExisting(target) {
* True when `target` resolves to `root` itself or a path beneath it, with
* symlinks resolved on both sides.
*/
function resolveContainment(target, root) {
const realRoot = realpathNearestExisting(root);
const realTarget = realpathNearestExisting(target);
const relativePath = path.relative(realRoot, realTarget);
const contained = relativePath === ''
|| (
relativePath !== '..'
&& !relativePath.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relativePath)
);
return {
contained,
realRoot,
realTarget
};
}
function isWithinRoot(target, root) {
if (!root) {
return false;
}
const realRoot = safeRealpath(root);
const realTarget = realpathNearestExisting(target);
if (realTarget === realRoot) {
return true;
try {
return resolveContainment(target, root).contained;
} catch {
return false;
}
const rel = path.relative(realRoot, realTarget);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
/**
@@ -69,10 +88,17 @@ function assertWithinTrustedRoot(target, root, action = 'write') {
if (!root) {
throw new Error(`Refusing to ${action} '${target}': no trusted install root resolved.`);
}
if (!isWithinRoot(target, root)) {
let containment;
try {
containment = resolveContainment(target, root);
} catch {
containment = null;
}
if (!containment || !containment.contained) {
throw new Error(`Refusing to ${action} outside the install root: '${target}' is not within '${root}'.`);
}
return realpathNearestExisting(target);
return containment.realTarget;
}
module.exports = {
+166 -10
View File
@@ -68,6 +68,20 @@ function withIsolatedCwd(fn) {
}
}
function captureConsoleErrors(fn) {
const originalError = console.error;
const messages = [];
console.error = (...args) => {
messages.push(args.join(' '));
};
try {
return { result: fn(), messages };
} finally {
console.error = originalError;
}
}
function runTests() {
console.log('\n=== Testing agent-data-home.js ===\n');
let passed = 0;
@@ -148,10 +162,11 @@ function runTests() {
})) passed++; else failed++;
if (test('reads project ecc-agent-data.json config file', () => {
const tmpDir = path.join(os.tmpdir(), `ecc-agent-data-home-read-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
const configPath = path.join(tmpDir, 'ecc-agent-data.json');
const customHome = path.join(tmpDir, 'data-root');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-read-'));
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-user-'));
const configPath = path.join(tmpDir, '.cursor', 'ecc-agent-data.json');
const customHome = path.join(homeDir, '.cursor', 'ecc', 'custom');
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(
configPath,
JSON.stringify({ agentDataHome: customHome }),
@@ -162,27 +177,31 @@ function runTests() {
withEnv({
ECC_AGENT_DATA_HOME: undefined,
CURSOR_VERSION: undefined,
HOME: homeDir,
USERPROFILE: undefined,
}, () => {
const agentDataHome = require('../../scripts/lib/agent-data-home');
assert.strictEqual(
agentDataHome.readProjectConfigAt(configPath),
path.resolve(customHome)
path.join(fs.realpathSync(homeDir), '.cursor', 'ecc', 'custom')
);
});
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('resolves relative agentDataHome against project root, not cwd', () => {
if (test('rejects a relative agentDataHome that redirects into the project', () => {
const stamp = Date.now();
const projectDir = path.join(os.tmpdir(), `ecc-agent-data-home-relative-${stamp}`);
const cursorDir = path.join(projectDir, '.cursor');
const otherCwd = path.join(os.tmpdir(), `ecc-agent-data-home-other-cwd-${stamp}`);
const homeDir = path.join(os.tmpdir(), `ecc-agent-data-home-relative-user-${stamp}`);
fs.mkdirSync(cursorDir, { recursive: true });
fs.mkdirSync(otherCwd, { recursive: true });
fs.mkdirSync(homeDir, { recursive: true });
const configPath = path.join(cursorDir, 'ecc-agent-data.json');
const expectedHome = path.join(projectDir, '.ecc-data');
fs.writeFileSync(
configPath,
JSON.stringify({ agentDataHome: '.ecc-data' }),
@@ -196,18 +215,155 @@ function runTests() {
ECC_AGENT_DATA_HOME: undefined,
CURSOR_VERSION: undefined,
CURSOR_PROJECT_DIR: projectDir,
HOME: homeDir,
USERPROFILE: undefined,
}, () => {
const agentDataHome = require('../../scripts/lib/agent-data-home');
assert.strictEqual(agentDataHome.readProjectConfigAt(configPath), expectedHome);
const { result, messages } = captureConsoleErrors(
() => agentDataHome.readProjectConfigAt(configPath)
);
assert.strictEqual(result, null);
assert.ok(messages.some(message => message.includes('Ignoring unsafe agent data project config')));
assert.ok(messages.every(message => !message.includes('.ecc-data')));
assert.strictEqual(
agentDataHome.resolveAgentDataHome({ projectDir }),
expectedHome
captureConsoleErrors(
() => agentDataHome.resolveAgentDataHome({ projectDir, preferCursorDefault: true })
).result,
path.join(homeDir, '.cursor', 'ecc')
);
});
} finally {
process.chdir(originalCwd);
fs.rmSync(projectDir, { recursive: true, force: true });
fs.rmSync(otherCwd, { recursive: true, force: true });
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('rejects relative project config paths even when the project is beneath the trusted root', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-nested-user-'));
const projectDir = path.join(homeDir, '.cursor', 'ecc', 'checked-out-project');
const configPath = path.join(projectDir, '.cursor', 'ecc-agent-data.json');
const candidate = '.repo-data';
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({ agentDataHome: candidate }), 'utf8');
try {
withEnv({
ECC_AGENT_DATA_HOME: undefined,
HOME: homeDir,
USERPROFILE: undefined,
}, () => {
const agentDataHome = require('../../scripts/lib/agent-data-home');
const { result, messages } = captureConsoleErrors(
() => agentDataHome.readProjectConfigAt(configPath)
);
assert.strictEqual(result, null);
assert.ok(messages.some(message => message.includes('Ignoring unsafe agent data project config')));
assert.ok(messages.every(message => !message.includes(candidate)));
});
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('rejects traversal and absolute project config paths outside the Cursor data root', () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-unsafe-'));
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-unsafe-user-'));
const configPath = path.join(projectDir, '.cursor', 'ecc-agent-data.json');
fs.mkdirSync(path.dirname(configPath), { recursive: true });
try {
withEnv({
ECC_AGENT_DATA_HOME: undefined,
HOME: homeDir,
USERPROFILE: undefined,
}, () => {
const agentDataHome = require('../../scripts/lib/agent-data-home');
const unsafeCandidates = [
'../../repo-data',
path.join(projectDir, 'absolute-data'),
'~/.cursor/ecc/profiles/../traversed-data',
];
for (const candidate of unsafeCandidates) {
fs.writeFileSync(configPath, JSON.stringify({ agentDataHome: candidate }), 'utf8');
const { result, messages } = captureConsoleErrors(
() => agentDataHome.readProjectConfigAt(configPath)
);
assert.strictEqual(result, null);
assert.ok(messages.some(message => message.includes('Ignoring unsafe agent data project config')));
assert.ok(messages.every(message => !message.includes(candidate)));
}
});
} finally {
fs.rmSync(projectDir, { recursive: true, force: true });
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('allows a non-existent project config destination beneath the Cursor data root', () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-safe-'));
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-safe-user-'));
const configPath = path.join(projectDir, '.cursor', 'ecc-agent-data.json');
const safeHome = path.join(homeDir, '.cursor', 'ecc', 'profiles', 'work');
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({ agentDataHome: safeHome }), 'utf8');
try {
withEnv({
ECC_AGENT_DATA_HOME: undefined,
HOME: homeDir,
USERPROFILE: undefined,
}, () => {
const agentDataHome = require('../../scripts/lib/agent-data-home');
assert.strictEqual(
agentDataHome.readProjectConfigAt(configPath),
path.join(fs.realpathSync(homeDir), '.cursor', 'ecc', 'profiles', 'work')
);
});
} finally {
fs.rmSync(projectDir, { recursive: true, force: true });
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('rejects a project config destination that escapes through a symlink', () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-link-'));
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-link-user-'));
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-link-outside-'));
const configPath = path.join(projectDir, '.cursor', 'ecc-agent-data.json');
const cursorRoot = path.join(homeDir, '.cursor', 'ecc');
const linkPath = path.join(cursorRoot, 'redirect');
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.mkdirSync(cursorRoot, { recursive: true });
try {
try {
fs.symlinkSync(outsideDir, linkPath, 'dir');
} catch {
console.log(' (symlink unsupported on this platform; skipping)');
return;
}
const candidate = path.join(linkPath, 'session-data');
fs.writeFileSync(configPath, JSON.stringify({ agentDataHome: candidate }), 'utf8');
withEnv({
ECC_AGENT_DATA_HOME: undefined,
HOME: homeDir,
USERPROFILE: undefined,
}, () => {
const agentDataHome = require('../../scripts/lib/agent-data-home');
const { result, messages } = captureConsoleErrors(
() => agentDataHome.readProjectConfigAt(configPath)
);
assert.strictEqual(result, null);
assert.ok(messages.some(message => message.includes('Ignoring unsafe agent data project config')));
assert.ok(messages.every(message => !message.includes(candidate)));
});
} finally {
fs.rmSync(projectDir, { recursive: true, force: true });
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(outsideDir, { recursive: true, force: true });
}
})) passed++; else failed++;
+524
View File
@@ -17,6 +17,7 @@ const {
const { getInstallTargetAdapter } = require('../../scripts/lib/install-targets/registry');
const {
createInstallState,
readInstallState,
writeInstallState,
} = require('../../scripts/lib/install-state');
@@ -1017,6 +1018,162 @@ function runTests() {
}
})) passed++; else failed++;
if (test('repair rejects absolute and parent-relative source metadata outside the repository', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const outsideRoot = createTempDir('install-lifecycle-source-outside-');
const outsideSourcePath = path.join(outsideRoot, 'secret.txt');
fs.writeFileSync(outsideSourcePath, 'outside secret\n');
try {
const unsafeSources = [
outsideSourcePath,
path.relative(REPO_ROOT, outsideSourcePath),
];
for (const sourceRelativePath of unsafeSources) {
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const destinationPath = path.join(projectRoot, '.cursor', 'copied-secret.txt');
writeCursorState(projectRoot, {
operations: [
managedOperation('copy-file', destinationPath, {
sourceRelativePath,
strategy: 'copy-file',
}),
],
});
const doctor = buildDoctorReport({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['cursor'],
});
const result = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['cursor'],
});
assert.strictEqual(doctor.results[0].status, 'error');
assert.ok(
doctor.results[0].issues.some(
issue => issue.code === 'unsafe-repair-source'
)
);
assert.strictEqual(result.results[0].status, 'error');
assert.ok(result.results[0].error.includes('unsafe repair source metadata'));
assert.ok(!result.results[0].error.includes(outsideSourcePath));
assert.ok(!fs.existsSync(destinationPath));
} finally {
cleanup(projectRoot);
}
}
} finally {
cleanup(homeDir);
cleanup(outsideRoot);
}
})) passed++; else failed++;
if (test('doctor and repair reject unsafe destinations before health inspection reads them', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const outsideRoot = createTempDir('install-lifecycle-destination-outside-');
const copySource = fs.readFileSync(
path.join(REPO_ROOT, 'rules', 'common', 'coding-style.md'),
'utf8'
);
const cases = [
{
name: 'matching copy',
kind: 'copy-file',
content: copySource,
overrides: { strategy: 'copy-file' },
},
{
name: 'drifted copy',
kind: 'copy-file',
content: 'outside drift\n',
overrides: { strategy: 'copy-file' },
},
{
name: 'rendered template',
kind: 'render-template',
content: 'managed template\n',
overrides: {
renderedContent: 'managed template\n',
strategy: 'render-template',
},
},
{
name: 'merged JSON',
kind: 'merge-json',
content: '{"managed":true,"outside":"sentinel"}\n',
overrides: {
mergePayload: { managed: true },
strategy: 'merge-json',
},
},
];
try {
for (const testCase of cases) {
const projectRoot = createTempDir('install-lifecycle-project-');
const destinationPath = path.join(outsideRoot, `${testCase.name}.txt`);
const originalExistsSync = fs.existsSync;
try {
fs.writeFileSync(destinationPath, testCase.content);
writeCursorState(projectRoot, {
operations: [
managedOperation(testCase.kind, destinationPath, testCase.overrides),
],
});
fs.existsSync = function existsSyncWithoutOutsideInspection(candidatePath) {
if (path.resolve(candidatePath) === path.resolve(destinationPath)) {
throw new Error(`unsafe destination inspected: ${testCase.name}`);
}
return originalExistsSync.call(fs, candidatePath);
};
const doctor = buildDoctorReport({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['cursor'],
});
const repair = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['cursor'],
});
assert.strictEqual(doctor.results[0].status, 'error');
assert.ok(
doctor.results[0].issues.some(
issue => issue.code === 'unsafe-managed-destination'
)
);
assert.strictEqual(repair.results[0].status, 'error');
assert.ok(repair.results[0].error.includes('unsafe managed destination'));
assert.strictEqual(
originalExistsSync.call(fs, destinationPath),
true,
`${testCase.name} destination should remain untouched`
);
} finally {
fs.existsSync = originalExistsSync;
cleanup(projectRoot);
}
}
} finally {
cleanup(homeDir);
cleanup(outsideRoot);
}
})) passed++; else failed++;
if (test('doctor reports drifted managed files as a warning', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
@@ -1317,6 +1474,202 @@ function runTests() {
}
})) passed++; else failed++;
if (test('repair rejects a symlink inserted while creating a missing destination parent', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
const outsideRoot = createTempDir('install-lifecycle-outside-');
const targetRoot = path.join(projectRoot, '.cursor');
const destinationParent = path.join(targetRoot, 'late-parent');
const destinationPath = path.join(destinationParent, 'managed.md');
const outsideDestinationPath = path.join(outsideRoot, 'managed.md');
const originalMkdirSync = fs.mkdirSync;
let canonicalDestinationParent;
let insertedSymlink = false;
let result;
try {
writeCursorState(projectRoot, {
operations: [
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
],
});
canonicalDestinationParent = path.join(
fs.realpathSync(targetRoot),
path.basename(destinationParent)
);
fs.mkdirSync = function mkdirSyncWithLateSymlink(directoryPath, options) {
if (!insertedSymlink && path.resolve(directoryPath) === canonicalDestinationParent) {
originalMkdirSync.call(fs, path.dirname(canonicalDestinationParent), { recursive: true });
fs.symlinkSync(
outsideRoot,
canonicalDestinationParent,
process.platform === 'win32' ? 'junction' : 'dir'
);
insertedSymlink = true;
return undefined;
}
return originalMkdirSync.call(fs, directoryPath, options);
};
result = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['cursor'],
});
} finally {
fs.mkdirSync = originalMkdirSync;
}
try {
assert.strictEqual(insertedSymlink, true);
assert.strictEqual(result.results[0].status, 'error');
assert.ok(result.results[0].error.includes('outside the install root'));
assert.ok(!fs.existsSync(outsideDestinationPath));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
cleanup(outsideRoot);
}
})) passed++; else failed++;
if (test('repair rejects an in-root final symlink without overwriting its victim', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(projectRoot, '.cursor');
const victimPath = path.join(targetRoot, 'victim.md');
const destinationPath = path.join(targetRoot, 'managed.md');
fs.mkdirSync(targetRoot, { recursive: true });
fs.writeFileSync(victimPath, 'victim sentinel\n');
try {
fs.symlinkSync(victimPath, destinationPath);
} catch {
console.log(' (symlink unsupported on this platform; skipping)');
return;
}
writeCursorState(projectRoot, {
operations: [
managedOperation('render-template', destinationPath, {
renderedContent: 'managed replacement\n',
strategy: 'render-template',
}),
],
});
const result = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['cursor'],
});
assert.strictEqual(result.results[0].status, 'error');
assert.ok(result.results[0].error.includes('final symlink'));
assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), 'victim sentinel\n');
assert.strictEqual(fs.lstatSync(destinationPath).isSymbolicLink(), true);
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('repair uses no-follow writes when a final destination becomes a symlink', () => {
if (!fs.constants.O_NOFOLLOW) {
return;
}
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
const outsideRoot = createTempDir('install-lifecycle-outside-');
const targetRoot = path.join(projectRoot, '.cursor');
const destinationPath = path.join(targetRoot, 'managed.md');
const outsideDestinationPath = path.join(outsideRoot, 'managed.md');
const originalOpenSync = fs.openSync;
let canonicalDestinationPath;
let insertedSymlink = false;
let result;
try {
fs.writeFileSync(outsideDestinationPath, 'outside sentinel\n');
writeCursorState(projectRoot, {
operations: [
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
],
});
canonicalDestinationPath = path.join(
fs.realpathSync(targetRoot),
path.basename(destinationPath)
);
fs.openSync = function openSyncWithLateSymlink(filePath, flags, mode) {
if (!insertedSymlink && path.resolve(filePath) === canonicalDestinationPath) {
fs.symlinkSync(outsideDestinationPath, canonicalDestinationPath);
insertedSymlink = true;
}
return originalOpenSync.call(fs, filePath, flags, mode);
};
result = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['cursor'],
});
} finally {
fs.openSync = originalOpenSync;
}
try {
assert.strictEqual(insertedSymlink, true);
assert.strictEqual(result.results[0].status, 'error');
assert.strictEqual(
fs.readFileSync(outsideDestinationPath, 'utf8'),
'outside sentinel\n'
);
} finally {
cleanup(homeDir);
cleanup(projectRoot);
cleanup(outsideRoot);
}
})) passed++; else failed++;
if (test('repair refreshes only the adapter-derived install-state path', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
const outsideRoot = createTempDir('install-lifecycle-outside-');
try {
const targetRoot = path.join(projectRoot, '.cursor');
const adapterStatePath = path.join(targetRoot, 'ecc-install-state.json');
const recordedStatePath = path.join(outsideRoot, 'recorded-state.json');
const stateOptions = createCursorStateOptions(projectRoot, {
installStatePath: recordedStatePath,
});
writeState(adapterStatePath, stateOptions);
const result = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['cursor'],
});
assert.strictEqual(result.results[0].status, 'ok');
assert.ok(fs.existsSync(adapterStatePath));
assert.ok(!fs.existsSync(recordedStatePath));
const refreshedState = readInstallState(adapterStatePath);
assert.strictEqual(refreshedState.target.root, targetRoot);
assert.strictEqual(refreshedState.target.installStatePath, adapterStatePath);
} finally {
cleanup(homeDir);
cleanup(projectRoot);
cleanup(outsideRoot);
}
})) passed++; else failed++;
if (test('uninstall restores JSON merged files from recorded previous content', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
@@ -1565,6 +1918,40 @@ function runTests() {
}
})) passed++; else failed++;
if (test('uninstall removes only the adapter-derived install-state path', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
const outsideRoot = createTempDir('install-lifecycle-outside-');
try {
const targetRoot = path.join(projectRoot, '.cursor');
const adapterStatePath = path.join(targetRoot, 'ecc-install-state.json');
const recordedStatePath = path.join(outsideRoot, 'recorded-state.json');
const stateOptions = createCursorStateOptions(projectRoot, {
installStatePath: recordedStatePath,
});
writeState(adapterStatePath, stateOptions);
fs.writeFileSync(recordedStatePath, 'outside sentinel\n');
const result = uninstallInstalledStates({
homeDir,
projectRoot,
targets: ['cursor'],
});
assert.strictEqual(result.results[0].status, 'uninstalled');
assert.ok(!fs.existsSync(adapterStatePath));
assert.strictEqual(
fs.readFileSync(recordedStatePath, 'utf8'),
'outside sentinel\n'
);
} finally {
cleanup(homeDir);
cleanup(projectRoot);
cleanup(outsideRoot);
}
})) passed++; else failed++;
if (test('uninstall removes copied files and cleans empty parent directories', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
@@ -1597,6 +1984,42 @@ function runTests() {
}
})) passed++; else failed++;
if (test('uninstall cleanup stops at the adapter-derived target root', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const cleanupBoundaryRoot = createTempDir('install-lifecycle-boundary-');
const projectRoot = path.join(cleanupBoundaryRoot, 'project');
try {
const targetRoot = path.join(projectRoot, '.cursor');
const adapterStatePath = path.join(targetRoot, 'ecc-install-state.json');
const destinationPath = path.join(targetRoot, 'rules', 'nested', 'managed.md');
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.writeFileSync(destinationPath, 'managed\n');
const stateOptions = createCursorStateOptions(projectRoot, {
targetRoot: cleanupBoundaryRoot,
installStatePath: adapterStatePath,
operations: [
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
],
});
writeState(adapterStatePath, stateOptions);
const result = uninstallInstalledStates({
homeDir,
projectRoot,
targets: ['cursor'],
});
assert.strictEqual(result.results[0].status, 'uninstalled');
assert.ok(fs.existsSync(projectRoot));
assert.ok(fs.existsSync(targetRoot));
assert.ok(!fs.existsSync(destinationPath));
} finally {
cleanup(homeDir);
cleanup(cleanupBoundaryRoot);
}
})) passed++; else failed++;
if (test('uninstall handles merge-json subset removal and full-file deletion', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const partialProjectRoot = createTempDir('install-lifecycle-partial-');
@@ -1802,6 +2225,107 @@ function runTests() {
}
})) passed++; else failed++;
if (test('uninstall removes an in-root final symlink without deleting its victim', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(projectRoot, '.cursor');
const victimPath = path.join(targetRoot, 'victim.md');
const destinationPath = path.join(targetRoot, 'managed.md');
fs.mkdirSync(targetRoot, { recursive: true });
fs.writeFileSync(victimPath, 'victim sentinel\n');
try {
fs.symlinkSync(victimPath, destinationPath);
} catch {
console.log(' (symlink unsupported on this platform; skipping)');
return;
}
writeCursorState(projectRoot, {
operations: [
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
],
});
const result = uninstallInstalledStates({
homeDir,
projectRoot,
targets: ['cursor'],
});
assert.strictEqual(result.results[0].status, 'uninstalled');
assert.ok(!fs.existsSync(destinationPath));
assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), 'victim sentinel\n');
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('uninstall rejects a symlink inserted after initial destination validation', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
const outsideRoot = createTempDir('install-lifecycle-outside-');
const targetRoot = path.join(projectRoot, '.cursor');
const destinationParent = path.join(targetRoot, 'late-parent');
const backupParent = path.join(targetRoot, 'late-parent-backup');
const destinationPath = path.join(destinationParent, 'managed.md');
const outsideDestinationPath = path.join(outsideRoot, 'managed.md');
const originalExistsSync = fs.existsSync;
let canonicalDestinationParent;
let canonicalDestinationPath;
let insertedSymlink = false;
let result;
try {
fs.mkdirSync(destinationParent, { recursive: true });
fs.writeFileSync(destinationPath, 'managed\n');
fs.writeFileSync(outsideDestinationPath, 'outside sentinel\n');
writeCursorState(projectRoot, {
operations: [
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
],
});
canonicalDestinationPath = fs.realpathSync(destinationPath);
canonicalDestinationParent = path.dirname(canonicalDestinationPath);
fs.existsSync = function existsSyncWithLateSymlink(candidatePath) {
if (!insertedSymlink && path.resolve(candidatePath) === canonicalDestinationPath) {
fs.renameSync(canonicalDestinationParent, backupParent);
fs.symlinkSync(
outsideRoot,
canonicalDestinationParent,
process.platform === 'win32' ? 'junction' : 'dir'
);
insertedSymlink = true;
}
return originalExistsSync.call(fs, candidatePath);
};
result = uninstallInstalledStates({
homeDir,
projectRoot,
targets: ['cursor'],
});
} finally {
fs.existsSync = originalExistsSync;
}
try {
assert.strictEqual(insertedSymlink, true);
assert.strictEqual(result.results[0].status, 'error');
assert.ok(result.results[0].error.includes('outside the install root'));
assert.strictEqual(
fs.readFileSync(outsideDestinationPath, 'utf8'),
'outside sentinel\n'
);
} finally {
cleanup(homeDir);
cleanup(projectRoot);
cleanup(outsideRoot);
}
})) passed++; else failed++;
if (test('uninstall restores previous JSON snapshots for template and remove operations', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
+7
View File
@@ -60,6 +60,13 @@ function runTests() {
assert.strictEqual(parseHostHeader('bad:host:extra'), null);
})) passed++; else failed++;
if (test('rejects ports outside the valid TCP range', () => {
assert.strictEqual(parseHostHeader('localhost:65536'), null);
assert.strictEqual(parseHostHeader('localhost:99999'), null);
assert.strictEqual(parseHostHeader('[::1]:65536'), null);
assert.strictEqual(parseHostHeader('localhost:65535'), 'localhost');
})) passed++; else failed++;
console.log('\nbuildAllowedHostnames:');
if (test('always includes loopback names', () => {
+99 -1
View File
@@ -10,7 +10,11 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { assertWithinTrustedRoot, isWithinRoot } = require('../../scripts/lib/path-safety');
const {
assertWithinTrustedRoot,
isWithinRoot,
realpathNearestExisting
} = require('../../scripts/lib/path-safety');
let passed = 0;
let failed = 0;
@@ -43,12 +47,89 @@ try {
assert.strictEqual(isWithinRoot(root, root), true);
});
test('allows a non-existent destination beneath a non-existent trusted root', () => {
const futureRoot = path.join(root, 'future-root');
const futureDestination = path.join(futureRoot, 'session-data', 'session.json');
assert.strictEqual(isWithinRoot(futureDestination, futureRoot), true);
assert.strictEqual(
assertWithinTrustedRoot(futureDestination, futureRoot, 'write'),
realpathNearestExisting(futureDestination)
);
});
test('canonicalizes the nearest existing ancestor for a non-existent trusted root', () => {
const realParent = fs.mkdtempSync(path.join(os.tmpdir(), 'path-safety-real-'));
const linkedParent = path.join(
os.tmpdir(),
`path-safety-link-${process.pid}-${Date.now()}`
);
try {
fs.symlinkSync(realParent, linkedParent, 'dir');
} catch {
console.log(' (symlink unsupported on this platform; skipping)');
fs.rmSync(realParent, { recursive: true, force: true });
return;
}
try {
const futureRoot = path.join(linkedParent, '.cursor', 'ecc');
const futureDestination = path.join(futureRoot, 'session-data', 'session.json');
assert.strictEqual(isWithinRoot(futureDestination, futureRoot), true);
assert.strictEqual(
assertWithinTrustedRoot(futureDestination, futureRoot, 'write'),
path.join(
fs.realpathSync(realParent),
'.cursor',
'ecc',
'session-data',
'session.json'
)
);
} finally {
fs.rmSync(linkedParent, { force: true });
fs.rmSync(realParent, { recursive: true, force: true });
}
});
test('returns the same canonical destination that was checked for containment', () => {
const destination = path.join(root, 'single-canonicalization.txt');
const originalRealpathSync = fs.realpathSync;
let destinationCanonicalizations = 0;
fs.writeFileSync(destination, 'safe\n');
fs.realpathSync = function countedRealpathSync(candidatePath, options) {
if (path.resolve(candidatePath) === path.resolve(destination)) {
destinationCanonicalizations += 1;
}
return originalRealpathSync.call(fs, candidatePath, options);
};
try {
assert.strictEqual(
assertWithinTrustedRoot(destination, root, 'write'),
originalRealpathSync(destination)
);
} finally {
fs.realpathSync = originalRealpathSync;
}
assert.strictEqual(destinationCanonicalizations, 1);
});
test('refuses an absolute path outside the root', () => {
const evil = path.join(outside, 'PWNED.txt');
assert.throws(() => assertWithinTrustedRoot(evil, root, 'repair'), /outside the install root/);
assert.strictEqual(isWithinRoot(evil, root), false);
});
test('refuses an escape from a non-existent trusted root', () => {
const futureRoot = path.join(root, 'future-root');
const evil = path.join(futureRoot, '..', 'escape.txt');
assert.throws(() => assertWithinTrustedRoot(evil, futureRoot, 'write'), /outside the install root/);
assert.strictEqual(isWithinRoot(evil, futureRoot), false);
});
test('refuses a ../ traversal escape', () => {
const evil = path.join(root, '..', 'escape.txt');
assert.throws(() => assertWithinTrustedRoot(evil, root, 'uninstall'), /outside the install root/);
@@ -67,6 +148,23 @@ try {
assert.throws(() => assertWithinTrustedRoot(evil, root, 'repair'), /outside the install root/);
});
test('refuses a dangling symlinked intermediate directory', () => {
const danglingTarget = path.join(outside, 'missing-target');
const linkDir = path.join(root, 'dangling-link');
try {
fs.symlinkSync(danglingTarget, linkDir, 'dir');
} catch {
console.log(' (symlink unsupported on this platform; skipping)');
return;
}
const evil = path.join(linkDir, 'session-data', 'session.json');
assert.strictEqual(isWithinRoot(evil, root), false);
assert.throws(
() => assertWithinTrustedRoot(evil, root, 'write'),
/outside the install root/
);
});
test('refuses when no trusted root is resolved', () => {
assert.throws(() => assertWithinTrustedRoot(path.join(root, 'x'), null, 'repair'), /no trusted install root/);
});
+234 -41
View File
@@ -7,12 +7,14 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const http = require('http');
const net = require('net');
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'dashboard-web.js');
let testRoot;
let testPassed = 0;
let testFailed = 0;
const asyncTests = [];
function test(name, fn) {
try {
@@ -28,6 +30,10 @@ function test(name, fn) {
}
}
function asyncTest(name, fn) {
asyncTests.push({ name, fn });
}
function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
@@ -42,6 +48,81 @@ function writeFile(rootDir, relativePath, content) {
fs.writeFileSync(targetPath, content);
}
function requestDashboard(port, options = {}) {
return new Promise((resolve, reject) => {
const request = http.request({
host: '127.0.0.1',
port,
method: options.method || 'GET',
path: options.path || '/',
headers: options.headers || {},
setHost: options.setHost !== false,
}, (response) => {
let body = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
body += chunk;
});
response.on('end', () => {
resolve({
body,
headers: response.headers,
statusCode: response.statusCode,
});
});
});
request.on('error', reject);
request.end();
});
}
function requestDashboardWithoutHost(port) {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host: '127.0.0.1', port });
let raw = '';
socket.setEncoding('utf8');
socket.on('connect', () => {
socket.write('GET / HTTP/1.0\r\n\r\n');
});
socket.on('data', (chunk) => {
raw += chunk;
});
socket.on('end', () => {
const [head, body = ''] = raw.split('\r\n\r\n');
const lines = head.split('\r\n');
const statusCode = Number.parseInt(lines[0].split(' ')[1], 10);
const headers = {};
for (const line of lines.slice(1)) {
const separator = line.indexOf(':');
if (separator < 1) continue;
headers[line.slice(0, separator).toLowerCase()] = line.slice(separator + 1).trim();
}
resolve({ body, headers, statusCode });
});
socket.on('error', reject);
});
}
async function withDashboardServer(fn) {
const { createDashboardServer } = require(SCRIPT);
const testServer = createDashboardServer({ host: '127.0.0.1' });
await new Promise((resolve, reject) => {
testServer.once('error', reject);
testServer.listen(0, '127.0.0.1', () => {
testServer.off('error', reject);
resolve();
});
});
try {
await fn(testServer.address().port);
} finally {
await new Promise((resolve, reject) => {
testServer.close(error => (error ? reject(error) : resolve()));
});
}
}
// ===================== parsePort =====================
test('parsePort returns 3456 for undefined', () => {
@@ -661,49 +742,145 @@ test('renderHTML includes the dashboard title and footer', () => {
// ===================== Server / HTTP =====================
test('server returns HTML on GET /', (done) => {
const { server } = require(SCRIPT);
// Server may or may not be listening — we start it on a random port
const testServer = http.createServer(server._events.request);
testServer.listen(0, () => {
const port = testServer.address().port;
http.get(`http://localhost:${port}/`, (res) => {
assert.strictEqual(res.statusCode, 200);
assert.strictEqual(res.headers['content-type'], 'text/html; charset=utf-8');
let body = '';
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => {
assert.ok(body.includes('<!DOCTYPE html>'));
assert.ok(body.includes('ECC Capabilities'));
testServer.close();
done();
});
});
test('resolveDashboardHost defaults to IPv4 loopback', () => {
const { resolveDashboardHost } = require(SCRIPT);
assert.strictEqual(resolveDashboardHost({}), '127.0.0.1');
assert.strictEqual(resolveDashboardHost({ ECC_DASHBOARD_HOST: '' }), '127.0.0.1');
});
test('resolveDashboardHost accepts only normalized loopback hosts', () => {
const { resolveDashboardHost } = require(SCRIPT);
assert.strictEqual(
resolveDashboardHost({ ECC_DASHBOARD_HOST: ' LOCALHOST ' }),
'localhost'
);
assert.strictEqual(
resolveDashboardHost({ ECC_DASHBOARD_HOST: '::1' }),
'::1'
);
assert.strictEqual(
resolveDashboardHost({ ECC_DASHBOARD_HOST: '[::1]' }),
'::1'
);
});
test('resolveDashboardHost rejects wildcard, LAN, and arbitrary hosts', () => {
const { resolveDashboardHost } = require(SCRIPT);
for (const host of ['0.0.0.0', '::', '192.168.1.10', 'dashboard.internal', '127.0.0.1:3456']) {
assert.throws(
() => resolveDashboardHost({ ECC_DASHBOARD_HOST: host }),
/ECC_DASHBOARD_HOST must be loopback-only/
);
}
});
test('listenDashboardServer always passes an explicit loopback host to listen', () => {
const { listenDashboardServer } = require(SCRIPT);
const calls = [];
const fakeServer = {
listen(...args) {
calls.push(args);
return this;
},
};
const onListening = () => {};
assert.strictEqual(
listenDashboardServer(fakeServer, {
host: '127.0.0.1',
onListening,
port: 3456,
}),
fakeServer
);
assert.deepStrictEqual(calls, [[3456, '127.0.0.1', onListening]]);
assert.throws(
() => listenDashboardServer(fakeServer, { host: '0.0.0.0', port: 3456 }),
/ECC_DASHBOARD_HOST must be loopback-only/
);
assert.strictEqual(calls.length, 1);
});
asyncTest('server returns no-store HTML on GET /', async () => {
await withDashboardServer(async (port) => {
const response = await requestDashboard(port);
assert.strictEqual(response.statusCode, 200);
assert.strictEqual(response.headers['content-type'], 'text/html; charset=utf-8');
assert.strictEqual(response.headers['cache-control'], 'no-store');
assert.ok(response.body.includes('<!DOCTYPE html>'));
assert.ok(response.body.includes('ECC Capabilities'));
});
});
test('server returns JSON on GET /api/data', (done) => {
const { server } = require(SCRIPT);
const testServer = http.createServer(server._events.request);
testServer.listen(0, () => {
const port = testServer.address().port;
http.get(`http://localhost:${port}/api/data`, (res) => {
assert.strictEqual(res.statusCode, 200);
assert.strictEqual(res.headers['content-type'], 'application/json');
let body = '';
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => {
const parsed = JSON.parse(body);
assert.ok(Array.isArray(parsed.agents));
assert.ok(Array.isArray(parsed.skills));
assert.ok(Array.isArray(parsed.commands));
assert.ok(Array.isArray(parsed.rules));
assert.ok(Array.isArray(parsed.mcps));
assert.ok(Array.isArray(parsed.hooks));
testServer.close();
done();
});
asyncTest('server returns no-store JSON on GET /api/data', async () => {
await withDashboardServer(async (port) => {
const response = await requestDashboard(port, { path: '/api/data' });
assert.strictEqual(response.statusCode, 200);
assert.strictEqual(response.headers['content-type'], 'application/json');
assert.strictEqual(response.headers['cache-control'], 'no-store');
const parsed = JSON.parse(response.body);
assert.ok(Array.isArray(parsed.agents));
assert.ok(Array.isArray(parsed.skills));
assert.ok(Array.isArray(parsed.commands));
assert.ok(Array.isArray(parsed.rules));
assert.ok(Array.isArray(parsed.mcps));
assert.ok(Array.isArray(parsed.hooks));
});
});
asyncTest('server rejects a missing or DNS-rebinding Host before routing', async () => {
await withDashboardServer(async (port) => {
const missingHost = await requestDashboardWithoutHost(port);
assert.strictEqual(missingHost.statusCode, 421);
assert.strictEqual(missingHost.headers['cache-control'], 'no-store');
const reboundHost = await requestDashboard(port, {
headers: { Host: 'dashboard.attacker.example' },
path: '/api/data',
});
assert.strictEqual(reboundHost.statusCode, 421);
assert.strictEqual(reboundHost.headers['cache-control'], 'no-store');
});
});
asyncTest('server rejects an allowed hostname with an invalid port without crashing', async () => {
await withDashboardServer(async (port) => {
const response = await requestDashboard(port, {
headers: { Host: 'localhost:99999' },
path: '/api/data',
});
assert.strictEqual(response.statusCode, 421);
assert.strictEqual(response.headers['cache-control'], 'no-store');
});
});
asyncTest('server returns a generic no-store 400 for a malformed absolute request target and remains usable', async () => {
await withDashboardServer(async (port) => {
const malformedResponse = await requestDashboard(port, {
path: 'http://attacker.example:99999/',
});
assert.strictEqual(malformedResponse.statusCode, 400);
assert.strictEqual(malformedResponse.headers['cache-control'], 'no-store');
assert.deepStrictEqual(JSON.parse(malformedResponse.body), {
error: 'Bad request',
});
const followUpResponse = await requestDashboard(port, {
path: '/api/data',
});
assert.strictEqual(followUpResponse.statusCode, 200);
assert.strictEqual(followUpResponse.headers['cache-control'], 'no-store');
});
});
asyncTest('server rejects cross-origin requests before routing', async () => {
await withDashboardServer(async (port) => {
const response = await requestDashboard(port, {
headers: { Origin: 'https://attacker.example' },
path: '/api/data',
});
assert.strictEqual(response.statusCode, 403);
assert.strictEqual(response.headers['cache-control'], 'no-store');
});
});
@@ -793,5 +970,21 @@ test('loadMcps handles empty mcp-configs directory', () => {
// ===================== Results =====================
console.log(`\nResults: Passed: ${testPassed}, Failed: ${testFailed}`);
process.exit(testFailed > 0 ? 1 : 0);
async function runAsyncTests() {
for (const { name, fn } of asyncTests) {
try {
await fn();
console.log(`${name}`);
testPassed++;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
testFailed++;
}
}
console.log(`\nResults: Passed: ${testPassed}, Failed: ${testFailed}`);
process.exitCode = testFailed > 0 ? 1 : 0;
}
runAsyncTests();