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.
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,35 @@ ${buildPath?.toggle ? `
`;
}
+function allowedHost(host, port) {
+ return host === `127.0.0.1:${port}` || host === `localhost:${port}`;
+}
+
+function allowedOrigin(origin, port) {
+ return origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`;
+}
+
+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 +1622,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 +1639,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 +1658,7 @@ 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') {
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1678,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/tests/serve-question.test.mjs b/tests/serve-question.test.mjs
index 2736b1876..4a574966a 100644
--- a/tests/serve-question.test.mjs
+++ b/tests/serve-question.test.mjs
@@ -1,6 +1,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { spawn, execSync } from 'node:child_process';
+import http from 'node:http';
import { writeFileSync, readFileSync, rmSync, utimesSync, mkdtempSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
@@ -41,6 +42,25 @@ const PAYLOAD = {
steer: true,
};
+function rawRequest(port, { method = 'GET', path: reqPath = '/', headers = {} } = {}, body) {
+ return new Promise((resolve, reject) => {
+ const req = http.request({
+ host: '127.0.0.1',
+ port,
+ method,
+ path: reqPath,
+ headers,
+ }, (res) => {
+ const chunks = [];
+ res.on('data', (chunk) => chunks.push(chunk));
+ res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() }));
+ });
+ req.on('error', reject);
+ if (body) req.write(body);
+ req.end();
+ });
+}
+
describe('serve-question', () => {
it('opens Windows URLs through cmd.exe and reserves the start title argument', () => {
assert.deepEqual(
@@ -105,12 +125,85 @@ describe('serve-question', () => {
assert.ok(url, started.out);
const waiting = await run(['--wait', '--key', 'tk', '--poll', '1']);
assert.equal(waiting.code, 3);
- await fetch(`${url}answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'assigned', steer: '' }) });
+ await fetch(`${url}answer?key=tk`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'assigned', steer: '' }) });
const collected = await run(['--wait', '--key', 'tk', '--poll', '5']);
assert.equal(collected.code, 0);
assert.match(collected.out, /"optionId":"assigned"/);
});
+ it('rejects detached POSTs without the session key or with bad Host/Origin', async () => {
+ const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
+ const payloadPath = path.join(dir, 'q.json');
+ const key = 'seckey';
+ const answerPath = path.join(dir, '.impeccable', 'questions', `${key}.answer.json`);
+ writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
+ const run = (args) => new Promise((resolve) => {
+ const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] });
+ let out = '';
+ child.stdout.on('data', (chunk) => { out += chunk; });
+ child.on('exit', (code) => resolve({ code, out }));
+ });
+ const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', key]);
+ assert.equal(started.code, 0);
+ const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
+ assert.ok(url, started.out);
+ const port = Number(new URL(url).port);
+ const goodHost = `127.0.0.1:${port}`;
+ const body = JSON.stringify({ optionId: 'assigned', steer: '' });
+ const jsonHeaders = { 'content-type': 'application/json', Host: goodHost };
+
+ const noKey = await fetch(`http://${goodHost}/answer`, { method: 'POST', headers: jsonHeaders, body });
+ assert.equal(noKey.status, 401);
+ assert.equal(existsSync(answerPath), false);
+ const waiting = await run(['--wait', '--key', key, '--poll', '1']);
+ assert.equal(waiting.code, 3);
+
+ const wrongKey = await fetch(`http://${goodHost}/answer?key=wrong`, { method: 'POST', headers: jsonHeaders, body });
+ assert.equal(wrongKey.status, 401);
+
+ const evilOrigin = await rawRequest(port, {
+ method: 'POST',
+ path: `/answer?key=${key}`,
+ headers: { ...jsonHeaders, Origin: 'https://evil.example' },
+ }, body);
+ assert.equal(evilOrigin.status, 403);
+ assert.equal(existsSync(answerPath), false);
+
+ const spoofedHostPost = await rawRequest(port, {
+ method: 'POST',
+ path: `/answer?key=${key}`,
+ headers: { ...jsonHeaders, Host: `evil.example:${port}` },
+ }, body);
+ assert.equal(spoofedHostPost.status, 403);
+
+ const noKeyBeat = await fetch(`http://${goodHost}/heartbeat`, { method: 'POST', headers: { Host: goodHost } });
+ assert.equal(noKeyBeat.status, 401);
+
+ const spoofedHostGet = await rawRequest(port, {
+ path: '/',
+ headers: { Host: `evil.example:${port}` },
+ });
+ assert.equal(spoofedHostGet.status, 403);
+
+ const slashSlash = await rawRequest(port, {
+ path: '//',
+ headers: { Host: goodHost },
+ });
+ assert.equal(slashSlash.status, 400);
+ assert.equal((await fetch(url)).status, 200);
+
+ const html = await (await fetch(url)).text();
+ assert.match(html, /const KEY = "seckey"/);
+ assert.match(html, /\/answer' \+ keyQ/);
+ assert.match(html, /\/heartbeat' \+ keyQ/);
+
+ const ok = await fetch(`http://${goodHost}/answer?key=${key}`, { method: 'POST', headers: jsonHeaders, body });
+ assert.equal(ok.status, 200);
+ const collected = await run(['--wait', '--key', key, '--poll', '5']);
+ assert.equal(collected.code, 0);
+ assert.match(collected.out, /"optionId":"assigned"/);
+ });
+
it('headless detection spares the modes that never open a browser', async () => {
// Only the blocking serve path auto-opens a URL. --wait polls a daemon
// that is already running, --stop kills one, --schema just prints text,