diff --git a/scripts/dashboard-web.js b/scripts/dashboard-web.js index dfaad4e4b..b3d7a7879 100644 --- a/scripts/dashboard-web.js +++ b/scripts/dashboard-web.js @@ -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, + '
Dashboard unavailable.
' + ); + } + return sendHtml(res, 200, html); }); } diff --git a/scripts/lib/agent-data-home.js b/scripts/lib/agent-data-home.js index 0032beb15..7302bd572 100644 --- a/scripts/lib/agent-data-home.js +++ b/scripts/lib/agent-data-home.js @@ -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}` diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index a02eb0d52..77e10e45d 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -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) { diff --git a/tests/lib/agent-data-home.test.js b/tests/lib/agent-data-home.test.js index 569b01324..f5f72fc35 100644 --- a/tests/lib/agent-data-home.test.js +++ b/tests/lib/agent-data-home.test.js @@ -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-')); diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index 19cfbf819..81d191f22 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -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); diff --git a/tests/scripts/dashboard-web.test.js b/tests/scripts/dashboard-web.test.js index 20e9b549f..5cdd7206b 100644 --- a/tests/scripts/dashboard-web.test.js +++ b/tests/scripts/dashboard-web.test.js @@ -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, + 'Dashboard unavailable.
' + ); + 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);