UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

139 lines (138 loc) 6.21 kB
import { l as normalizeOptionalString } from "./string-coerce-CIXf7egm.js"; import { a as isLoopbackAddress, d as isTrustedProxyAddress, h as resolveClientIp, n as hasForwardedRequestHeaders, x as resolveRequestClientIpFromHeaders } from "./net-DbNPs6Xm.js"; import { o as readTailscaleWhoisIdentity } from "./tailscale-Boe4Jx-9.js"; //#region src/gateway/ingress-attribution.ts const PROXY_ATTRIBUTION_REQUIRED_REASON = "proxy_attribution_required"; const PROXY_ATTRIBUTION_GUIDANCE = "Configure gateway.trustedProxies narrowly and make the proxy overwrite or safely rebuild forwarded client headers."; const requestTransport = /* @__PURE__ */ new WeakMap(); const preparedAttribution = /* @__PURE__ */ new WeakMap(); /** Records listener-owned provenance before any request-time policy runs. */ function markGatewayIngressTransport(req, transport) { const existing = requestTransport.get(req); if (existing) { if (existing.kind !== transport.kind || existing.kind === "managed-tailscale" && transport.kind === "managed-tailscale" && existing.mode !== transport.mode) throw new Error("Gateway ingress transport was already assigned"); return; } requestTransport.set(req, transport); } function headerValue(value) { return Array.isArray(value) ? value[0] : value; } function unattributableProxy(remoteAddress) { return { kind: "unattributable-proxy", reason: PROXY_ATTRIBUTION_REQUIRED_REASON, guidance: PROXY_ATTRIBUTION_GUIDANCE, remoteAddress }; } function attributed(kind, clientIp) { return { kind, clientIp, rateLimit: { subject: { key: clientIp }, resetOnSuccess: true } }; } function hasTailscaleProxyHeaders(req) { const headers = req.headers ?? {}; return Boolean(headers["x-forwarded-for"] && headers["x-forwarded-proto"] && headers["x-forwarded-host"]); } function hasTailscaleOwnedHeaders(req) { const headers = req.headers ?? {}; return [ "tailscale-funnel-request", "tailscale-headers-info", "tailscale-user-login", "tailscale-user-name", "tailscale-user-profile-pic" ].some((name) => headers[name] !== void 0); } function resolveTailscaleClientIp(req) { return resolveClientIp({ remoteAddr: req.socket?.remoteAddress, forwardedFor: headerValue(req.headers?.["x-forwarded-for"]), trustedProxies: ["127.0.0.1", "::1"] }); } function resolveManagedTailscaleIngress(params) { const { req, mode, remoteAddress, tailscaleWhois } = params; if (!isLoopbackAddress(remoteAddress) || !hasTailscaleProxyHeaders(req)) return unattributableProxy(remoteAddress); const clientIp = resolveTailscaleClientIp(req); if (!clientIp || isLoopbackAddress(clientIp)) return unattributableProxy(remoteAddress); const funnelMarker = headerValue(req.headers?.["tailscale-funnel-request"]); if (mode === "funnel") return !funnelMarker || funnelMarker === "?1" ? attributed("tailscale-funnel", clientIp) : unattributableProxy(remoteAddress); if (funnelMarker) return unattributableProxy(remoteAddress); const headerLogin = normalizeOptionalString(req.headers?.["tailscale-user-login"]); const headerName = normalizeOptionalString(req.headers?.["tailscale-user-name"]); const profilePic = normalizeOptionalString(req.headers?.["tailscale-user-profile-pic"]); let identityPromise; const verifyIdentity = () => { if (!headerLogin) return Promise.resolve(void 0); identityPromise ??= (async () => { try { const whois = await tailscaleWhois(clientIp); if (!whois?.login || whois.login.toLowerCase() !== headerLogin.toLowerCase()) return; return { login: whois.login, name: whois.name ?? headerName ?? whois.login, ...profilePic ? { profilePic } : {} }; } catch { return; } })(); return identityPromise; }; return { ...attributed("tailscale-serve", clientIp), verifyIdentity }; } function resolveGatewayIngressAttribution(params) { const { req } = params; const remoteAddress = resolveClientIp({ remoteAddr: req.socket?.remoteAddress }) ?? req.socket?.remoteAddress ?? "unknown"; const transport = requestTransport.get(req) ?? { kind: "ordinary" }; if (transport.kind === "managed-tailscale") return resolveManagedTailscaleIngress({ req, mode: transport.mode, remoteAddress, tailscaleWhois: params.tailscaleWhois ?? readTailscaleWhoisIdentity }); const hasProxyHeaders = hasForwardedRequestHeaders(req); const hasTailscaleHeaders = hasTailscaleOwnedHeaders(req); if (isLoopbackAddress(remoteAddress) && !hasProxyHeaders && !hasTailscaleHeaders) return attributed("direct-local", remoteAddress); if (isTrustedProxyAddress(remoteAddress, params.trustedProxies)) { const clientIp = resolveRequestClientIpFromHeaders(req, params.trustedProxies, params.allowRealIpFallback === true); if (!clientIp || isLoopbackAddress(clientIp)) return unattributableProxy(remoteAddress); return { ...attributed("trusted-proxy", clientIp), ...headerValue(req.headers?.["tailscale-funnel-request"]) === "?1" ? { externalTailscaleExposure: "funnel" } : {} }; } if (hasProxyHeaders || hasTailscaleHeaders) return unattributableProxy(remoteAddress); return attributed("direct-remote", remoteAddress); } function prepareGatewayIngressAttribution(params) { const existing = preparedAttribution.get(params.req); if (existing) return existing; const prepared = resolveGatewayIngressAttribution(params); preparedAttribution.set(params.req, prepared); return prepared; } function readPreparedGatewayIngressAttribution(req) { return preparedAttribution.get(req); } /** Emits one actionable warning per runtime without attacker-controlled log growth. */ function createGatewayUnattributableProxyReporter(log) { let emitted = false; return (attribution) => { if (emitted) return; emitted = true; log.warn(`gateway: observed unattributable proxy-shaped traffic from ${attribution.remoteAddress}; Gateway-authenticated routes reject it, while plugin-authenticated routes ignore forwarded claims. ${attribution.guidance}`); }; } //#endregion export { prepareGatewayIngressAttribution as a, markGatewayIngressTransport as i, PROXY_ATTRIBUTION_REQUIRED_REASON as n, readPreparedGatewayIngressAttribution as o, createGatewayUnattributableProxyReporter as r, PROXY_ATTRIBUTION_GUIDANCE as t };