mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user