mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-30 13:35:12 +02:00
fix: close dashboard hardening review gaps
This commit is contained in:
@@ -834,7 +834,25 @@ function loadDashboardData(root) {
|
||||
};
|
||||
}
|
||||
|
||||
function createDashboardServer({ root = ROOT, host = HOST } = {}) {
|
||||
function defaultReportError(message, error) {
|
||||
console.error(message, error);
|
||||
}
|
||||
|
||||
function reportDashboardFailure(reportError, message, error) {
|
||||
try {
|
||||
reportError(message, error);
|
||||
} catch {
|
||||
// Error reporting must never prevent the generic HTTP response.
|
||||
}
|
||||
}
|
||||
|
||||
function createDashboardServer({
|
||||
root = ROOT,
|
||||
host = HOST,
|
||||
loadData = loadDashboardData,
|
||||
render = renderHTML,
|
||||
reportError = defaultReportError,
|
||||
} = {}) {
|
||||
const resolvedHost = resolveDashboardHost({ ECC_DASHBOARD_HOST: host });
|
||||
const allowedHostnames = buildAllowedHostnames(resolvedHost);
|
||||
|
||||
@@ -854,9 +872,36 @@ function createDashboardServer({ root = ROOT, host = HOST } = {}) {
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/data') {
|
||||
return sendJson(res, 200, loadDashboardData(root));
|
||||
let data;
|
||||
try {
|
||||
data = loadData(root);
|
||||
} catch (error) {
|
||||
reportDashboardFailure(
|
||||
reportError,
|
||||
'[ECC] Failed to load dashboard data:',
|
||||
error
|
||||
);
|
||||
return sendJson(res, 500, { error: 'Internal server error' });
|
||||
}
|
||||
return sendJson(res, 200, data);
|
||||
}
|
||||
return sendHtml(res, 200, renderHTML(loadDashboardData(root)));
|
||||
|
||||
let html;
|
||||
try {
|
||||
html = render(loadData(root));
|
||||
} catch (error) {
|
||||
reportDashboardFailure(
|
||||
reportError,
|
||||
'[ECC] Failed to render dashboard:',
|
||||
error
|
||||
);
|
||||
return sendHtml(
|
||||
res,
|
||||
500,
|
||||
'<!DOCTYPE html><p>Dashboard unavailable.</p>'
|
||||
);
|
||||
}
|
||||
return sendHtml(res, 200, html);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -98,8 +98,8 @@ function getDefaultClaudeAgentDataHome() {
|
||||
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.'
|
||||
'within the default Cursor or Claude data directories. Use ' +
|
||||
'ECC_AGENT_DATA_HOME for an explicit trusted override.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,6 +110,26 @@ function isSafeProjectConfigSyntax(candidate) {
|
||||
return isUserAnchored && !hasParentTraversal;
|
||||
}
|
||||
|
||||
function resolveAllowedProjectConfigHome(candidate) {
|
||||
const allowedRoots = [
|
||||
getDefaultCursorAgentDataHome(),
|
||||
getDefaultClaudeAgentDataHome(),
|
||||
];
|
||||
|
||||
for (const allowedRoot of allowedRoots) {
|
||||
try {
|
||||
return assertWithinTrustedRoot(
|
||||
candidate,
|
||||
allowedRoot,
|
||||
'use project agent data home'
|
||||
);
|
||||
} catch {
|
||||
// Try the next explicitly allowed default root.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readProjectConfigAt(configPath) {
|
||||
if (!configPath || typeof configPath !== 'string') return null;
|
||||
if (!fs.existsSync(configPath)) return null;
|
||||
@@ -125,16 +145,12 @@ function readProjectConfigAt(configPath) {
|
||||
}
|
||||
const projectRoot = resolveProjectRootFromConfigPath(configPath);
|
||||
const resolved = expandHomePath(candidate, projectRoot);
|
||||
try {
|
||||
return assertWithinTrustedRoot(
|
||||
resolved,
|
||||
getDefaultCursorAgentDataHome(),
|
||||
'use project agent data home'
|
||||
);
|
||||
} catch {
|
||||
const allowedHome = resolveAllowedProjectConfigHome(resolved);
|
||||
if (!allowedHome) {
|
||||
warnUnsafeProjectConfig();
|
||||
return null;
|
||||
}
|
||||
return allowedHome;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[ECC] Failed to read or parse agent data config at ${configPath}: ${error.message}`
|
||||
|
||||
@@ -329,14 +329,69 @@ function getContainedExistingPath(
|
||||
return finalDestination.exists ? finalDestination.managedPath : null;
|
||||
}
|
||||
|
||||
function writeFileNoFollow(filePath, content, mode) {
|
||||
function hasSameFileIdentity(leftStat, rightStat) {
|
||||
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
|
||||
}
|
||||
|
||||
function createChangedDestinationError(action) {
|
||||
return new Error(
|
||||
`Refusing to ${action}: managed destination changed during the write.`
|
||||
);
|
||||
}
|
||||
|
||||
function getStableParentStat(filePath, action) {
|
||||
const parentStat = fs.lstatSync(path.dirname(filePath));
|
||||
if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) {
|
||||
throw createChangedDestinationError(action);
|
||||
}
|
||||
return parentStat;
|
||||
}
|
||||
|
||||
function assertPinnedWriteDestination(
|
||||
filePath,
|
||||
fileDescriptor,
|
||||
expectedParentStat,
|
||||
trustedRoot,
|
||||
action
|
||||
) {
|
||||
const liveDestination = getManagedDestination(filePath, trustedRoot, action);
|
||||
if (path.resolve(liveDestination.managedPath) !== path.resolve(filePath)) {
|
||||
throw createChangedDestinationError(action);
|
||||
}
|
||||
|
||||
const liveParentStat = getStableParentStat(filePath, action);
|
||||
if (!hasSameFileIdentity(expectedParentStat, liveParentStat)) {
|
||||
throw createChangedDestinationError(action);
|
||||
}
|
||||
|
||||
const descriptorStat = fs.fstatSync(fileDescriptor);
|
||||
const livePathStat = fs.lstatSync(liveDestination.managedPath);
|
||||
if (
|
||||
!descriptorStat.isFile()
|
||||
|| !livePathStat.isFile()
|
||||
|| livePathStat.isSymbolicLink()
|
||||
|| !hasSameFileIdentity(descriptorStat, livePathStat)
|
||||
) {
|
||||
throw createChangedDestinationError(action);
|
||||
}
|
||||
}
|
||||
|
||||
function writeFileNoFollow(filePath, content, mode, trustedRoot, action) {
|
||||
const expectedParentStat = getStableParentStat(filePath, action);
|
||||
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 {
|
||||
assertPinnedWriteDestination(
|
||||
filePath,
|
||||
fileDescriptor,
|
||||
expectedParentStat,
|
||||
trustedRoot,
|
||||
action
|
||||
);
|
||||
fs.ftruncateSync(fileDescriptor, 0);
|
||||
fs.writeFileSync(fileDescriptor, content);
|
||||
if (mode !== undefined) {
|
||||
fs.fchmodSync(fileDescriptor, mode);
|
||||
@@ -379,7 +434,13 @@ function writeContainedFile(destinationPath, content, trustedRoot, action, mode)
|
||||
trustedRoot,
|
||||
action
|
||||
).managedPath;
|
||||
writeFileNoFollow(finalDestination, content, mode);
|
||||
writeFileNoFollow(
|
||||
finalDestination,
|
||||
content,
|
||||
mode,
|
||||
trustedRoot,
|
||||
action
|
||||
);
|
||||
return finalDestination;
|
||||
}
|
||||
|
||||
@@ -938,6 +999,27 @@ function getUnsafeManagedDestinationError(operationHealth) {
|
||||
return 'Refusing unsafe managed destination outside adapter-derived install root.';
|
||||
}
|
||||
|
||||
function getUnsafeOperationResult(record, operationHealth) {
|
||||
const error = operationHealth.unsafeDestination.length > 0
|
||||
? getUnsafeManagedDestinationError(operationHealth)
|
||||
: operationHealth.unsafeSource.length > 0
|
||||
? createUnsafeRepairSourceError().message
|
||||
: null;
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'error',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs: [],
|
||||
stateRefreshed: false,
|
||||
error
|
||||
};
|
||||
}
|
||||
|
||||
function buildDiscoveryRecord(adapter, context) {
|
||||
const installTargetInput = {
|
||||
homeDir: context.homeDir,
|
||||
@@ -1346,27 +1428,12 @@ function repairInstalledStates(options = {}) {
|
||||
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 unsafeOperationResult = getUnsafeOperationResult(
|
||||
record,
|
||||
operationHealth
|
||||
);
|
||||
if (unsafeOperationResult) {
|
||||
return unsafeOperationResult;
|
||||
}
|
||||
const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))];
|
||||
const plannedRepairs = [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)];
|
||||
@@ -1404,28 +1471,12 @@ function repairInstalledStates(options = {}) {
|
||||
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 unsafeOperationResult = getUnsafeOperationResult(
|
||||
record,
|
||||
operationHealth
|
||||
);
|
||||
if (unsafeOperationResult) {
|
||||
return unsafeOperationResult;
|
||||
}
|
||||
|
||||
if (operationHealth.missingSource.length > 0) {
|
||||
|
||||
@@ -192,6 +192,49 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('allows the documented ~/.claude project sharing root and its descendants', () => {
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-claude-'));
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-claude-user-'));
|
||||
const configPath = path.join(projectDir, '.cursor', 'ecc-agent-data.json');
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true });
|
||||
|
||||
try {
|
||||
withEnv({
|
||||
ECC_AGENT_DATA_HOME: undefined,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: undefined,
|
||||
}, () => {
|
||||
const agentDataHome = require('../../scripts/lib/agent-data-home');
|
||||
const cases = [
|
||||
{
|
||||
candidate: '~/.claude',
|
||||
expected: path.join(fs.realpathSync(homeDir), '.claude'),
|
||||
},
|
||||
{
|
||||
candidate: '~/.claude/shared',
|
||||
expected: path.join(fs.realpathSync(homeDir), '.claude', 'shared'),
|
||||
},
|
||||
];
|
||||
|
||||
for (const { candidate, expected } of cases) {
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ agentDataHome: candidate }),
|
||||
'utf8'
|
||||
);
|
||||
assert.strictEqual(
|
||||
agentDataHome.readProjectConfigAt(configPath),
|
||||
expected
|
||||
);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
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}`);
|
||||
@@ -267,7 +310,7 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects traversal and absolute project config paths outside the Cursor data root', () => {
|
||||
if (test('rejects traversal and absolute project config paths outside the allowed data roots', () => {
|
||||
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');
|
||||
@@ -284,6 +327,11 @@ function runTests() {
|
||||
'../../repo-data',
|
||||
path.join(projectDir, 'absolute-data'),
|
||||
'~/.cursor/ecc/profiles/../traversed-data',
|
||||
'~/.claude/profiles/../traversed-data',
|
||||
'~/.claude-other',
|
||||
'~/.config/ecc',
|
||||
'~',
|
||||
path.join(homeDir, 'arbitrary-agent-data'),
|
||||
];
|
||||
for (const candidate of unsafeCandidates) {
|
||||
fs.writeFileSync(configPath, JSON.stringify({ agentDataHome: candidate }), 'utf8');
|
||||
@@ -301,6 +349,46 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects a Claude project config destination that escapes through a symlink', () => {
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-claude-link-'));
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-claude-link-user-'));
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-claude-link-outside-'));
|
||||
const configPath = path.join(projectDir, '.cursor', 'ecc-agent-data.json');
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
const linkPath = path.join(claudeRoot, 'redirect');
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.mkdirSync(claudeRoot, { 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++;
|
||||
|
||||
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-'));
|
||||
|
||||
@@ -1619,6 +1619,7 @@ function runTests() {
|
||||
|
||||
if (test('repair uses no-follow writes when a final destination becomes a symlink', () => {
|
||||
if (!fs.constants.O_NOFOLLOW) {
|
||||
console.log(' (O_NOFOLLOW unsupported on this platform; skipping)');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1677,6 +1678,92 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('repair revalidates a pinned write before a swapped parent can truncate outside files', () => {
|
||||
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 originalOpenSync = fs.openSync;
|
||||
let canonicalDestinationPath;
|
||||
let insertedSymlink = false;
|
||||
let result;
|
||||
|
||||
const symlinkProbe = path.join(targetRoot, 'parent-symlink-probe');
|
||||
try {
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
fs.symlinkSync(
|
||||
outsideRoot,
|
||||
symlinkProbe,
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
);
|
||||
fs.rmSync(symlinkProbe, { force: true });
|
||||
} catch {
|
||||
console.log(' (symlink unsupported on this platform; skipping)');
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
cleanup(outsideRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(destinationParent, { recursive: true });
|
||||
fs.writeFileSync(destinationPath, 'drifted managed content\n');
|
||||
fs.writeFileSync(outsideDestinationPath, 'outside sentinel\n');
|
||||
canonicalDestinationPath = fs.realpathSync(destinationPath);
|
||||
writeCursorState(projectRoot, {
|
||||
operations: [
|
||||
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
|
||||
],
|
||||
});
|
||||
|
||||
fs.openSync = function openSyncWithLateParentSwap(filePath, flags, mode) {
|
||||
const isDestinationWrite = path.resolve(filePath) === canonicalDestinationPath
|
||||
&& typeof flags === 'number'
|
||||
&& (flags & fs.constants.O_WRONLY) === fs.constants.O_WRONLY;
|
||||
if (!insertedSymlink && isDestinationWrite) {
|
||||
fs.renameSync(destinationParent, backupParent);
|
||||
fs.symlinkSync(
|
||||
outsideRoot,
|
||||
destinationParent,
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
);
|
||||
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'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(path.join(backupParent, 'managed.md'), 'utf8'),
|
||||
'drifted managed content\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-');
|
||||
@@ -1690,6 +1777,7 @@ function runTests() {
|
||||
installStatePath: recordedStatePath,
|
||||
});
|
||||
writeState(adapterStatePath, stateOptions);
|
||||
fs.writeFileSync(recordedStatePath, 'outside sentinel\n');
|
||||
|
||||
const result = repairInstalledStates({
|
||||
repoRoot: REPO_ROOT,
|
||||
@@ -1700,7 +1788,10 @@ function runTests() {
|
||||
|
||||
assert.strictEqual(result.results[0].status, 'ok');
|
||||
assert.ok(fs.existsSync(adapterStatePath));
|
||||
assert.ok(!fs.existsSync(recordedStatePath));
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(recordedStatePath, 'utf8'),
|
||||
'outside sentinel\n'
|
||||
);
|
||||
const refreshedState = readInstallState(adapterStatePath);
|
||||
assert.strictEqual(refreshedState.target.root, targetRoot);
|
||||
assert.strictEqual(refreshedState.target.installStatePath, adapterStatePath);
|
||||
|
||||
@@ -15,6 +15,7 @@ let testRoot;
|
||||
let testPassed = 0;
|
||||
let testFailed = 0;
|
||||
const asyncTests = [];
|
||||
const REQUEST_TIMEOUT_MS = 5000;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
@@ -50,7 +51,23 @@ function writeFile(rootDir, relativePath, content) {
|
||||
|
||||
function requestDashboard(port, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = http.request({
|
||||
let settled = false;
|
||||
let request;
|
||||
const settle = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
callback(value);
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
const error = new Error(
|
||||
`Dashboard request timed out after ${REQUEST_TIMEOUT_MS}ms`
|
||||
);
|
||||
if (request) request.destroy();
|
||||
settle(reject, error);
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
|
||||
request = http.request({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
method: options.method || 'GET',
|
||||
@@ -63,15 +80,16 @@ function requestDashboard(port, options = {}) {
|
||||
response.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
response.on('error', error => settle(reject, error));
|
||||
response.on('end', () => {
|
||||
resolve({
|
||||
settle(resolve, {
|
||||
body,
|
||||
headers: response.headers,
|
||||
statusCode: response.statusCode,
|
||||
});
|
||||
});
|
||||
});
|
||||
request.on('error', reject);
|
||||
request.on('error', error => settle(reject, error));
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
@@ -80,6 +98,21 @@ function requestDashboardWithoutHost(port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({ host: '127.0.0.1', port });
|
||||
let raw = '';
|
||||
let settled = false;
|
||||
const settle = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
callback(value);
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
const error = new Error(
|
||||
`Host-less request timed out after ${REQUEST_TIMEOUT_MS}ms`
|
||||
);
|
||||
socket.destroy();
|
||||
settle(reject, error);
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
|
||||
socket.setEncoding('utf8');
|
||||
socket.on('connect', () => {
|
||||
socket.write('GET / HTTP/1.0\r\n\r\n');
|
||||
@@ -97,15 +130,23 @@ function requestDashboardWithoutHost(port) {
|
||||
if (separator < 1) continue;
|
||||
headers[line.slice(0, separator).toLowerCase()] = line.slice(separator + 1).trim();
|
||||
}
|
||||
resolve({ body, headers, statusCode });
|
||||
settle(resolve, { body, headers, statusCode });
|
||||
});
|
||||
socket.on('error', error => settle(reject, error));
|
||||
socket.on('close', hadError => {
|
||||
if (!hadError && !settled) {
|
||||
settle(reject, new Error('Host-less request closed before completion'));
|
||||
}
|
||||
});
|
||||
socket.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function withDashboardServer(fn) {
|
||||
async function withDashboardServer(fn, serverOptions = {}) {
|
||||
const { createDashboardServer } = require(SCRIPT);
|
||||
const testServer = createDashboardServer({ host: '127.0.0.1' });
|
||||
const testServer = createDashboardServer({
|
||||
host: '127.0.0.1',
|
||||
...serverOptions,
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
testServer.once('error', reject);
|
||||
testServer.listen(0, '127.0.0.1', () => {
|
||||
@@ -828,6 +869,89 @@ asyncTest('server returns no-store JSON on GET /api/data', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
asyncTest('server returns a generic no-store 500 for data failures and remains usable', async () => {
|
||||
let loadCount = 0;
|
||||
const loggedErrors = [];
|
||||
const emptyData = {
|
||||
agents: [],
|
||||
skills: [],
|
||||
commands: [],
|
||||
rules: [],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
};
|
||||
|
||||
await withDashboardServer(async (port) => {
|
||||
const failedResponse = await requestDashboard(port, { path: '/api/data' });
|
||||
assert.strictEqual(failedResponse.statusCode, 500);
|
||||
assert.strictEqual(failedResponse.headers['cache-control'], 'no-store');
|
||||
assert.deepStrictEqual(JSON.parse(failedResponse.body), {
|
||||
error: 'Internal server error',
|
||||
});
|
||||
assert.ok(!failedResponse.body.includes('sensitive loader detail'));
|
||||
|
||||
const followUpResponse = await requestDashboard(port, { path: '/api/data' });
|
||||
assert.strictEqual(followUpResponse.statusCode, 200);
|
||||
assert.deepStrictEqual(JSON.parse(followUpResponse.body), emptyData);
|
||||
assert.strictEqual(loggedErrors.length, 1);
|
||||
assert.strictEqual(loggedErrors[0].error.message, 'sensitive loader detail');
|
||||
}, {
|
||||
loadData: () => {
|
||||
loadCount++;
|
||||
if (loadCount === 1) {
|
||||
throw new Error('sensitive loader detail');
|
||||
}
|
||||
return emptyData;
|
||||
},
|
||||
reportError: (message, error) => {
|
||||
loggedErrors.push({ error, message });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
asyncTest('server returns a generic no-store 500 for render failures and remains usable', async () => {
|
||||
const { renderHTML } = require(SCRIPT);
|
||||
let renderCount = 0;
|
||||
const loggedErrors = [];
|
||||
const emptyData = {
|
||||
agents: [],
|
||||
skills: [],
|
||||
commands: [],
|
||||
rules: [],
|
||||
mcps: [],
|
||||
hooks: [],
|
||||
};
|
||||
|
||||
await withDashboardServer(async (port) => {
|
||||
const failedResponse = await requestDashboard(port);
|
||||
assert.strictEqual(failedResponse.statusCode, 500);
|
||||
assert.strictEqual(failedResponse.headers['cache-control'], 'no-store');
|
||||
assert.strictEqual(
|
||||
failedResponse.body,
|
||||
'<!DOCTYPE html><p>Dashboard unavailable.</p>'
|
||||
);
|
||||
assert.ok(!failedResponse.body.includes('sensitive render detail'));
|
||||
|
||||
const followUpResponse = await requestDashboard(port);
|
||||
assert.strictEqual(followUpResponse.statusCode, 200);
|
||||
assert.ok(followUpResponse.body.includes('ECC Capabilities'));
|
||||
assert.strictEqual(loggedErrors.length, 1);
|
||||
assert.strictEqual(loggedErrors[0].error.message, 'sensitive render detail');
|
||||
}, {
|
||||
loadData: () => emptyData,
|
||||
render: (data) => {
|
||||
renderCount++;
|
||||
if (renderCount === 1) {
|
||||
throw new Error('sensitive render detail');
|
||||
}
|
||||
return renderHTML(data);
|
||||
},
|
||||
reportError: (message, error) => {
|
||||
loggedErrors.push({ error, message });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
asyncTest('server rejects a missing or DNS-rebinding Host before routing', async () => {
|
||||
await withDashboardServer(async (port) => {
|
||||
const missingHost = await requestDashboardWithoutHost(port);
|
||||
|
||||
Reference in New Issue
Block a user