mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
dde6228121
* feat(daemon): add OD_ALLOWED_INTERNAL_HOSTS allowlist for internal provider endpoints Fixes #3225. The 0.8.0 SSRF hardening blocks provider base URLs that resolve to private/internal ranges, with no way to opt a trusted host out -- breaking internally-hosted LiteLLM/Ollama gateways on VPN-only RFC1918 addresses. Add an operator opt-in: OD_ALLOWED_INTERNAL_HOSTS exempts listed hosts from the internal-IP guard, threaded only into user-configured provider endpoints (connection test, model discovery, BYOK chat). The attacker-controllable asset-download guard stays strict. Malformed and CIDR entries are dropped with a warning rather than silently trusted. The Settings base-URL field no longer rejects internal-IP URLs client-side, deferring the authoritative decision to the daemon. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(e2e): update BYOK base-URL P0 for relaxed internal-IP validation (#3225) The P0 spec used an internal-IP URL as its 'invalid' example to assert the client-side error; that URL is now accepted (the daemon owns the allowlist decision). Switch to a non-http scheme, which is still rejected client-side. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): defer internal-IP base URL to the daemon in BYOK draft validation (#3225) isValidApiBaseUrl was relaxed, but the Test and model-fetch actions gate on validateByokDraft -> blockingByokDraftIssues, which still treated any validateBaseUrl error (including a forbidden internal IP) as a blocking base_url_invalid. So the Test button appeared but the action stayed blocked client-side and never reached the daemon allowlist. Treat a syntactically-valid forbidden URL as non-blocking here too; genuinely malformed / non-http URLs still block. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
export interface ParsedHostHeader {
|
|
hostname: string;
|
|
host: string;
|
|
port: string;
|
|
}
|
|
|
|
export interface RequestWithOriginHeaders {
|
|
headers?: {
|
|
host?: unknown;
|
|
origin?: unknown;
|
|
'sec-fetch-site'?: unknown;
|
|
};
|
|
}
|
|
|
|
export function configuredAllowedOrigins(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
const raw = env.OD_ALLOWED_ORIGINS || '';
|
|
if (!raw.trim()) return [];
|
|
return raw
|
|
.split(',')
|
|
.map((origin) => origin.trim())
|
|
.filter(Boolean)
|
|
.map((origin) => {
|
|
const parsed = new URL(origin);
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
throw new Error('OD_ALLOWED_ORIGINS only supports http:// and https:// origins');
|
|
}
|
|
return parsed.origin;
|
|
});
|
|
}
|
|
|
|
export function configuredAllowedHosts(origins = configuredAllowedOrigins()): string[] {
|
|
return origins.map((origin) => new URL(origin).host);
|
|
}
|
|
|
|
// Issue #3225 — operator-declared allowlist of internal hosts that are exempt
|
|
// from the default-deny SSRF guard for USER-CONFIGURED provider endpoints (an
|
|
// internally-hosted LiteLLM/Ollama on an RFC1918 address reachable only over
|
|
// VPN). Comma- or whitespace-separated; each entry may be a bare host
|
|
// (`10.0.0.5`), `host:port`, or a full URL — only the hostname is retained,
|
|
// since the SSRF block is host-based. IPv6 literals must be bracketed
|
|
// (`[fd00::1]` or `[fd00::1]:4000`) so the port is unambiguous; the brackets
|
|
// are stripped here so the result compares directly against a resolved
|
|
// address. An empty/unset value yields `[]`, preserving the strict default
|
|
// for every deployment that does not opt in.
|
|
//
|
|
// A malformed entry — or CIDR notation, which the host-based matcher cannot
|
|
// honor — is dropped with a warning rather than silently trusted, so a typo
|
|
// can never quietly widen (or quietly fail to widen) the guard. This list is
|
|
// threaded ONLY into the user-configured-endpoint validators, never the
|
|
// attacker-controllable asset-download guard (`assertExternalAssetUrl`).
|
|
export function configuredAllowedInternalHosts(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
warn: (message: string) => void = (message) => console.warn(message),
|
|
): string[] {
|
|
const raw = env.OD_ALLOWED_INTERNAL_HOSTS || '';
|
|
if (!raw.trim()) return [];
|
|
const hosts: string[] = [];
|
|
for (const part of raw.split(/[,\s]+/)) {
|
|
const entry = part.trim();
|
|
if (!entry) continue;
|
|
// A bare `10.0.0.0/24` parses as host `10.0.0.0` + path `/24`, so keeping
|
|
// it would silently trust a single network address the operator never
|
|
// meant. Reject loudly. (A full URL whose path happens to look numeric
|
|
// still carries a scheme, so exclude those from the CIDR heuristic.)
|
|
if (!entry.includes('://') && /^[^/]+\/\d{1,3}$/.test(entry)) {
|
|
warn(
|
|
`[ssrf] ignoring CIDR entry in OD_ALLOWED_INTERNAL_HOSTS: ${JSON.stringify(entry)} — list individual hosts instead`,
|
|
);
|
|
continue;
|
|
}
|
|
let hostname = '';
|
|
try {
|
|
const url = new URL(entry.includes('://') ? entry : `http://${entry}`);
|
|
hostname = url.hostname.toLowerCase();
|
|
if (hostname.startsWith('[') && hostname.endsWith(']')) {
|
|
hostname = hostname.slice(1, -1);
|
|
}
|
|
} catch {
|
|
hostname = '';
|
|
}
|
|
if (!hostname) {
|
|
warn(
|
|
`[ssrf] ignoring malformed OD_ALLOWED_INTERNAL_HOSTS entry: ${JSON.stringify(entry)}`,
|
|
);
|
|
continue;
|
|
}
|
|
hosts.push(hostname);
|
|
}
|
|
return hosts;
|
|
}
|
|
|
|
export function allowedBrowserPorts(
|
|
port: number | string | null | undefined,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): number[] {
|
|
const ports = [];
|
|
const primary = Number(port);
|
|
if (primary) ports.push(primary);
|
|
const webPort = Number(env.OD_WEB_PORT);
|
|
if (webPort && webPort !== primary) ports.push(webPort);
|
|
return ports;
|
|
}
|
|
|
|
export function parseHostHeader(value: unknown): ParsedHostHeader | null {
|
|
const raw = String(headerValue(value) || '').trim();
|
|
if (!raw) return null;
|
|
try {
|
|
const parsed = new URL(`http://${raw}`);
|
|
return { hostname: parsed.hostname, host: parsed.host, port: parsed.port || '80' };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function isPrivateIpv4(hostname: unknown): boolean {
|
|
const parts = String(hostname || '').split('.');
|
|
if (parts.length !== 4) return false;
|
|
if (!parts.every((part) => /^\d+$/.test(part))) return false;
|
|
const octets = parts.map((part) => Number(part));
|
|
if (!octets.every((n) => Number.isInteger(n) && n >= 0 && n <= 255)) return false;
|
|
const [a, b] = octets as [number, number, number, number];
|
|
return (
|
|
a === 10 ||
|
|
(a === 172 && b >= 16 && b <= 31) ||
|
|
(a === 192 && b === 168) ||
|
|
(a === 169 && b === 254)
|
|
);
|
|
}
|
|
|
|
export function isIpLiteralHostname(hostname: unknown): boolean {
|
|
const host = String(hostname || '').trim();
|
|
if (!host) return false;
|
|
if (host.startsWith('[') && host.endsWith(']')) return true;
|
|
const parts = host.split('.');
|
|
if (parts.length !== 4) return false;
|
|
if (!parts.every((part) => /^\d+$/.test(part))) return false;
|
|
return parts.map(Number).every((n) => Number.isInteger(n) && n >= 0 && n <= 255);
|
|
}
|
|
|
|
export function isLoopbackOrPrivateLanHost(hostname: unknown): boolean {
|
|
const host = String(hostname || '').toLowerCase();
|
|
return (
|
|
host === 'localhost' ||
|
|
host === '127.0.0.1' ||
|
|
host === '::1' ||
|
|
host === '[::1]' ||
|
|
host === '0.0.0.0' ||
|
|
host === '::' ||
|
|
isPrivateIpv4(host)
|
|
);
|
|
}
|
|
|
|
export function isAllowedBrowserHost(
|
|
hostHeader: unknown,
|
|
ports: number[],
|
|
bindHost: string,
|
|
extraAllowedOrigins: string[],
|
|
): boolean {
|
|
const requestHost = parseHostHeader(hostHeader);
|
|
if (!requestHost) return false;
|
|
|
|
const loopbackHosts = ['127.0.0.1', 'localhost', '[::1]'];
|
|
const explicitHosts = new Set([
|
|
...ports.flatMap((p) => [
|
|
...loopbackHosts.map((h) => `${h}:${p}`),
|
|
`${bindHost}:${p}`,
|
|
]),
|
|
...configuredAllowedHosts(extraAllowedOrigins),
|
|
]);
|
|
if (explicitHosts.has(requestHost.host)) return true;
|
|
|
|
if (!ports.map(String).includes(requestHost.port)) return false;
|
|
return isLoopbackOrPrivateLanHost(requestHost.hostname);
|
|
}
|
|
|
|
export function isAllowedBrowserOrigin(
|
|
origin: unknown,
|
|
hostHeader: unknown,
|
|
ports: number[],
|
|
bindHost: string,
|
|
extraAllowedOrigins: string[],
|
|
): boolean {
|
|
if (extraAllowedOrigins.includes(String(origin))) return true;
|
|
|
|
let parsedOrigin;
|
|
try {
|
|
parsedOrigin = new URL(String(origin));
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (parsedOrigin.protocol !== 'http:' && parsedOrigin.protocol !== 'https:') return false;
|
|
|
|
const requestHost = parseHostHeader(hostHeader);
|
|
if (!requestHost) return false;
|
|
|
|
const schemes = ['http', 'https'];
|
|
const loopbackHosts = ['127.0.0.1', 'localhost', '[::1]'];
|
|
const explicitOrigins = new Set(
|
|
ports.flatMap((p) => [
|
|
...schemes.flatMap((s) => loopbackHosts.map((h) => `${s}://${h}:${p}`)),
|
|
...schemes.map((s) => `${s}://${bindHost}:${p}`),
|
|
]),
|
|
);
|
|
if (explicitOrigins.has(String(origin))) return true;
|
|
|
|
const originPort = parsedOrigin.port || (parsedOrigin.protocol === 'https:' ? '443' : '80');
|
|
if (!ports.map(String).includes(originPort)) return false;
|
|
if (parsedOrigin.hostname !== requestHost.hostname) return false;
|
|
return isLoopbackOrPrivateLanHost(parsedOrigin.hostname);
|
|
}
|
|
|
|
export function isLocalSameOrigin(
|
|
req: RequestWithOriginHeaders,
|
|
port: number | string | null | undefined,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): boolean {
|
|
const host = String(headerValue(req.headers?.host) || '');
|
|
const origin = headerValue(req.headers?.origin);
|
|
const ports = allowedBrowserPorts(port, env);
|
|
const bindHost = env.OD_BIND_HOST || '127.0.0.1';
|
|
const extraAllowedOrigins = configuredAllowedOrigins(env);
|
|
const ipOnlyExtraOrigins = extraAllowedOrigins.filter((o) =>
|
|
isIpLiteralHostname(new URL(o).hostname),
|
|
);
|
|
|
|
const localHostAllowed = isAllowedBrowserHost(host, ports, bindHost, ipOnlyExtraOrigins);
|
|
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
|
|
// proxy upstream's address, not the browser-visible origin, so the host
|
|
// check below fails even when the user explicitly listed their proxy
|
|
// origin in OD_ALLOWED_ORIGINS. Trust the Origin header in that case:
|
|
// a client-supplied origin that exactly matches an explicitly allow-
|
|
// listed entry is the documented escape hatch for these deployments.
|
|
if (extraAllowedOrigins.includes(origin)) return true;
|
|
if (!isAllowedBrowserHost(host, ports, bindHost, extraAllowedOrigins)) return false;
|
|
return isAllowedBrowserOrigin(origin, host, ports, bindHost, extraAllowedOrigins);
|
|
}
|
|
|
|
function headerValue(value: unknown): string | undefined {
|
|
if (Array.isArray(value)) {
|
|
const first = value[0];
|
|
return first == null ? undefined : String(first);
|
|
}
|
|
return value == null ? undefined : String(value);
|
|
}
|
|
|
|
/**
|
|
* Zero-config OD Clipper bypass for the `/api` origin middleware.
|
|
*
|
|
* `apiRelativePath` MUST be `req.path` as observed INSIDE `app.use('/api', …)`.
|
|
* Express strips the mounted `/api` prefix there, so a request to
|
|
* `/api/library/ingest` arrives as `/library/ingest`. Matching `'/library/'`
|
|
* is therefore the correct (and only working) prefix — matching
|
|
* `'/api/library/'` here would never fire because the mount prefix is gone.
|
|
*
|
|
* Returns true only for a locally-installed browser extension (an origin a web
|
|
* page cannot forge) targeting the narrow OD Clipper bootstrap surface: the
|
|
* dedicated probe route and the ingest endpoint. Library reads still require
|
|
* normal same-origin / allow-list validation so an unrelated installed
|
|
* extension cannot enumerate or download the user's library.
|
|
*/
|
|
export function isZeroConfigClipperLibraryRequest(
|
|
method: string,
|
|
apiRelativePath: string,
|
|
origin: unknown,
|
|
): boolean {
|
|
const normalizedMethod = method.toUpperCase();
|
|
const isAllowedClipperPath =
|
|
(normalizedMethod === 'GET' && apiRelativePath === '/library/clipper-probe') ||
|
|
((normalizedMethod === 'OPTIONS' || normalizedMethod === 'POST') &&
|
|
apiRelativePath === '/library/ingest');
|
|
if (!isAllowedClipperPath) return false;
|
|
return (
|
|
typeof origin === 'string' &&
|
|
(origin.startsWith('chrome-extension://') || origin.startsWith('moz-extension://'))
|
|
);
|
|
}
|