b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
`;
}
+// Browsers omit the :80 suffix on the default HTTP port, so a server on
+// --port 80 sees bare loopback hosts and origins.
+function allowedHost(host, port) {
+ if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
+ return port === 80 && (host === '127.0.0.1' || host === 'localhost');
+}
+
+function allowedOrigin(origin, port) {
+ if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
+ return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
+}
+
+function rejectDetachedPost(req, res, url, port) {
+ if (detachedKey && url.searchParams.get('key') !== detachedKey) {
+ res.writeHead(401); res.end(); return true;
+ }
+ const origin = req.headers.origin;
+ if (origin && !allowedOrigin(origin, port)) {
+ res.writeHead(403); res.end(); return true;
+ }
+ return false;
+}
+
const server = http.createServer((req, res) => {
- if (req.method === 'GET' && req.url === '/') {
+ const { port } = server.address();
+ if (!allowedHost(req.headers.host, port)) {
+ res.writeHead(403); res.end(); return;
+ }
+ let url;
+ try { url = new URL(req.url, 'http://127.0.0.1'); }
+ catch { res.writeHead(400); res.end(); return; }
+ const pathname = url.pathname;
+ if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
- if (req.method === 'POST' && req.url === '/heartbeat') {
+ if (req.method === 'POST' && pathname === '/heartbeat') {
+ if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
- if (req.method === 'GET' && req.url === '/next-status') {
+ if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
- const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
+ const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
- if (req.method === 'POST' && req.url === '/build-path') {
+ if (req.method === 'POST' && pathname === '/build-path') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
- if (req.method === 'POST' && req.url === '/answer') {
+ if (req.method === 'POST' && pathname === '/answer') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
diff --git a/.claude/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs b/.claude/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
index 5e3d5446b..f3ff43c84 100644
--- a/.claude/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
+++ b/.claude/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
-async function detectUrl(url, options = {}) {
+function decodeUrlComponent(value) {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function splitScanUrl(url) {
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return { href: url, credentials: null };
+ }
+ if (!parsed.username && !parsed.password) {
+ return { href: url, credentials: null };
+ }
+ const credentials =
+ parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ ? {
+ username: decodeUrlComponent(parsed.username),
+ password: decodeUrlComponent(parsed.password),
+ }
+ : null;
+ parsed.username = '';
+ parsed.password = '';
+ return { href: parsed.href, credentials };
+}
+
+function basicAuthHeader(credentials) {
+ return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
+}
+
+// page.authenticate is page-wide: a cross-origin redirect that then 401s
+// would receive these credentials. Attach Authorization only to the scan origin.
+async function applyOriginScopedAuth(page, href, credentials) {
+ if (!credentials) return;
+ let origin = '';
+ try {
+ origin = new URL(href).origin;
+ } catch {
+ return;
+ }
+ if (!origin) return;
+ const header = basicAuthHeader(credentials);
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ let headers;
+ try {
+ if (new URL(request.url()).origin === origin) {
+ headers = { ...request.headers(), authorization: header };
+ }
+ } catch {
+ // invalid request URL: continue without auth
+ }
+ void request.continue(headers ? { headers } : undefined).catch(() => {});
+ });
+}
+
+async function detectUrl(rawUrl, options = {}) {
+ const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
+ await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
-export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
+export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
diff --git a/.claude/skills/impeccable/scripts/serve-question.mjs b/.claude/skills/impeccable/scripts/serve-question.mjs
index 7c4ff0812..e08052c48 100644
--- a/.claude/skills/impeccable/scripts/serve-question.mjs
+++ b/.claude/skills/impeccable/scripts/serve-question.mjs
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `
{ try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
+ const KEY = ${JSON.stringify(detachedKey || '')};
+ const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
+ const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `
{
set(value);
- fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
+ fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `
`;
}
+// Browsers omit the :80 suffix on the default HTTP port, so a server on
+// --port 80 sees bare loopback hosts and origins.
+function allowedHost(host, port) {
+ if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
+ return port === 80 && (host === '127.0.0.1' || host === 'localhost');
+}
+
+function allowedOrigin(origin, port) {
+ if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
+ return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
+}
+
+function rejectDetachedPost(req, res, url, port) {
+ if (detachedKey && url.searchParams.get('key') !== detachedKey) {
+ res.writeHead(401); res.end(); return true;
+ }
+ const origin = req.headers.origin;
+ if (origin && !allowedOrigin(origin, port)) {
+ res.writeHead(403); res.end(); return true;
+ }
+ return false;
+}
+
const server = http.createServer((req, res) => {
- if (req.method === 'GET' && req.url === '/') {
+ const { port } = server.address();
+ if (!allowedHost(req.headers.host, port)) {
+ res.writeHead(403); res.end(); return;
+ }
+ let url;
+ try { url = new URL(req.url, 'http://127.0.0.1'); }
+ catch { res.writeHead(400); res.end(); return; }
+ const pathname = url.pathname;
+ if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
- if (req.method === 'POST' && req.url === '/heartbeat') {
+ if (req.method === 'POST' && pathname === '/heartbeat') {
+ if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
- if (req.method === 'GET' && req.url === '/next-status') {
+ if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
- const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
+ const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
- if (req.method === 'POST' && req.url === '/build-path') {
+ if (req.method === 'POST' && pathname === '/build-path') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
- if (req.method === 'POST' && req.url === '/answer') {
+ if (req.method === 'POST' && pathname === '/answer') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
diff --git a/.cursor/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs b/.cursor/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
index 5e3d5446b..f3ff43c84 100644
--- a/.cursor/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
+++ b/.cursor/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
-async function detectUrl(url, options = {}) {
+function decodeUrlComponent(value) {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function splitScanUrl(url) {
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return { href: url, credentials: null };
+ }
+ if (!parsed.username && !parsed.password) {
+ return { href: url, credentials: null };
+ }
+ const credentials =
+ parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ ? {
+ username: decodeUrlComponent(parsed.username),
+ password: decodeUrlComponent(parsed.password),
+ }
+ : null;
+ parsed.username = '';
+ parsed.password = '';
+ return { href: parsed.href, credentials };
+}
+
+function basicAuthHeader(credentials) {
+ return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
+}
+
+// page.authenticate is page-wide: a cross-origin redirect that then 401s
+// would receive these credentials. Attach Authorization only to the scan origin.
+async function applyOriginScopedAuth(page, href, credentials) {
+ if (!credentials) return;
+ let origin = '';
+ try {
+ origin = new URL(href).origin;
+ } catch {
+ return;
+ }
+ if (!origin) return;
+ const header = basicAuthHeader(credentials);
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ let headers;
+ try {
+ if (new URL(request.url()).origin === origin) {
+ headers = { ...request.headers(), authorization: header };
+ }
+ } catch {
+ // invalid request URL: continue without auth
+ }
+ void request.continue(headers ? { headers } : undefined).catch(() => {});
+ });
+}
+
+async function detectUrl(rawUrl, options = {}) {
+ const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
+ await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
-export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
+export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
diff --git a/.cursor/skills/impeccable/scripts/serve-question.mjs b/.cursor/skills/impeccable/scripts/serve-question.mjs
index 7c4ff0812..e08052c48 100644
--- a/.cursor/skills/impeccable/scripts/serve-question.mjs
+++ b/.cursor/skills/impeccable/scripts/serve-question.mjs
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `
{ try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
+ const KEY = ${JSON.stringify(detachedKey || '')};
+ const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
+ const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `
{
set(value);
- fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
+ fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `
`;
}
+// Browsers omit the :80 suffix on the default HTTP port, so a server on
+// --port 80 sees bare loopback hosts and origins.
+function allowedHost(host, port) {
+ if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
+ return port === 80 && (host === '127.0.0.1' || host === 'localhost');
+}
+
+function allowedOrigin(origin, port) {
+ if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
+ return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
+}
+
+function rejectDetachedPost(req, res, url, port) {
+ if (detachedKey && url.searchParams.get('key') !== detachedKey) {
+ res.writeHead(401); res.end(); return true;
+ }
+ const origin = req.headers.origin;
+ if (origin && !allowedOrigin(origin, port)) {
+ res.writeHead(403); res.end(); return true;
+ }
+ return false;
+}
+
const server = http.createServer((req, res) => {
- if (req.method === 'GET' && req.url === '/') {
+ const { port } = server.address();
+ if (!allowedHost(req.headers.host, port)) {
+ res.writeHead(403); res.end(); return;
+ }
+ let url;
+ try { url = new URL(req.url, 'http://127.0.0.1'); }
+ catch { res.writeHead(400); res.end(); return; }
+ const pathname = url.pathname;
+ if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
- if (req.method === 'POST' && req.url === '/heartbeat') {
+ if (req.method === 'POST' && pathname === '/heartbeat') {
+ if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
- if (req.method === 'GET' && req.url === '/next-status') {
+ if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
- const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
+ const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
- if (req.method === 'POST' && req.url === '/build-path') {
+ if (req.method === 'POST' && pathname === '/build-path') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
- if (req.method === 'POST' && req.url === '/answer') {
+ if (req.method === 'POST' && pathname === '/answer') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
diff --git a/.gemini/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs b/.gemini/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
index 5e3d5446b..f3ff43c84 100644
--- a/.gemini/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
+++ b/.gemini/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
-async function detectUrl(url, options = {}) {
+function decodeUrlComponent(value) {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function splitScanUrl(url) {
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return { href: url, credentials: null };
+ }
+ if (!parsed.username && !parsed.password) {
+ return { href: url, credentials: null };
+ }
+ const credentials =
+ parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ ? {
+ username: decodeUrlComponent(parsed.username),
+ password: decodeUrlComponent(parsed.password),
+ }
+ : null;
+ parsed.username = '';
+ parsed.password = '';
+ return { href: parsed.href, credentials };
+}
+
+function basicAuthHeader(credentials) {
+ return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
+}
+
+// page.authenticate is page-wide: a cross-origin redirect that then 401s
+// would receive these credentials. Attach Authorization only to the scan origin.
+async function applyOriginScopedAuth(page, href, credentials) {
+ if (!credentials) return;
+ let origin = '';
+ try {
+ origin = new URL(href).origin;
+ } catch {
+ return;
+ }
+ if (!origin) return;
+ const header = basicAuthHeader(credentials);
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ let headers;
+ try {
+ if (new URL(request.url()).origin === origin) {
+ headers = { ...request.headers(), authorization: header };
+ }
+ } catch {
+ // invalid request URL: continue without auth
+ }
+ void request.continue(headers ? { headers } : undefined).catch(() => {});
+ });
+}
+
+async function detectUrl(rawUrl, options = {}) {
+ const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
+ await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
-export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
+export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
diff --git a/.gemini/skills/impeccable/scripts/serve-question.mjs b/.gemini/skills/impeccable/scripts/serve-question.mjs
index 7c4ff0812..e08052c48 100644
--- a/.gemini/skills/impeccable/scripts/serve-question.mjs
+++ b/.gemini/skills/impeccable/scripts/serve-question.mjs
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `
{ try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
+ const KEY = ${JSON.stringify(detachedKey || '')};
+ const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
+ const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `
{
set(value);
- fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
+ fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `
`;
}
+// Browsers omit the :80 suffix on the default HTTP port, so a server on
+// --port 80 sees bare loopback hosts and origins.
+function allowedHost(host, port) {
+ if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
+ return port === 80 && (host === '127.0.0.1' || host === 'localhost');
+}
+
+function allowedOrigin(origin, port) {
+ if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
+ return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
+}
+
+function rejectDetachedPost(req, res, url, port) {
+ if (detachedKey && url.searchParams.get('key') !== detachedKey) {
+ res.writeHead(401); res.end(); return true;
+ }
+ const origin = req.headers.origin;
+ if (origin && !allowedOrigin(origin, port)) {
+ res.writeHead(403); res.end(); return true;
+ }
+ return false;
+}
+
const server = http.createServer((req, res) => {
- if (req.method === 'GET' && req.url === '/') {
+ const { port } = server.address();
+ if (!allowedHost(req.headers.host, port)) {
+ res.writeHead(403); res.end(); return;
+ }
+ let url;
+ try { url = new URL(req.url, 'http://127.0.0.1'); }
+ catch { res.writeHead(400); res.end(); return; }
+ const pathname = url.pathname;
+ if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
- if (req.method === 'POST' && req.url === '/heartbeat') {
+ if (req.method === 'POST' && pathname === '/heartbeat') {
+ if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
- if (req.method === 'GET' && req.url === '/next-status') {
+ if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
- const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
+ const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
- if (req.method === 'POST' && req.url === '/build-path') {
+ if (req.method === 'POST' && pathname === '/build-path') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
- if (req.method === 'POST' && req.url === '/answer') {
+ if (req.method === 'POST' && pathname === '/answer') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
diff --git a/.github/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs b/.github/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
index 5e3d5446b..f3ff43c84 100644
--- a/.github/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
+++ b/.github/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
-async function detectUrl(url, options = {}) {
+function decodeUrlComponent(value) {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function splitScanUrl(url) {
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return { href: url, credentials: null };
+ }
+ if (!parsed.username && !parsed.password) {
+ return { href: url, credentials: null };
+ }
+ const credentials =
+ parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ ? {
+ username: decodeUrlComponent(parsed.username),
+ password: decodeUrlComponent(parsed.password),
+ }
+ : null;
+ parsed.username = '';
+ parsed.password = '';
+ return { href: parsed.href, credentials };
+}
+
+function basicAuthHeader(credentials) {
+ return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
+}
+
+// page.authenticate is page-wide: a cross-origin redirect that then 401s
+// would receive these credentials. Attach Authorization only to the scan origin.
+async function applyOriginScopedAuth(page, href, credentials) {
+ if (!credentials) return;
+ let origin = '';
+ try {
+ origin = new URL(href).origin;
+ } catch {
+ return;
+ }
+ if (!origin) return;
+ const header = basicAuthHeader(credentials);
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ let headers;
+ try {
+ if (new URL(request.url()).origin === origin) {
+ headers = { ...request.headers(), authorization: header };
+ }
+ } catch {
+ // invalid request URL: continue without auth
+ }
+ void request.continue(headers ? { headers } : undefined).catch(() => {});
+ });
+}
+
+async function detectUrl(rawUrl, options = {}) {
+ const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
+ await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
-export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
+export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
diff --git a/.github/skills/impeccable/scripts/serve-question.mjs b/.github/skills/impeccable/scripts/serve-question.mjs
index 7c4ff0812..e08052c48 100644
--- a/.github/skills/impeccable/scripts/serve-question.mjs
+++ b/.github/skills/impeccable/scripts/serve-question.mjs
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `
{ try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
+ const KEY = ${JSON.stringify(detachedKey || '')};
+ const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
+ const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `
{
set(value);
- fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
+ fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `
`;
}
+// Browsers omit the :80 suffix on the default HTTP port, so a server on
+// --port 80 sees bare loopback hosts and origins.
+function allowedHost(host, port) {
+ if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
+ return port === 80 && (host === '127.0.0.1' || host === 'localhost');
+}
+
+function allowedOrigin(origin, port) {
+ if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
+ return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
+}
+
+function rejectDetachedPost(req, res, url, port) {
+ if (detachedKey && url.searchParams.get('key') !== detachedKey) {
+ res.writeHead(401); res.end(); return true;
+ }
+ const origin = req.headers.origin;
+ if (origin && !allowedOrigin(origin, port)) {
+ res.writeHead(403); res.end(); return true;
+ }
+ return false;
+}
+
const server = http.createServer((req, res) => {
- if (req.method === 'GET' && req.url === '/') {
+ const { port } = server.address();
+ if (!allowedHost(req.headers.host, port)) {
+ res.writeHead(403); res.end(); return;
+ }
+ let url;
+ try { url = new URL(req.url, 'http://127.0.0.1'); }
+ catch { res.writeHead(400); res.end(); return; }
+ const pathname = url.pathname;
+ if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
- if (req.method === 'POST' && req.url === '/heartbeat') {
+ if (req.method === 'POST' && pathname === '/heartbeat') {
+ if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
- if (req.method === 'GET' && req.url === '/next-status') {
+ if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
- const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
+ const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
- if (req.method === 'POST' && req.url === '/build-path') {
+ if (req.method === 'POST' && pathname === '/build-path') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
- if (req.method === 'POST' && req.url === '/answer') {
+ if (req.method === 'POST' && pathname === '/answer') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
diff --git a/.grok/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs b/.grok/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
index 5e3d5446b..f3ff43c84 100644
--- a/.grok/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
+++ b/.grok/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
-async function detectUrl(url, options = {}) {
+function decodeUrlComponent(value) {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function splitScanUrl(url) {
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return { href: url, credentials: null };
+ }
+ if (!parsed.username && !parsed.password) {
+ return { href: url, credentials: null };
+ }
+ const credentials =
+ parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ ? {
+ username: decodeUrlComponent(parsed.username),
+ password: decodeUrlComponent(parsed.password),
+ }
+ : null;
+ parsed.username = '';
+ parsed.password = '';
+ return { href: parsed.href, credentials };
+}
+
+function basicAuthHeader(credentials) {
+ return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
+}
+
+// page.authenticate is page-wide: a cross-origin redirect that then 401s
+// would receive these credentials. Attach Authorization only to the scan origin.
+async function applyOriginScopedAuth(page, href, credentials) {
+ if (!credentials) return;
+ let origin = '';
+ try {
+ origin = new URL(href).origin;
+ } catch {
+ return;
+ }
+ if (!origin) return;
+ const header = basicAuthHeader(credentials);
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ let headers;
+ try {
+ if (new URL(request.url()).origin === origin) {
+ headers = { ...request.headers(), authorization: header };
+ }
+ } catch {
+ // invalid request URL: continue without auth
+ }
+ void request.continue(headers ? { headers } : undefined).catch(() => {});
+ });
+}
+
+async function detectUrl(rawUrl, options = {}) {
+ const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
+ await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
-export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
+export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
diff --git a/.grok/skills/impeccable/scripts/serve-question.mjs b/.grok/skills/impeccable/scripts/serve-question.mjs
index 7c4ff0812..e08052c48 100644
--- a/.grok/skills/impeccable/scripts/serve-question.mjs
+++ b/.grok/skills/impeccable/scripts/serve-question.mjs
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `
{ try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
+ const KEY = ${JSON.stringify(detachedKey || '')};
+ const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
+ const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `
{
set(value);
- fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
+ fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `
`;
}
+// Browsers omit the :80 suffix on the default HTTP port, so a server on
+// --port 80 sees bare loopback hosts and origins.
+function allowedHost(host, port) {
+ if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
+ return port === 80 && (host === '127.0.0.1' || host === 'localhost');
+}
+
+function allowedOrigin(origin, port) {
+ if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
+ return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
+}
+
+function rejectDetachedPost(req, res, url, port) {
+ if (detachedKey && url.searchParams.get('key') !== detachedKey) {
+ res.writeHead(401); res.end(); return true;
+ }
+ const origin = req.headers.origin;
+ if (origin && !allowedOrigin(origin, port)) {
+ res.writeHead(403); res.end(); return true;
+ }
+ return false;
+}
+
const server = http.createServer((req, res) => {
- if (req.method === 'GET' && req.url === '/') {
+ const { port } = server.address();
+ if (!allowedHost(req.headers.host, port)) {
+ res.writeHead(403); res.end(); return;
+ }
+ let url;
+ try { url = new URL(req.url, 'http://127.0.0.1'); }
+ catch { res.writeHead(400); res.end(); return; }
+ const pathname = url.pathname;
+ if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
- if (req.method === 'POST' && req.url === '/heartbeat') {
+ if (req.method === 'POST' && pathname === '/heartbeat') {
+ if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
- if (req.method === 'GET' && req.url === '/next-status') {
+ if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
- const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
+ const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
- if (req.method === 'POST' && req.url === '/build-path') {
+ if (req.method === 'POST' && pathname === '/build-path') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
- if (req.method === 'POST' && req.url === '/answer') {
+ if (req.method === 'POST' && pathname === '/answer') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
diff --git a/.hermes/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs b/.hermes/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
index 5e3d5446b..f3ff43c84 100644
--- a/.hermes/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
+++ b/.hermes/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
-async function detectUrl(url, options = {}) {
+function decodeUrlComponent(value) {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function splitScanUrl(url) {
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return { href: url, credentials: null };
+ }
+ if (!parsed.username && !parsed.password) {
+ return { href: url, credentials: null };
+ }
+ const credentials =
+ parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ ? {
+ username: decodeUrlComponent(parsed.username),
+ password: decodeUrlComponent(parsed.password),
+ }
+ : null;
+ parsed.username = '';
+ parsed.password = '';
+ return { href: parsed.href, credentials };
+}
+
+function basicAuthHeader(credentials) {
+ return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
+}
+
+// page.authenticate is page-wide: a cross-origin redirect that then 401s
+// would receive these credentials. Attach Authorization only to the scan origin.
+async function applyOriginScopedAuth(page, href, credentials) {
+ if (!credentials) return;
+ let origin = '';
+ try {
+ origin = new URL(href).origin;
+ } catch {
+ return;
+ }
+ if (!origin) return;
+ const header = basicAuthHeader(credentials);
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ let headers;
+ try {
+ if (new URL(request.url()).origin === origin) {
+ headers = { ...request.headers(), authorization: header };
+ }
+ } catch {
+ // invalid request URL: continue without auth
+ }
+ void request.continue(headers ? { headers } : undefined).catch(() => {});
+ });
+}
+
+async function detectUrl(rawUrl, options = {}) {
+ const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
+ await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
-export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
+export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
diff --git a/.hermes/skills/impeccable/scripts/serve-question.mjs b/.hermes/skills/impeccable/scripts/serve-question.mjs
index 7c4ff0812..e08052c48 100644
--- a/.hermes/skills/impeccable/scripts/serve-question.mjs
+++ b/.hermes/skills/impeccable/scripts/serve-question.mjs
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `
{ try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
+ const KEY = ${JSON.stringify(detachedKey || '')};
+ const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
+ const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `
{
set(value);
- fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
+ fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `
`;
}
+// Browsers omit the :80 suffix on the default HTTP port, so a server on
+// --port 80 sees bare loopback hosts and origins.
+function allowedHost(host, port) {
+ if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
+ return port === 80 && (host === '127.0.0.1' || host === 'localhost');
+}
+
+function allowedOrigin(origin, port) {
+ if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
+ return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
+}
+
+function rejectDetachedPost(req, res, url, port) {
+ if (detachedKey && url.searchParams.get('key') !== detachedKey) {
+ res.writeHead(401); res.end(); return true;
+ }
+ const origin = req.headers.origin;
+ if (origin && !allowedOrigin(origin, port)) {
+ res.writeHead(403); res.end(); return true;
+ }
+ return false;
+}
+
const server = http.createServer((req, res) => {
- if (req.method === 'GET' && req.url === '/') {
+ const { port } = server.address();
+ if (!allowedHost(req.headers.host, port)) {
+ res.writeHead(403); res.end(); return;
+ }
+ let url;
+ try { url = new URL(req.url, 'http://127.0.0.1'); }
+ catch { res.writeHead(400); res.end(); return; }
+ const pathname = url.pathname;
+ if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
- if (req.method === 'POST' && req.url === '/heartbeat') {
+ if (req.method === 'POST' && pathname === '/heartbeat') {
+ if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
- if (req.method === 'GET' && req.url === '/next-status') {
+ if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
- const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
+ const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
- if (req.method === 'POST' && req.url === '/build-path') {
+ if (req.method === 'POST' && pathname === '/build-path') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
- if (req.method === 'POST' && req.url === '/answer') {
+ if (req.method === 'POST' && pathname === '/answer') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
diff --git a/.kiro/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs b/.kiro/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
index 5e3d5446b..f3ff43c84 100644
--- a/.kiro/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
+++ b/.kiro/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
-async function detectUrl(url, options = {}) {
+function decodeUrlComponent(value) {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function splitScanUrl(url) {
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return { href: url, credentials: null };
+ }
+ if (!parsed.username && !parsed.password) {
+ return { href: url, credentials: null };
+ }
+ const credentials =
+ parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ ? {
+ username: decodeUrlComponent(parsed.username),
+ password: decodeUrlComponent(parsed.password),
+ }
+ : null;
+ parsed.username = '';
+ parsed.password = '';
+ return { href: parsed.href, credentials };
+}
+
+function basicAuthHeader(credentials) {
+ return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
+}
+
+// page.authenticate is page-wide: a cross-origin redirect that then 401s
+// would receive these credentials. Attach Authorization only to the scan origin.
+async function applyOriginScopedAuth(page, href, credentials) {
+ if (!credentials) return;
+ let origin = '';
+ try {
+ origin = new URL(href).origin;
+ } catch {
+ return;
+ }
+ if (!origin) return;
+ const header = basicAuthHeader(credentials);
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ let headers;
+ try {
+ if (new URL(request.url()).origin === origin) {
+ headers = { ...request.headers(), authorization: header };
+ }
+ } catch {
+ // invalid request URL: continue without auth
+ }
+ void request.continue(headers ? { headers } : undefined).catch(() => {});
+ });
+}
+
+async function detectUrl(rawUrl, options = {}) {
+ const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
+ await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
-export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
+export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
diff --git a/.kiro/skills/impeccable/scripts/serve-question.mjs b/.kiro/skills/impeccable/scripts/serve-question.mjs
index 7c4ff0812..e08052c48 100644
--- a/.kiro/skills/impeccable/scripts/serve-question.mjs
+++ b/.kiro/skills/impeccable/scripts/serve-question.mjs
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `
{ try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
+ const KEY = ${JSON.stringify(detachedKey || '')};
+ const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
+ const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `
{
set(value);
- fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
+ fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `
`;
}
+// Browsers omit the :80 suffix on the default HTTP port, so a server on
+// --port 80 sees bare loopback hosts and origins.
+function allowedHost(host, port) {
+ if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
+ return port === 80 && (host === '127.0.0.1' || host === 'localhost');
+}
+
+function allowedOrigin(origin, port) {
+ if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
+ return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
+}
+
+function rejectDetachedPost(req, res, url, port) {
+ if (detachedKey && url.searchParams.get('key') !== detachedKey) {
+ res.writeHead(401); res.end(); return true;
+ }
+ const origin = req.headers.origin;
+ if (origin && !allowedOrigin(origin, port)) {
+ res.writeHead(403); res.end(); return true;
+ }
+ return false;
+}
+
const server = http.createServer((req, res) => {
- if (req.method === 'GET' && req.url === '/') {
+ const { port } = server.address();
+ if (!allowedHost(req.headers.host, port)) {
+ res.writeHead(403); res.end(); return;
+ }
+ let url;
+ try { url = new URL(req.url, 'http://127.0.0.1'); }
+ catch { res.writeHead(400); res.end(); return; }
+ const pathname = url.pathname;
+ if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
- if (req.method === 'POST' && req.url === '/heartbeat') {
+ if (req.method === 'POST' && pathname === '/heartbeat') {
+ if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
- if (req.method === 'GET' && req.url === '/next-status') {
+ if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
- const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
+ const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
- if (req.method === 'POST' && req.url === '/build-path') {
+ if (req.method === 'POST' && pathname === '/build-path') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
- if (req.method === 'POST' && req.url === '/answer') {
+ if (req.method === 'POST' && pathname === '/answer') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
diff --git a/.opencode/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs b/.opencode/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
index 5e3d5446b..f3ff43c84 100644
--- a/.opencode/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
+++ b/.opencode/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
-async function detectUrl(url, options = {}) {
+function decodeUrlComponent(value) {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function splitScanUrl(url) {
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return { href: url, credentials: null };
+ }
+ if (!parsed.username && !parsed.password) {
+ return { href: url, credentials: null };
+ }
+ const credentials =
+ parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ ? {
+ username: decodeUrlComponent(parsed.username),
+ password: decodeUrlComponent(parsed.password),
+ }
+ : null;
+ parsed.username = '';
+ parsed.password = '';
+ return { href: parsed.href, credentials };
+}
+
+function basicAuthHeader(credentials) {
+ return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
+}
+
+// page.authenticate is page-wide: a cross-origin redirect that then 401s
+// would receive these credentials. Attach Authorization only to the scan origin.
+async function applyOriginScopedAuth(page, href, credentials) {
+ if (!credentials) return;
+ let origin = '';
+ try {
+ origin = new URL(href).origin;
+ } catch {
+ return;
+ }
+ if (!origin) return;
+ const header = basicAuthHeader(credentials);
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ let headers;
+ try {
+ if (new URL(request.url()).origin === origin) {
+ headers = { ...request.headers(), authorization: header };
+ }
+ } catch {
+ // invalid request URL: continue without auth
+ }
+ void request.continue(headers ? { headers } : undefined).catch(() => {});
+ });
+}
+
+async function detectUrl(rawUrl, options = {}) {
+ const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
+ await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
-export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
+export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
diff --git a/.opencode/skills/impeccable/scripts/serve-question.mjs b/.opencode/skills/impeccable/scripts/serve-question.mjs
index 7c4ff0812..e08052c48 100644
--- a/.opencode/skills/impeccable/scripts/serve-question.mjs
+++ b/.opencode/skills/impeccable/scripts/serve-question.mjs
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `
{ try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
+ const KEY = ${JSON.stringify(detachedKey || '')};
+ const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
+ const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `
{
set(value);
- fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
+ fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `
`;
}
+// Browsers omit the :80 suffix on the default HTTP port, so a server on
+// --port 80 sees bare loopback hosts and origins.
+function allowedHost(host, port) {
+ if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
+ return port === 80 && (host === '127.0.0.1' || host === 'localhost');
+}
+
+function allowedOrigin(origin, port) {
+ if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
+ return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
+}
+
+function rejectDetachedPost(req, res, url, port) {
+ if (detachedKey && url.searchParams.get('key') !== detachedKey) {
+ res.writeHead(401); res.end(); return true;
+ }
+ const origin = req.headers.origin;
+ if (origin && !allowedOrigin(origin, port)) {
+ res.writeHead(403); res.end(); return true;
+ }
+ return false;
+}
+
const server = http.createServer((req, res) => {
- if (req.method === 'GET' && req.url === '/') {
+ const { port } = server.address();
+ if (!allowedHost(req.headers.host, port)) {
+ res.writeHead(403); res.end(); return;
+ }
+ let url;
+ try { url = new URL(req.url, 'http://127.0.0.1'); }
+ catch { res.writeHead(400); res.end(); return; }
+ const pathname = url.pathname;
+ if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
- if (req.method === 'POST' && req.url === '/heartbeat') {
+ if (req.method === 'POST' && pathname === '/heartbeat') {
+ if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
- if (req.method === 'GET' && req.url === '/next-status') {
+ if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
- const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
+ const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
- if (req.method === 'POST' && req.url === '/build-path') {
+ if (req.method === 'POST' && pathname === '/build-path') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
- if (req.method === 'POST' && req.url === '/answer') {
+ if (req.method === 'POST' && pathname === '/answer') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
diff --git a/.pi/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs b/.pi/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
index 5e3d5446b..f3ff43c84 100644
--- a/.pi/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
+++ b/.pi/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
-async function detectUrl(url, options = {}) {
+function decodeUrlComponent(value) {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function splitScanUrl(url) {
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return { href: url, credentials: null };
+ }
+ if (!parsed.username && !parsed.password) {
+ return { href: url, credentials: null };
+ }
+ const credentials =
+ parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ ? {
+ username: decodeUrlComponent(parsed.username),
+ password: decodeUrlComponent(parsed.password),
+ }
+ : null;
+ parsed.username = '';
+ parsed.password = '';
+ return { href: parsed.href, credentials };
+}
+
+function basicAuthHeader(credentials) {
+ return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
+}
+
+// page.authenticate is page-wide: a cross-origin redirect that then 401s
+// would receive these credentials. Attach Authorization only to the scan origin.
+async function applyOriginScopedAuth(page, href, credentials) {
+ if (!credentials) return;
+ let origin = '';
+ try {
+ origin = new URL(href).origin;
+ } catch {
+ return;
+ }
+ if (!origin) return;
+ const header = basicAuthHeader(credentials);
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ let headers;
+ try {
+ if (new URL(request.url()).origin === origin) {
+ headers = { ...request.headers(), authorization: header };
+ }
+ } catch {
+ // invalid request URL: continue without auth
+ }
+ void request.continue(headers ? { headers } : undefined).catch(() => {});
+ });
+}
+
+async function detectUrl(rawUrl, options = {}) {
+ const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
+ await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
-export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
+export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
diff --git a/.pi/skills/impeccable/scripts/serve-question.mjs b/.pi/skills/impeccable/scripts/serve-question.mjs
index 7c4ff0812..e08052c48 100644
--- a/.pi/skills/impeccable/scripts/serve-question.mjs
+++ b/.pi/skills/impeccable/scripts/serve-question.mjs
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `
{ try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
+ const KEY = ${JSON.stringify(detachedKey || '')};
+ const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
+ const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `
{
set(value);
- fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
+ fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `
`;
}
+// Browsers omit the :80 suffix on the default HTTP port, so a server on
+// --port 80 sees bare loopback hosts and origins.
+function allowedHost(host, port) {
+ if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
+ return port === 80 && (host === '127.0.0.1' || host === 'localhost');
+}
+
+function allowedOrigin(origin, port) {
+ if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
+ return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
+}
+
+function rejectDetachedPost(req, res, url, port) {
+ if (detachedKey && url.searchParams.get('key') !== detachedKey) {
+ res.writeHead(401); res.end(); return true;
+ }
+ const origin = req.headers.origin;
+ if (origin && !allowedOrigin(origin, port)) {
+ res.writeHead(403); res.end(); return true;
+ }
+ return false;
+}
+
const server = http.createServer((req, res) => {
- if (req.method === 'GET' && req.url === '/') {
+ const { port } = server.address();
+ if (!allowedHost(req.headers.host, port)) {
+ res.writeHead(403); res.end(); return;
+ }
+ let url;
+ try { url = new URL(req.url, 'http://127.0.0.1'); }
+ catch { res.writeHead(400); res.end(); return; }
+ const pathname = url.pathname;
+ if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
- if (req.method === 'POST' && req.url === '/heartbeat') {
+ if (req.method === 'POST' && pathname === '/heartbeat') {
+ if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
- if (req.method === 'GET' && req.url === '/next-status') {
+ if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
- const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
+ const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
- if (req.method === 'POST' && req.url === '/build-path') {
+ if (req.method === 'POST' && pathname === '/build-path') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
- if (req.method === 'POST' && req.url === '/answer') {
+ if (req.method === 'POST' && pathname === '/answer') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
diff --git a/.qoder/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs b/.qoder/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
index 5e3d5446b..f3ff43c84 100644
--- a/.qoder/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
+++ b/.qoder/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
-async function detectUrl(url, options = {}) {
+function decodeUrlComponent(value) {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function splitScanUrl(url) {
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return { href: url, credentials: null };
+ }
+ if (!parsed.username && !parsed.password) {
+ return { href: url, credentials: null };
+ }
+ const credentials =
+ parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ ? {
+ username: decodeUrlComponent(parsed.username),
+ password: decodeUrlComponent(parsed.password),
+ }
+ : null;
+ parsed.username = '';
+ parsed.password = '';
+ return { href: parsed.href, credentials };
+}
+
+function basicAuthHeader(credentials) {
+ return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
+}
+
+// page.authenticate is page-wide: a cross-origin redirect that then 401s
+// would receive these credentials. Attach Authorization only to the scan origin.
+async function applyOriginScopedAuth(page, href, credentials) {
+ if (!credentials) return;
+ let origin = '';
+ try {
+ origin = new URL(href).origin;
+ } catch {
+ return;
+ }
+ if (!origin) return;
+ const header = basicAuthHeader(credentials);
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ let headers;
+ try {
+ if (new URL(request.url()).origin === origin) {
+ headers = { ...request.headers(), authorization: header };
+ }
+ } catch {
+ // invalid request URL: continue without auth
+ }
+ void request.continue(headers ? { headers } : undefined).catch(() => {});
+ });
+}
+
+async function detectUrl(rawUrl, options = {}) {
+ const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
+ await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
-export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
+export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
diff --git a/.qoder/skills/impeccable/scripts/serve-question.mjs b/.qoder/skills/impeccable/scripts/serve-question.mjs
index 7c4ff0812..e08052c48 100644
--- a/.qoder/skills/impeccable/scripts/serve-question.mjs
+++ b/.qoder/skills/impeccable/scripts/serve-question.mjs
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `
{ try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
+ const KEY = ${JSON.stringify(detachedKey || '')};
+ const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
+ const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `
{
set(value);
- fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
+ fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `
`;
}
+// Browsers omit the :80 suffix on the default HTTP port, so a server on
+// --port 80 sees bare loopback hosts and origins.
+function allowedHost(host, port) {
+ if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
+ return port === 80 && (host === '127.0.0.1' || host === 'localhost');
+}
+
+function allowedOrigin(origin, port) {
+ if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
+ return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
+}
+
+function rejectDetachedPost(req, res, url, port) {
+ if (detachedKey && url.searchParams.get('key') !== detachedKey) {
+ res.writeHead(401); res.end(); return true;
+ }
+ const origin = req.headers.origin;
+ if (origin && !allowedOrigin(origin, port)) {
+ res.writeHead(403); res.end(); return true;
+ }
+ return false;
+}
+
const server = http.createServer((req, res) => {
- if (req.method === 'GET' && req.url === '/') {
+ const { port } = server.address();
+ if (!allowedHost(req.headers.host, port)) {
+ res.writeHead(403); res.end(); return;
+ }
+ let url;
+ try { url = new URL(req.url, 'http://127.0.0.1'); }
+ catch { res.writeHead(400); res.end(); return; }
+ const pathname = url.pathname;
+ if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
- if (req.method === 'POST' && req.url === '/heartbeat') {
+ if (req.method === 'POST' && pathname === '/heartbeat') {
+ if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
- if (req.method === 'GET' && req.url === '/next-status') {
+ if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
- const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
+ const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
- if (req.method === 'POST' && req.url === '/build-path') {
+ if (req.method === 'POST' && pathname === '/build-path') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
- if (req.method === 'POST' && req.url === '/answer') {
+ if (req.method === 'POST' && pathname === '/answer') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
diff --git a/.rovodev/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs b/.rovodev/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
index 5e3d5446b..f3ff43c84 100644
--- a/.rovodev/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
+++ b/.rovodev/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
-async function detectUrl(url, options = {}) {
+function decodeUrlComponent(value) {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function splitScanUrl(url) {
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return { href: url, credentials: null };
+ }
+ if (!parsed.username && !parsed.password) {
+ return { href: url, credentials: null };
+ }
+ const credentials =
+ parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ ? {
+ username: decodeUrlComponent(parsed.username),
+ password: decodeUrlComponent(parsed.password),
+ }
+ : null;
+ parsed.username = '';
+ parsed.password = '';
+ return { href: parsed.href, credentials };
+}
+
+function basicAuthHeader(credentials) {
+ return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
+}
+
+// page.authenticate is page-wide: a cross-origin redirect that then 401s
+// would receive these credentials. Attach Authorization only to the scan origin.
+async function applyOriginScopedAuth(page, href, credentials) {
+ if (!credentials) return;
+ let origin = '';
+ try {
+ origin = new URL(href).origin;
+ } catch {
+ return;
+ }
+ if (!origin) return;
+ const header = basicAuthHeader(credentials);
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ let headers;
+ try {
+ if (new URL(request.url()).origin === origin) {
+ headers = { ...request.headers(), authorization: header };
+ }
+ } catch {
+ // invalid request URL: continue without auth
+ }
+ void request.continue(headers ? { headers } : undefined).catch(() => {});
+ });
+}
+
+async function detectUrl(rawUrl, options = {}) {
+ const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
+ await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
-export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
+export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
diff --git a/.rovodev/skills/impeccable/scripts/serve-question.mjs b/.rovodev/skills/impeccable/scripts/serve-question.mjs
index 7c4ff0812..e08052c48 100644
--- a/.rovodev/skills/impeccable/scripts/serve-question.mjs
+++ b/.rovodev/skills/impeccable/scripts/serve-question.mjs
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `
{ try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
+ const KEY = ${JSON.stringify(detachedKey || '')};
+ const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
+ const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `
{
set(value);
- fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
+ fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `
`;
}
+// Browsers omit the :80 suffix on the default HTTP port, so a server on
+// --port 80 sees bare loopback hosts and origins.
+function allowedHost(host, port) {
+ if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
+ return port === 80 && (host === '127.0.0.1' || host === 'localhost');
+}
+
+function allowedOrigin(origin, port) {
+ if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
+ return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
+}
+
+function rejectDetachedPost(req, res, url, port) {
+ if (detachedKey && url.searchParams.get('key') !== detachedKey) {
+ res.writeHead(401); res.end(); return true;
+ }
+ const origin = req.headers.origin;
+ if (origin && !allowedOrigin(origin, port)) {
+ res.writeHead(403); res.end(); return true;
+ }
+ return false;
+}
+
const server = http.createServer((req, res) => {
- if (req.method === 'GET' && req.url === '/') {
+ const { port } = server.address();
+ if (!allowedHost(req.headers.host, port)) {
+ res.writeHead(403); res.end(); return;
+ }
+ let url;
+ try { url = new URL(req.url, 'http://127.0.0.1'); }
+ catch { res.writeHead(400); res.end(); return; }
+ const pathname = url.pathname;
+ if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
- if (req.method === 'POST' && req.url === '/heartbeat') {
+ if (req.method === 'POST' && pathname === '/heartbeat') {
+ if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
- if (req.method === 'GET' && req.url === '/next-status') {
+ if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
- const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
+ const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
- if (req.method === 'POST' && req.url === '/build-path') {
+ if (req.method === 'POST' && pathname === '/build-path') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
- if (req.method === 'POST' && req.url === '/answer') {
+ if (req.method === 'POST' && pathname === '/answer') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
diff --git a/.trae-cn/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs b/.trae-cn/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
index 5e3d5446b..f3ff43c84 100644
--- a/.trae-cn/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
+++ b/.trae-cn/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
-async function detectUrl(url, options = {}) {
+function decodeUrlComponent(value) {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function splitScanUrl(url) {
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return { href: url, credentials: null };
+ }
+ if (!parsed.username && !parsed.password) {
+ return { href: url, credentials: null };
+ }
+ const credentials =
+ parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ ? {
+ username: decodeUrlComponent(parsed.username),
+ password: decodeUrlComponent(parsed.password),
+ }
+ : null;
+ parsed.username = '';
+ parsed.password = '';
+ return { href: parsed.href, credentials };
+}
+
+function basicAuthHeader(credentials) {
+ return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
+}
+
+// page.authenticate is page-wide: a cross-origin redirect that then 401s
+// would receive these credentials. Attach Authorization only to the scan origin.
+async function applyOriginScopedAuth(page, href, credentials) {
+ if (!credentials) return;
+ let origin = '';
+ try {
+ origin = new URL(href).origin;
+ } catch {
+ return;
+ }
+ if (!origin) return;
+ const header = basicAuthHeader(credentials);
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ let headers;
+ try {
+ if (new URL(request.url()).origin === origin) {
+ headers = { ...request.headers(), authorization: header };
+ }
+ } catch {
+ // invalid request URL: continue without auth
+ }
+ void request.continue(headers ? { headers } : undefined).catch(() => {});
+ });
+}
+
+async function detectUrl(rawUrl, options = {}) {
+ const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
+ await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
-export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
+export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
diff --git a/.trae-cn/skills/impeccable/scripts/serve-question.mjs b/.trae-cn/skills/impeccable/scripts/serve-question.mjs
index 7c4ff0812..e08052c48 100644
--- a/.trae-cn/skills/impeccable/scripts/serve-question.mjs
+++ b/.trae-cn/skills/impeccable/scripts/serve-question.mjs
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `
{ try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
+ const KEY = ${JSON.stringify(detachedKey || '')};
+ const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
+ const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `
{
set(value);
- fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
+ fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `
`;
}
+// Browsers omit the :80 suffix on the default HTTP port, so a server on
+// --port 80 sees bare loopback hosts and origins.
+function allowedHost(host, port) {
+ if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
+ return port === 80 && (host === '127.0.0.1' || host === 'localhost');
+}
+
+function allowedOrigin(origin, port) {
+ if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
+ return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
+}
+
+function rejectDetachedPost(req, res, url, port) {
+ if (detachedKey && url.searchParams.get('key') !== detachedKey) {
+ res.writeHead(401); res.end(); return true;
+ }
+ const origin = req.headers.origin;
+ if (origin && !allowedOrigin(origin, port)) {
+ res.writeHead(403); res.end(); return true;
+ }
+ return false;
+}
+
const server = http.createServer((req, res) => {
- if (req.method === 'GET' && req.url === '/') {
+ const { port } = server.address();
+ if (!allowedHost(req.headers.host, port)) {
+ res.writeHead(403); res.end(); return;
+ }
+ let url;
+ try { url = new URL(req.url, 'http://127.0.0.1'); }
+ catch { res.writeHead(400); res.end(); return; }
+ const pathname = url.pathname;
+ if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
- if (req.method === 'POST' && req.url === '/heartbeat') {
+ if (req.method === 'POST' && pathname === '/heartbeat') {
+ if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
- if (req.method === 'GET' && req.url === '/next-status') {
+ if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
- const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
+ const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
- if (req.method === 'POST' && req.url === '/build-path') {
+ if (req.method === 'POST' && pathname === '/build-path') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
- if (req.method === 'POST' && req.url === '/answer') {
+ if (req.method === 'POST' && pathname === '/answer') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
diff --git a/.trae/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs b/.trae/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
index 5e3d5446b..f3ff43c84 100644
--- a/.trae/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
+++ b/.trae/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
-async function detectUrl(url, options = {}) {
+function decodeUrlComponent(value) {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function splitScanUrl(url) {
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return { href: url, credentials: null };
+ }
+ if (!parsed.username && !parsed.password) {
+ return { href: url, credentials: null };
+ }
+ const credentials =
+ parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ ? {
+ username: decodeUrlComponent(parsed.username),
+ password: decodeUrlComponent(parsed.password),
+ }
+ : null;
+ parsed.username = '';
+ parsed.password = '';
+ return { href: parsed.href, credentials };
+}
+
+function basicAuthHeader(credentials) {
+ return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
+}
+
+// page.authenticate is page-wide: a cross-origin redirect that then 401s
+// would receive these credentials. Attach Authorization only to the scan origin.
+async function applyOriginScopedAuth(page, href, credentials) {
+ if (!credentials) return;
+ let origin = '';
+ try {
+ origin = new URL(href).origin;
+ } catch {
+ return;
+ }
+ if (!origin) return;
+ const header = basicAuthHeader(credentials);
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ let headers;
+ try {
+ if (new URL(request.url()).origin === origin) {
+ headers = { ...request.headers(), authorization: header };
+ }
+ } catch {
+ // invalid request URL: continue without auth
+ }
+ void request.continue(headers ? { headers } : undefined).catch(() => {});
+ });
+}
+
+async function detectUrl(rawUrl, options = {}) {
+ const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
+ await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
-export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
+export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
diff --git a/.trae/skills/impeccable/scripts/serve-question.mjs b/.trae/skills/impeccable/scripts/serve-question.mjs
index 7c4ff0812..e08052c48 100644
--- a/.trae/skills/impeccable/scripts/serve-question.mjs
+++ b/.trae/skills/impeccable/scripts/serve-question.mjs
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `
{ try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
+ const KEY = ${JSON.stringify(detachedKey || '')};
+ const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
+ const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `
{
set(value);
- fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
+ fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `
`;
}
+// Browsers omit the :80 suffix on the default HTTP port, so a server on
+// --port 80 sees bare loopback hosts and origins.
+function allowedHost(host, port) {
+ if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
+ return port === 80 && (host === '127.0.0.1' || host === 'localhost');
+}
+
+function allowedOrigin(origin, port) {
+ if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
+ return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
+}
+
+function rejectDetachedPost(req, res, url, port) {
+ if (detachedKey && url.searchParams.get('key') !== detachedKey) {
+ res.writeHead(401); res.end(); return true;
+ }
+ const origin = req.headers.origin;
+ if (origin && !allowedOrigin(origin, port)) {
+ res.writeHead(403); res.end(); return true;
+ }
+ return false;
+}
+
const server = http.createServer((req, res) => {
- if (req.method === 'GET' && req.url === '/') {
+ const { port } = server.address();
+ if (!allowedHost(req.headers.host, port)) {
+ res.writeHead(403); res.end(); return;
+ }
+ let url;
+ try { url = new URL(req.url, 'http://127.0.0.1'); }
+ catch { res.writeHead(400); res.end(); return; }
+ const pathname = url.pathname;
+ if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
- if (req.method === 'POST' && req.url === '/heartbeat') {
+ if (req.method === 'POST' && pathname === '/heartbeat') {
+ if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
- if (req.method === 'GET' && req.url === '/next-status') {
+ if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
- const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
+ const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
- if (req.method === 'POST' && req.url === '/build-path') {
+ if (req.method === 'POST' && pathname === '/build-path') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
- if (req.method === 'POST' && req.url === '/answer') {
+ if (req.method === 'POST' && pathname === '/answer') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
diff --git a/.vibe/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs b/.vibe/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
index 5e3d5446b..f3ff43c84 100644
--- a/.vibe/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
+++ b/.vibe/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
-async function detectUrl(url, options = {}) {
+function decodeUrlComponent(value) {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function splitScanUrl(url) {
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return { href: url, credentials: null };
+ }
+ if (!parsed.username && !parsed.password) {
+ return { href: url, credentials: null };
+ }
+ const credentials =
+ parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ ? {
+ username: decodeUrlComponent(parsed.username),
+ password: decodeUrlComponent(parsed.password),
+ }
+ : null;
+ parsed.username = '';
+ parsed.password = '';
+ return { href: parsed.href, credentials };
+}
+
+function basicAuthHeader(credentials) {
+ return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
+}
+
+// page.authenticate is page-wide: a cross-origin redirect that then 401s
+// would receive these credentials. Attach Authorization only to the scan origin.
+async function applyOriginScopedAuth(page, href, credentials) {
+ if (!credentials) return;
+ let origin = '';
+ try {
+ origin = new URL(href).origin;
+ } catch {
+ return;
+ }
+ if (!origin) return;
+ const header = basicAuthHeader(credentials);
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ let headers;
+ try {
+ if (new URL(request.url()).origin === origin) {
+ headers = { ...request.headers(), authorization: header };
+ }
+ } catch {
+ // invalid request URL: continue without auth
+ }
+ void request.continue(headers ? { headers } : undefined).catch(() => {});
+ });
+}
+
+async function detectUrl(rawUrl, options = {}) {
+ const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
+ await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
-export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
+export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
diff --git a/.vibe/skills/impeccable/scripts/serve-question.mjs b/.vibe/skills/impeccable/scripts/serve-question.mjs
index 7c4ff0812..e08052c48 100644
--- a/.vibe/skills/impeccable/scripts/serve-question.mjs
+++ b/.vibe/skills/impeccable/scripts/serve-question.mjs
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `
{ try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
+ const KEY = ${JSON.stringify(detachedKey || '')};
+ const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
+ const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `
{
set(value);
- fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
+ fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `
`;
}
+// Browsers omit the :80 suffix on the default HTTP port, so a server on
+// --port 80 sees bare loopback hosts and origins.
+function allowedHost(host, port) {
+ if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
+ return port === 80 && (host === '127.0.0.1' || host === 'localhost');
+}
+
+function allowedOrigin(origin, port) {
+ if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
+ return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
+}
+
+function rejectDetachedPost(req, res, url, port) {
+ if (detachedKey && url.searchParams.get('key') !== detachedKey) {
+ res.writeHead(401); res.end(); return true;
+ }
+ const origin = req.headers.origin;
+ if (origin && !allowedOrigin(origin, port)) {
+ res.writeHead(403); res.end(); return true;
+ }
+ return false;
+}
+
const server = http.createServer((req, res) => {
- if (req.method === 'GET' && req.url === '/') {
+ const { port } = server.address();
+ if (!allowedHost(req.headers.host, port)) {
+ res.writeHead(403); res.end(); return;
+ }
+ let url;
+ try { url = new URL(req.url, 'http://127.0.0.1'); }
+ catch { res.writeHead(400); res.end(); return; }
+ const pathname = url.pathname;
+ if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
- if (req.method === 'POST' && req.url === '/heartbeat') {
+ if (req.method === 'POST' && pathname === '/heartbeat') {
+ if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
- if (req.method === 'GET' && req.url === '/next-status') {
+ if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
- const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
+ const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
- if (req.method === 'POST' && req.url === '/build-path') {
+ if (req.method === 'POST' && pathname === '/build-path') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
- if (req.method === 'POST' && req.url === '/answer') {
+ if (req.method === 'POST' && pathname === '/answer') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
diff --git a/plugin/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs b/plugin/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
index 5e3d5446b..f3ff43c84 100644
--- a/plugin/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
+++ b/plugin/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
-async function detectUrl(url, options = {}) {
+function decodeUrlComponent(value) {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function splitScanUrl(url) {
+ let parsed;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return { href: url, credentials: null };
+ }
+ if (!parsed.username && !parsed.password) {
+ return { href: url, credentials: null };
+ }
+ const credentials =
+ parsed.protocol === 'http:' || parsed.protocol === 'https:'
+ ? {
+ username: decodeUrlComponent(parsed.username),
+ password: decodeUrlComponent(parsed.password),
+ }
+ : null;
+ parsed.username = '';
+ parsed.password = '';
+ return { href: parsed.href, credentials };
+}
+
+function basicAuthHeader(credentials) {
+ return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
+}
+
+// page.authenticate is page-wide: a cross-origin redirect that then 401s
+// would receive these credentials. Attach Authorization only to the scan origin.
+async function applyOriginScopedAuth(page, href, credentials) {
+ if (!credentials) return;
+ let origin = '';
+ try {
+ origin = new URL(href).origin;
+ } catch {
+ return;
+ }
+ if (!origin) return;
+ const header = basicAuthHeader(credentials);
+ await page.setRequestInterception(true);
+ page.on('request', (request) => {
+ let headers;
+ try {
+ if (new URL(request.url()).origin === origin) {
+ headers = { ...request.headers(), authorization: header };
+ }
+ } catch {
+ // invalid request URL: continue without auth
+ }
+ void request.continue(headers ? { headers } : undefined).catch(() => {});
+ });
+}
+
+async function detectUrl(rawUrl, options = {}) {
+ const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
+ await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
-export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
+export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
diff --git a/plugin/skills/impeccable/scripts/serve-question.mjs b/plugin/skills/impeccable/scripts/serve-question.mjs
index 7c4ff0812..e08052c48 100644
--- a/plugin/skills/impeccable/scripts/serve-question.mjs
+++ b/plugin/skills/impeccable/scripts/serve-question.mjs
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `
{ try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
+ const KEY = ${JSON.stringify(detachedKey || '')};
+ const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
+ const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `
{
set(value);
- fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
+ fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `
b.setAttribute('disabled', ''));
try {
- await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
+ await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '
The question server went away before this choice could land.
Tell the agent your pick in the chat instead.
';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `
`;
}
+// Browsers omit the :80 suffix on the default HTTP port, so a server on
+// --port 80 sees bare loopback hosts and origins.
+function allowedHost(host, port) {
+ if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
+ return port === 80 && (host === '127.0.0.1' || host === 'localhost');
+}
+
+function allowedOrigin(origin, port) {
+ if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
+ return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
+}
+
+function rejectDetachedPost(req, res, url, port) {
+ if (detachedKey && url.searchParams.get('key') !== detachedKey) {
+ res.writeHead(401); res.end(); return true;
+ }
+ const origin = req.headers.origin;
+ if (origin && !allowedOrigin(origin, port)) {
+ res.writeHead(403); res.end(); return true;
+ }
+ return false;
+}
+
const server = http.createServer((req, res) => {
- if (req.method === 'GET' && req.url === '/') {
+ const { port } = server.address();
+ if (!allowedHost(req.headers.host, port)) {
+ res.writeHead(403); res.end(); return;
+ }
+ let url;
+ try { url = new URL(req.url, 'http://127.0.0.1'); }
+ catch { res.writeHead(400); res.end(); return; }
+ const pathname = url.pathname;
+ if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
- if (req.method === 'POST' && req.url === '/heartbeat') {
+ if (req.method === 'POST' && pathname === '/heartbeat') {
+ if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
- if (req.method === 'GET' && req.url === '/next-status') {
+ if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
- const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
+ const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
- if (req.method === 'POST' && req.url === '/build-path') {
+ if (req.method === 'POST' && pathname === '/build-path') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
- if (req.method === 'POST' && req.url === '/answer') {
+ if (req.method === 'POST' && pathname === '/answer') {
+ if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {