openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
428 lines (427 loc) • 16.4 kB
JavaScript
import { a as normalizeLowercaseStringOrEmpty, c as normalizeOptionalString, s as normalizeOptionalLowercaseString } from "./string-coerce-mnp54Vah.js";
import { c as readTailscaleWhoisIdentity } from "./tailscale-BOFw04AY.js";
import { t as safeEqualSecret } from "./secret-equal-DRsL8lKD.js";
import { p as isPrivateOrLoopbackIpAddress } from "./ip-0oQXo6_w.js";
import { _ as resolveRequestClientIp, c as isTrustedProxyAddress, f as resolveClientIp, g as resolveLocalInterfaceAddressMatch, h as resolveHostName, i as isLoopbackHost, r as isLoopbackAddress, u as normalizeHostHeader } from "./net-DTe7AQiu.js";
import { c as normalizeRateLimitClientIp } from "./auth-rate-limit-Bwr29t2Y.js";
import "./auth-resolve-BSKl_nHz.js";
import net from "node:net";
//#region src/gateway/origin-check.ts
function parseOrigin(originRaw) {
const trimmed = (originRaw ?? "").trim();
if (!trimmed || trimmed === "null") return null;
try {
const url = new URL(trimmed);
return {
origin: normalizeLowercaseStringOrEmpty(url.origin),
host: normalizeLowercaseStringOrEmpty(url.host),
hostname: normalizeLowercaseStringOrEmpty(url.hostname)
};
} catch {
return null;
}
}
/** Validate a browser Origin against explicit allowlist, same-host, and local dev rules. */
function checkBrowserOrigin(params) {
const parsedOrigin = parseOrigin(params.origin);
if (!parsedOrigin) return {
ok: false,
reason: "origin missing or invalid"
};
const allowlist = new Set((params.allowedOrigins ?? []).map((value) => normalizeOptionalLowercaseString(value)).filter(Boolean));
if (allowlist.has("*") || allowlist.has(parsedOrigin.origin)) return {
ok: true,
matchedBy: "allowlist"
};
const requestHost = normalizeHostHeader(params.requestHost);
if (params.allowHostHeaderOriginFallback === true && requestHost && parsedOrigin.host === requestHost) return {
ok: true,
matchedBy: "host-header-fallback"
};
if (requestHost && parsedOrigin.host === requestHost && isTrustedSameOriginHost(requestHost, params.isLocalClient)) return {
ok: true,
matchedBy: "private-same-origin"
};
if (params.isLocalClient && isLoopbackHost(parsedOrigin.hostname)) return {
ok: true,
matchedBy: "local-loopback"
};
return {
ok: false,
reason: "origin not allowed"
};
}
function isTrustedSameOriginHost(hostHeader, isLocalClient) {
const hostname = resolveHostName(hostHeader);
if (!hostname) return false;
if (isLoopbackHost(hostname)) return isLocalClient !== false;
if (net.isIP(hostname) !== 0) return isPrivateOrLoopbackIpAddress(hostname);
return hostname.endsWith(".local") || hostname.endsWith(".ts.net");
}
//#endregion
//#region src/gateway/rate-limit-attempt-serialization.ts
const pendingAttempts = /* @__PURE__ */ new Map();
function normalizeScope(scope) {
return (scope ?? "default").trim() || "default";
}
function buildSerializationKey(ip, scope) {
return `${normalizeScope(scope)}:${normalizeRateLimitClientIp(ip)}`;
}
/** Runs one rate-limit attempt after prior attempts for the same IP/scope finish. */
async function withSerializedRateLimitAttempt(params) {
const key = buildSerializationKey(params.ip, params.scope);
const previous = pendingAttempts.get(key) ?? Promise.resolve();
let releaseCurrent;
const current = new Promise((resolve) => {
releaseCurrent = resolve;
});
const tail = previous.catch(() => {}).then(() => current);
pendingAttempts.set(key, tail);
await previous.catch(() => {});
try {
return await params.run();
} finally {
releaseCurrent();
if (pendingAttempts.get(key) === tail) pendingAttempts.delete(key);
}
}
//#endregion
//#region src/gateway/auth.ts
const LEGACY_OPENCLAW_ENV_NOTE = " Legacy CLAWDBOT_* and MOLTBOT_* environment variables are ignored; use OPENCLAW_* names.";
function resolveGatewayAuthRequestContext(params) {
const { req, trustedProxies } = params;
const authSurface = params.authSurface ?? "http";
const ip = params.clientIp ?? resolveRequestClientIp(req, trustedProxies, params.allowRealIpFallback === true) ?? req?.socket?.remoteAddress;
return {
authSurface,
limiter: params.rateLimiter,
ip,
rateLimitScope: params.rateLimitScope ?? "shared-secret",
localDirect: isLocalDirectRequest(req, trustedProxies, params.allowRealIpFallback === true)
};
}
function hasExplicitSharedSecretAuth(connectAuth) {
return Boolean(normalizeOptionalString(connectAuth?.token) || normalizeOptionalString(connectAuth?.password));
}
function normalizeLogin(login) {
return normalizeLowercaseStringOrEmpty(login);
}
function headerValue(value) {
return Array.isArray(value) ? value[0] : value;
}
const TAILSCALE_TRUSTED_PROXIES = ["127.0.0.1", "::1"];
function resolveTailscaleClientIp(req) {
if (!req) return;
return resolveClientIp({
remoteAddr: req.socket?.remoteAddress ?? "",
forwardedFor: headerValue(req.headers?.["x-forwarded-for"]),
trustedProxies: [...TAILSCALE_TRUSTED_PROXIES]
});
}
/** Detect forwarded/proxy headers that make loopback requests ineligible for direct-local auth. */
/** Return true when forwarded headers make loopback direct-local auth unsafe. */
function hasForwardedRequestHeaders(req) {
if (!req) return false;
const headers = req.headers ?? {};
return Boolean(headers.forwarded || headers["x-real-ip"] || Object.keys(headers).some((header) => normalizeLowercaseStringOrEmpty(header).startsWith("x-forwarded-")));
}
/** Return whether a request is a clean loopback request without forwarded identity headers. */
function isLocalDirectRequest(req, _trustedProxies, _allowRealIpFallback = false) {
if (!req) return false;
if (!hasForwardedRequestHeaders(req)) return isLoopbackAddress(req.socket?.remoteAddress);
return false;
}
function getTailscaleUser(req) {
if (!req) return null;
const login = normalizeOptionalString(req.headers["tailscale-user-login"]);
if (!login) return null;
const nameRaw = req.headers["tailscale-user-name"];
const profilePic = req.headers["tailscale-user-profile-pic"];
return {
login,
name: normalizeOptionalString(nameRaw) ?? login,
profilePic: normalizeOptionalString(profilePic)
};
}
function hasTailscaleProxyHeaders(req) {
if (!req) return false;
return Boolean(req.headers["x-forwarded-for"] && req.headers["x-forwarded-proto"] && req.headers["x-forwarded-host"]);
}
function isTailscaleProxyRequest(req) {
if (!req) return false;
return isLoopbackAddress(req.socket?.remoteAddress) && hasTailscaleProxyHeaders(req);
}
async function resolveVerifiedTailscaleUser(params) {
const { req, tailscaleWhois } = params;
const tailscaleUser = getTailscaleUser(req);
if (!tailscaleUser) return {
ok: false,
reason: "tailscale_user_missing"
};
if (!isTailscaleProxyRequest(req)) return {
ok: false,
reason: "tailscale_proxy_missing"
};
const clientIp = resolveTailscaleClientIp(req);
if (!clientIp) return {
ok: false,
reason: "tailscale_whois_failed"
};
const whois = await tailscaleWhois(clientIp);
if (!whois?.login) return {
ok: false,
reason: "tailscale_whois_failed"
};
if (normalizeLogin(whois.login) !== normalizeLogin(tailscaleUser.login)) return {
ok: false,
reason: "tailscale_user_mismatch"
};
return {
ok: true,
user: {
login: whois.login,
name: whois.name ?? tailscaleUser.name,
profilePic: tailscaleUser.profilePic
}
};
}
/** Validate that the selected gateway auth mode has the required resolved credentials/config. */
function assertGatewayAuthConfigured(auth, rawAuthConfig) {
if (auth.mode === "token" && !auth.token) {
if (auth.allowTailscale) return;
throw new Error(`gateway auth mode is token, but no token was configured (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN).${LEGACY_OPENCLAW_ENV_NOTE}`);
}
if (auth.mode === "password" && !auth.password) {
if (rawAuthConfig?.password != null && typeof rawAuthConfig.password !== "string") throw new Error("gateway auth mode is password, but gateway.auth.password contains a provider reference object instead of a resolved string — bootstrap secrets (gateway.auth.password) must be plaintext strings or set via the OPENCLAW_GATEWAY_PASSWORD environment variable because the secrets provider system has not initialised yet at gateway startup");
throw new Error(`gateway auth mode is password, but no password was configured.${LEGACY_OPENCLAW_ENV_NOTE}`);
}
if (auth.mode === "trusted-proxy") {
if (!auth.trustedProxy) throw new Error("gateway auth mode is trusted-proxy, but no trustedProxy config was provided (set gateway.auth.trustedProxy)");
if (!auth.trustedProxy.userHeader || auth.trustedProxy.userHeader.trim() === "") throw new Error("gateway auth mode is trusted-proxy, but trustedProxy.userHeader is empty (set gateway.auth.trustedProxy.userHeader)");
if (auth.token) throw new Error("gateway auth mode is trusted-proxy, but a shared token is also configured; remove gateway.auth.token / OPENCLAW_GATEWAY_TOKEN because trusted-proxy and token auth are mutually exclusive");
}
}
/**
* Check if the request came from a trusted proxy and extract user identity.
* Returns the user identity if valid, or null with a reason if not.
*/
function authorizeTrustedProxy(params) {
const { req, trustedProxies, trustedProxyConfig } = params;
if (!req) return { reason: "trusted_proxy_no_request" };
const remoteAddr = req.socket?.remoteAddress;
if (!remoteAddr || !isTrustedProxyAddress(remoteAddr, trustedProxies)) return { reason: "trusted_proxy_untrusted_source" };
const remoteIsLoopback = isLoopbackAddress(remoteAddr);
if (remoteIsLoopback && trustedProxyConfig.allowLoopback !== true) return { reason: "trusted_proxy_loopback_source" };
if (!remoteIsLoopback) {
const localInterfaceMatch = resolveLocalInterfaceAddressMatch(remoteAddr);
if (localInterfaceMatch === void 0) return { reason: "trusted_proxy_local_interface_check_failed" };
if (localInterfaceMatch) return { reason: "trusted_proxy_local_interface_source" };
}
const requiredHeaders = trustedProxyConfig.requiredHeaders ?? [];
for (const header of requiredHeaders) {
const value = headerValue(req.headers[normalizeLowercaseStringOrEmpty(header)]);
if (!value || value.trim() === "") return { reason: `trusted_proxy_missing_header_${header}` };
}
const userHeaderValue = headerValue(req.headers[normalizeLowercaseStringOrEmpty(trustedProxyConfig.userHeader)]);
if (!userHeaderValue || userHeaderValue.trim() === "") return { reason: "trusted_proxy_user_missing" };
const user = userHeaderValue.trim();
const allowUsers = trustedProxyConfig.allowUsers ?? [];
if (allowUsers.length > 0 && !allowUsers.includes(user)) return { reason: "trusted_proxy_user_not_allowed" };
return { user };
}
function shouldAllowTailscaleHeaderAuth(authSurface) {
return authSurface === "ws-control-ui";
}
function authorizeTrustedProxyBrowserOrigin(params) {
if (params.authSurface !== "http") return null;
const origin = params.browserOriginPolicy?.origin?.trim();
if (!origin) return null;
if (checkBrowserOrigin({
requestHost: params.browserOriginPolicy?.requestHost,
origin,
allowedOrigins: params.browserOriginPolicy?.allowedOrigins,
allowHostHeaderOriginFallback: params.browserOriginPolicy?.allowHostHeaderOriginFallback,
isLocalClient: false
}).ok) return null;
return {
ok: false,
reason: "trusted_proxy_origin_not_allowed"
};
}
function authorizeTokenAuth(params) {
if (!params.authToken) return {
ok: false,
reason: "token_missing_config"
};
if (!params.connectToken) return {
ok: false,
reason: "token_missing"
};
if (!safeEqualSecret(params.connectToken, params.authToken)) {
params.limiter?.recordFailure(params.ip, params.rateLimitScope);
return {
ok: false,
reason: "token_mismatch"
};
}
params.limiter?.reset(params.ip, params.rateLimitScope);
return {
ok: true,
method: "token"
};
}
function authorizePasswordAuth(params) {
if (!params.authPassword) return {
ok: false,
reason: "password_missing_config"
};
if (!params.connectPassword) return {
ok: false,
reason: "password_missing"
};
if (!safeEqualSecret(params.connectPassword, params.authPassword)) {
params.limiter?.recordFailure(params.ip, params.rateLimitScope);
return {
ok: false,
reason: "password_mismatch"
};
}
params.limiter?.reset(params.ip, params.rateLimitScope);
return {
ok: true,
method: "password"
};
}
function rejectIfRateLimited(params) {
if (!params.limiter) return;
const rlCheck = params.limiter.check(params.ip, params.rateLimitScope);
if (rlCheck.allowed) return;
return {
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: rlCheck.retryAfterMs
};
}
/** Authorize a gateway connection, including rate-limit handling around shared-secret failures. */
async function authorizeGatewayConnect(params) {
const { auth } = params;
const { authSurface, limiter, ip, rateLimitScope, localDirect } = resolveGatewayAuthRequestContext(params);
if (limiter && shouldAllowTailscaleHeaderAuth(authSurface) && auth.allowTailscale && !localDirect) return await withSerializedRateLimitAttempt({
ip,
scope: rateLimitScope,
run: async () => await authorizeGatewayConnectCore(params)
});
return await authorizeGatewayConnectCore(params);
}
async function authorizeGatewayConnectCore(params) {
const { auth, connectAuth, req, trustedProxies } = params;
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
const { authSurface, limiter, ip, rateLimitScope, localDirect } = resolveGatewayAuthRequestContext(params);
const allowTailscaleHeaderAuth = shouldAllowTailscaleHeaderAuth(authSurface);
if (auth.mode === "trusted-proxy") {
if (!auth.trustedProxy) return {
ok: false,
reason: "trusted_proxy_config_missing"
};
if (!trustedProxies || trustedProxies.length === 0) return {
ok: false,
reason: "trusted_proxy_no_proxies_configured"
};
const result = authorizeTrustedProxy({
req,
trustedProxies,
trustedProxyConfig: auth.trustedProxy
});
if ("user" in result) {
const originResult = authorizeTrustedProxyBrowserOrigin({
authSurface,
browserOriginPolicy: params.browserOriginPolicy
});
if (originResult) return originResult;
return {
ok: true,
method: "trusted-proxy",
user: result.user
};
}
if (localDirect && auth.password && connectAuth?.password) {
const rateLimitResult = rejectIfRateLimited({
limiter,
ip,
rateLimitScope
});
if (rateLimitResult) return rateLimitResult;
return authorizePasswordAuth({
authPassword: auth.password,
connectPassword: connectAuth.password,
limiter,
ip,
rateLimitScope
});
}
return {
ok: false,
reason: result.reason
};
}
if (auth.mode === "none") return {
ok: true,
method: "none"
};
const rateLimitResult = rejectIfRateLimited({
limiter,
ip,
rateLimitScope
});
if (rateLimitResult) return rateLimitResult;
if (allowTailscaleHeaderAuth && auth.allowTailscale && !localDirect && !hasExplicitSharedSecretAuth(connectAuth)) {
const tailscaleCheck = await resolveVerifiedTailscaleUser({
req,
tailscaleWhois
});
if (tailscaleCheck.ok) {
limiter?.reset(ip, rateLimitScope);
return {
ok: true,
method: "tailscale",
user: tailscaleCheck.user.login
};
}
}
if (auth.mode === "token") return authorizeTokenAuth({
authToken: auth.token,
connectToken: connectAuth?.token,
limiter,
ip,
rateLimitScope
});
if (auth.mode === "password") return authorizePasswordAuth({
authPassword: auth.password,
connectPassword: connectAuth?.password,
limiter,
ip,
rateLimitScope
});
limiter?.recordFailure(ip, rateLimitScope);
return {
ok: false,
reason: "unauthorized"
};
}
/** Authorize an HTTP gateway request with Tailscale forwarded-header auth disabled. */
async function authorizeHttpGatewayConnect(params) {
return authorizeGatewayConnect({
...params,
authSurface: "http"
});
}
/** Authorize a Control UI websocket request with the WS-specific auth surface. */
async function authorizeWsControlUiGatewayConnect(params) {
return authorizeGatewayConnect({
...params,
authSurface: "ws-control-ui"
});
}
//#endregion
export { hasForwardedRequestHeaders as a, checkBrowserOrigin as c, authorizeWsControlUiGatewayConnect as i, authorizeGatewayConnect as n, isLocalDirectRequest as o, authorizeHttpGatewayConnect as r, withSerializedRateLimitAttempt as s, assertGatewayAuthConfigured as t };