fix(daemon): accept any loopback origin in same-origin guard

The previous port-pinned check required the request's Origin to match
either the daemon's own port or OD_WEB_PORT. tools-dev does not pass
OD_WEB_PORT to the daemon process, so any browser POST to /api/active
proxied through the dev web (port 17573 etc.) was rejected with 403,
and get_active_context always returned {active: false}.

Relax to a loopback-prefix match: any http://127.0.0.1:*,
http://localhost:*, or http://[::1]:* origin passes regardless of
port. Cross-origin (https://evil.com) is still rejected. The
trade-off is that another local web app on a different loopback port
could now CSRF the daemon; same-origin checks are inherently a CSRF
defense, not a network ACL.
This commit is contained in:
Emil Neander
2026-05-03 22:21:58 +02:00
parent fb9d0ccd90
commit ae13094e74
3 changed files with 50 additions and 71 deletions
+15 -16
View File
@@ -2981,24 +2981,23 @@ function assembleExample(templateHtml, slidesHtml, title) {
);
}
export function isLocalSameOrigin(req, port) {
const ports = [port];
const webPort = Number(process.env.OD_WEB_PORT);
if (webPort && webPort !== port) ports.push(webPort);
// Accept any loopback Host and any loopback Origin (any port). The
// daemon serves the web on its own port in prod and proxies through
// a sibling port in dev (tools-dev), and the daemon process never
// learns the web's port at spawn time. Pinning to a single port broke
// the dev POST /api/active flow whenever Origin was http://127.0.0.1:
// <web-port>. Loopback-prefix matching keeps browser cross-origin
// rejection (evil.com is not loopback) while letting the proxy chain
// through. Raw LAN HTTP clients can spoof Host/Origin freely, so the
// real defense for LAN exposure is binding 127.0.0.1; this guard is
// a CSRF defense, not a network ACL.
const LOOPBACK_HOST_RE = /^(?:127\.0\.0\.1|localhost|\[::1\]):\d+$/;
const LOOPBACK_ORIGIN_RE = /^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d+$/;
const allowedHosts = new Set(
ports.flatMap((p) => [`127.0.0.1:${p}`, `localhost:${p}`, `[::1]:${p}`]),
);
const allowedOrigins = new Set(
ports.flatMap((p) => [
`http://127.0.0.1:${p}`,
`http://localhost:${p}`,
`http://[::1]:${p}`,
]),
);
export function isLocalSameOrigin(req, _port) {
const host = String(req.headers.host || '');
if (!allowedHosts.has(host)) return false;
if (!LOOPBACK_HOST_RE.test(host)) return false;
const origin = req.headers.origin;
if (origin == null || origin === '') return true;
return allowedOrigins.has(String(origin));
return LOOPBACK_ORIGIN_RE.test(String(origin));
}
+27 -42
View File
@@ -302,53 +302,38 @@ describe('app-config origin guard', () => {
expect(res.status).toBe(403);
});
it('allows GET when Origin is the trusted web port (proxy flow)', async () => {
it('allows GET when Origin is any loopback port (proxy flow)', async () => {
const webPort = port + 1;
process.env.OD_WEB_PORT = String(webPort);
try {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
headers: {
Host: `127.0.0.1:${port}`,
Origin: `http://127.0.0.1:${webPort}`,
},
});
expect(res.status).toBe(200);
} finally {
delete process.env.OD_WEB_PORT;
}
const res = await httpRequest(`${baseUrl}/api/app-config`, {
headers: {
Host: `127.0.0.1:${port}`,
Origin: `http://127.0.0.1:${webPort}`,
},
});
expect(res.status).toBe(200);
});
it('allows PUT when Origin is the trusted web port (proxy flow)', async () => {
it('allows PUT when Origin is any loopback port (proxy flow)', async () => {
const webPort = port + 1;
process.env.OD_WEB_PORT = String(webPort);
try {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Host: `127.0.0.1:${port}`,
Origin: `http://localhost:${webPort}`,
},
body: JSON.stringify({ onboardingCompleted: true }),
});
expect(res.status).toBe(200);
} finally {
delete process.env.OD_WEB_PORT;
}
const res = await httpRequest(`${baseUrl}/api/app-config`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Host: `127.0.0.1:${port}`,
Origin: `http://localhost:${webPort}`,
},
body: JSON.stringify({ onboardingCompleted: true }),
});
expect(res.status).toBe(200);
});
it('still rejects cross-origin even when OD_WEB_PORT is set', async () => {
process.env.OD_WEB_PORT = String(port + 1);
try {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
headers: {
Host: `127.0.0.1:${port}`,
Origin: 'https://evil.com',
},
});
expect(res.status).toBe(403);
} finally {
delete process.env.OD_WEB_PORT;
}
it('still rejects non-loopback Origin', async () => {
const res = await httpRequest(`${baseUrl}/api/app-config`, {
headers: {
Host: `127.0.0.1:${port}`,
Origin: 'https://evil.com',
},
});
expect(res.status).toBe(403);
});
});
+8 -13
View File
@@ -12,22 +12,17 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
// minimal Express app with the same handler shape rather than booting
// the full daemon (which needs SQLite, sidecar, fs scaffolding).
function isLocalSameOrigin(req, port) {
const allowedHosts = new Set([
`127.0.0.1:${port}`,
`localhost:${port}`,
`[::1]:${port}`,
]);
const allowedOrigins = new Set([
`http://127.0.0.1:${port}`,
`http://localhost:${port}`,
`http://[::1]:${port}`,
]);
// Mirrors the loopback-prefix guard in src/server.ts; the install
// panel must reject non-loopback origins regardless of port.
const LOOPBACK_HOST_RE = /^(?:127\.0\.0\.1|localhost|\[::1\]):\d+$/;
const LOOPBACK_ORIGIN_RE = /^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d+$/;
function isLocalSameOrigin(req, _port) {
const host = String(req.headers.host || '');
if (!allowedHosts.has(host)) return false;
if (!LOOPBACK_HOST_RE.test(host)) return false;
const origin = req.headers.origin;
if (origin == null || origin === '') return true;
return allowedOrigins.has(String(origin));
return LOOPBACK_ORIGIN_RE.test(String(origin));
}
interface InstallInfoOpts {