fix(daemon): honor hostname OD_ALLOWED_ORIGINS for no-Origin same-origin GETs (#2478)

Browsers omit the Origin header on same-origin GET requests per the Fetch
spec, but the no-Origin host-only branch in isLocalSameOrigin only honors
IP-literal entries in OD_ALLOWED_ORIGINS (via ipOnlyExtraOrigins). When a
remote-access proxy (Tailscale Serve, Caddy, etc.) terminates TLS at a
non-loopback hostname listed in OD_ALLOWED_ORIGINS, those legitimate
same-origin GETs to /api/app-config, /api/mcp/servers, etc. get rejected
with "cross-origin request rejected" 403s while the matching POST/PUTs
(which always carry Origin) succeed.

Trust Sec-Fetch-Site: same-origin as a fallback when the Origin header is
absent. The Sec-* prefix is browser-set and cannot be modified by
JavaScript, so a value of "same-origin" attests that the request really
originated from the same origin as the target — a cross-site
<img>/<script> exploit would carry "cross-site" instead, leaving the
existing CSRF defenses intact.

Tests:
- pnpm --filter @open-design/daemon test -- tests/origin-validation.test.ts → 43 passed (5 new)
This commit is contained in:
Weston Houghton
2026-05-21 19:15:07 +08:00
committed by GitHub
parent 0108d3a655
commit ce3891f20f
2 changed files with 93 additions and 1 deletions
+18 -1
View File
@@ -8,6 +8,7 @@ export interface RequestWithOriginHeaders {
headers?: {
host?: unknown;
origin?: unknown;
'sec-fetch-site'?: unknown;
};
}
@@ -166,7 +167,23 @@ export function isLocalSameOrigin(
);
const localHostAllowed = isAllowedBrowserHost(host, ports, bindHost, ipOnlyExtraOrigins);
if (origin == null || origin === '') return localHostAllowed;
if (origin == null || origin === '') {
if (localHostAllowed) return true;
// Browsers (Firefox, Chrome) omit Origin on same-origin GET subresource
// requests per the Fetch spec, which made hostname entries in
// OD_ALLOWED_ORIGINS unreachable for legitimate same-origin GETs
// through a reverse proxy. Sec-Fetch-Site is set by the user agent and
// cannot be modified by JavaScript, so a value of "same-origin"
// attests that the request originated from the same origin as the
// target — a cross-site `<img>`/`<script>` exploit would carry
// "cross-site" instead. Only consult the broader allow-list once that
// signal is present.
const fetchSite = headerValue(req.headers?.['sec-fetch-site']);
if (fetchSite === 'same-origin') {
return isAllowedBrowserHost(host, ports, bindHost, extraAllowedOrigins);
}
return false;
}
// Reverse-proxy deployments (e.g. Nginx in front of the daemon) terminate
// the browser connection at the proxy and open a fresh upstream
// connection to the daemon. The Host header the daemon sees is the
@@ -561,3 +561,78 @@ describe('isLocalSameOrigin: OD_ALLOWED_ORIGINS bypass for reverse-proxy deploym
expect(isLocalSameOrigin(req, 7457, env)).toBe(false);
});
});
// Firefox and Chrome omit the Origin header on same-origin GET requests per
// the Fetch spec. When the daemon runs behind a remote-access proxy whose
// public hostname is listed in OD_ALLOWED_ORIGINS, those legitimate
// same-origin GETs (e.g. /api/app-config) get rejected by the no-Origin
// host check because hostname entries in OD_ALLOWED_ORIGINS are only
// honored via the IP-literal subset in that branch. Sec-Fetch-Site is set
// by the browser and cannot be modified by JavaScript, so a value of
// "same-origin" is a trustworthy substitute for the missing Origin header.
describe('isLocalSameOrigin: Sec-Fetch-Site fallback for no-Origin same-origin GETs', () => {
const ALLOWED = 'https://nas.example.ts.net';
const previousAllowedOrigins = process.env.OD_ALLOWED_ORIGINS;
const env: NodeJS.ProcessEnv = {
...process.env,
OD_ALLOWED_ORIGINS: ALLOWED,
OD_BIND_HOST: '127.0.0.1',
};
beforeAll(() => {
process.env.OD_ALLOWED_ORIGINS = ALLOWED;
});
afterAll(() => {
if (previousAllowedOrigins === undefined) delete process.env.OD_ALLOWED_ORIGINS;
else process.env.OD_ALLOWED_ORIGINS = previousAllowedOrigins;
});
it('accepts a no-Origin request whose Host matches OD_ALLOWED_ORIGINS when Sec-Fetch-Site is same-origin', () => {
const req = {
headers: {
host: 'nas.example.ts.net',
'sec-fetch-site': 'same-origin',
},
};
expect(isLocalSameOrigin(req, 7456, env)).toBe(true);
});
it('still rejects a no-Origin request whose Host matches the allow-list but Sec-Fetch-Site is cross-site', () => {
const req = {
headers: {
host: 'nas.example.ts.net',
'sec-fetch-site': 'cross-site',
},
};
expect(isLocalSameOrigin(req, 7456, env)).toBe(false);
});
it('still rejects a no-Origin request whose Host matches the allow-list but Sec-Fetch-Site is same-site', () => {
const req = {
headers: {
host: 'nas.example.ts.net',
'sec-fetch-site': 'same-site',
},
};
expect(isLocalSameOrigin(req, 7456, env)).toBe(false);
});
it('still rejects a no-Origin request whose Host is foreign even with Sec-Fetch-Site: same-origin (Host alone is forgeable)', () => {
const req = {
headers: {
host: 'evil.example.com',
'sec-fetch-site': 'same-origin',
},
};
expect(isLocalSameOrigin(req, 7456, env)).toBe(false);
});
it('preserves no-Sec-Fetch-Site rejection (older / non-browser clients fall back to host-only check)', () => {
const req = {
headers: {
host: 'nas.example.ts.net',
},
};
expect(isLocalSameOrigin(req, 7456, env)).toBe(false);
});
});