Files
moltbot/src/gateway/auth.ts
Vincent Koc 42e3d8d693 Secrets: add inline allowlist review set (#38314)
* Secrets: add inline allowlist review set

* Secrets: narrow detect-secrets file exclusions

* Secrets: exclude Docker fingerprint false positive

* Secrets: allowlist test and docs false positives

* Secrets: refresh baseline after allowlist updates

* Secrets: fix gateway chat fixture pragma

* Secrets: format pre-commit config

* Android: keep talk mode fixture JSON valid

* Feishu: rely on client timeout injection

* Secrets: allowlist provider auth test fixtures

* Secrets: allowlist onboard search fixtures

* Secrets: allowlist onboard mode fixture

* Secrets: allowlist gateway auth mode fixture

* Secrets: allowlist APNS wake test key

* Secrets: allowlist gateway reload fixtures

* Secrets: allowlist moonshot video fixture

* Secrets: allowlist auto audio fixture

* Secrets: allowlist tiny audio fixture

* Secrets: allowlist embeddings fixtures

* Secrets: allowlist resolve fixtures

* Secrets: allowlist target registry pattern fixtures

* Secrets: allowlist gateway chat env fixture

* Secrets: refresh baseline after fixture allowlists

* Secrets: reapply gateway chat env allowlist

* Secrets: reapply gateway chat env allowlist

* Secrets: stabilize gateway chat env allowlist

* Secrets: allowlist runtime snapshot save fixture

* Secrets: allowlist oauth profile fixtures

* Secrets: allowlist compaction identifier fixture

* Secrets: allowlist model auth fixture

* Secrets: allowlist model status fixtures

* Secrets: allowlist custom onboarding fixture

* Secrets: allowlist mattermost token summary fixtures

* Secrets: allowlist gateway auth suite fixtures

* Secrets: allowlist channel summary fixture

* Secrets: allowlist provider usage auth fixtures

* Secrets: allowlist media proxy fixture

* Secrets: allowlist secrets audit fixtures

* Secrets: refresh baseline after final fixture allowlists

* Feishu: prefer explicit client timeout

* Feishu: test direct timeout precedence
2026-03-06 19:35:26 -05:00

491 lines
15 KiB
TypeScript

import type { IncomingMessage } from "node:http";
import type {
GatewayAuthConfig,
GatewayTailscaleMode,
GatewayTrustedProxyConfig,
} from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
import { safeEqualSecret } from "../security/secret-equal.js";
import {
AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
type AuthRateLimiter,
type RateLimitCheckResult,
} from "./auth-rate-limit.js";
import { resolveGatewayCredentialsFromValues } from "./credentials.js";
import {
isLocalishHost,
isLoopbackAddress,
isTrustedProxyAddress,
resolveClientIp,
} from "./net.js";
export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy";
export type ResolvedGatewayAuthModeSource =
| "override"
| "config"
| "password"
| "token"
| "default";
export type ResolvedGatewayAuth = {
mode: ResolvedGatewayAuthMode;
modeSource?: ResolvedGatewayAuthModeSource;
token?: string;
password?: string;
allowTailscale: boolean;
trustedProxy?: GatewayTrustedProxyConfig;
};
export type GatewayAuthResult = {
ok: boolean;
method?: "none" | "token" | "password" | "tailscale" | "device-token" | "trusted-proxy";
user?: string;
reason?: string;
/** Present when the request was blocked by the rate limiter. */
rateLimited?: boolean;
/** Milliseconds the client should wait before retrying (when rate-limited). */
retryAfterMs?: number;
};
type ConnectAuth = {
token?: string;
password?: string;
};
export type GatewayAuthSurface = "http" | "ws-control-ui";
export type AuthorizeGatewayConnectParams = {
auth: ResolvedGatewayAuth;
connectAuth?: ConnectAuth | null;
req?: IncomingMessage;
trustedProxies?: string[];
tailscaleWhois?: TailscaleWhoisLookup;
/**
* Explicit auth surface. HTTP keeps Tailscale forwarded-header auth disabled.
* WS Control UI enables it intentionally for tokenless trusted-host login.
*/
authSurface?: GatewayAuthSurface;
/** Optional rate limiter instance; when provided, failed attempts are tracked per IP. */
rateLimiter?: AuthRateLimiter;
/** Client IP used for rate-limit tracking. Falls back to proxy-aware request IP resolution. */
clientIp?: string;
/** Optional limiter scope; defaults to shared-secret auth scope. */
rateLimitScope?: string;
/** Trust X-Real-IP only when explicitly enabled. */
allowRealIpFallback?: boolean;
};
type TailscaleUser = {
login: string;
name: string;
profilePic?: string;
};
type TailscaleWhoisLookup = (ip: string) => Promise<TailscaleWhoisIdentity | null>;
function normalizeLogin(login: string): string {
return login.trim().toLowerCase();
}
function headerValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
const TAILSCALE_TRUSTED_PROXIES = ["127.0.0.1", "::1"] as const;
function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined {
if (!req) {
return undefined;
}
return resolveClientIp({
remoteAddr: req.socket?.remoteAddress ?? "",
forwardedFor: headerValue(req.headers?.["x-forwarded-for"]),
trustedProxies: [...TAILSCALE_TRUSTED_PROXIES],
});
}
function resolveRequestClientIp(
req?: IncomingMessage,
trustedProxies?: string[],
allowRealIpFallback = false,
): string | undefined {
if (!req) {
return undefined;
}
return resolveClientIp({
remoteAddr: req.socket?.remoteAddress ?? "",
forwardedFor: headerValue(req.headers?.["x-forwarded-for"]),
realIp: headerValue(req.headers?.["x-real-ip"]),
trustedProxies,
allowRealIpFallback,
});
}
export function isLocalDirectRequest(
req?: IncomingMessage,
trustedProxies?: string[],
allowRealIpFallback = false,
): boolean {
if (!req) {
return false;
}
const clientIp = resolveRequestClientIp(req, trustedProxies, allowRealIpFallback) ?? "";
if (!isLoopbackAddress(clientIp)) {
return false;
}
const hasForwarded = Boolean(
req.headers?.["x-forwarded-for"] ||
req.headers?.["x-real-ip"] ||
req.headers?.["x-forwarded-host"],
);
const remoteIsTrustedProxy = isTrustedProxyAddress(req.socket?.remoteAddress, trustedProxies);
return isLocalishHost(req.headers?.host) && (!hasForwarded || remoteIsTrustedProxy);
}
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
if (!req) {
return null;
}
const login = req.headers["tailscale-user-login"];
if (typeof login !== "string" || !login.trim()) {
return null;
}
const nameRaw = req.headers["tailscale-user-name"];
const profilePic = req.headers["tailscale-user-profile-pic"];
const name = typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : login.trim();
return {
login: login.trim(),
name,
profilePic: typeof profilePic === "string" && profilePic.trim() ? profilePic.trim() : undefined,
};
}
function hasTailscaleProxyHeaders(req?: IncomingMessage): boolean {
if (!req) {
return false;
}
return Boolean(
req.headers["x-forwarded-for"] &&
req.headers["x-forwarded-proto"] &&
req.headers["x-forwarded-host"],
);
}
function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
if (!req) {
return false;
}
return isLoopbackAddress(req.socket?.remoteAddress) && hasTailscaleProxyHeaders(req);
}
async function resolveVerifiedTailscaleUser(params: {
req?: IncomingMessage;
tailscaleWhois: TailscaleWhoisLookup;
}): Promise<{ ok: true; user: TailscaleUser } | { ok: false; reason: string }> {
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,
},
};
}
export function resolveGatewayAuth(params: {
authConfig?: GatewayAuthConfig | null;
authOverride?: GatewayAuthConfig | null;
env?: NodeJS.ProcessEnv;
tailscaleMode?: GatewayTailscaleMode;
}): ResolvedGatewayAuth {
const baseAuthConfig = params.authConfig ?? {};
const authOverride = params.authOverride ?? undefined;
const authConfig: GatewayAuthConfig = { ...baseAuthConfig };
if (authOverride) {
if (authOverride.mode !== undefined) {
authConfig.mode = authOverride.mode;
}
if (authOverride.token !== undefined) {
authConfig.token = authOverride.token;
}
if (authOverride.password !== undefined) {
authConfig.password = authOverride.password;
}
if (authOverride.allowTailscale !== undefined) {
authConfig.allowTailscale = authOverride.allowTailscale;
}
if (authOverride.rateLimit !== undefined) {
authConfig.rateLimit = authOverride.rateLimit;
}
if (authOverride.trustedProxy !== undefined) {
authConfig.trustedProxy = authOverride.trustedProxy;
}
}
const env = params.env ?? process.env;
const tokenRef = resolveSecretInputRef({ value: authConfig.token }).ref;
const passwordRef = resolveSecretInputRef({ value: authConfig.password }).ref;
const resolvedCredentials = resolveGatewayCredentialsFromValues({
configToken: tokenRef ? undefined : authConfig.token,
configPassword: passwordRef ? undefined : authConfig.password,
env,
includeLegacyEnv: false,
tokenPrecedence: "config-first",
passwordPrecedence: "config-first", // pragma: allowlist secret
});
const token = resolvedCredentials.token;
const password = resolvedCredentials.password;
const trustedProxy = authConfig.trustedProxy;
let mode: ResolvedGatewayAuth["mode"];
let modeSource: ResolvedGatewayAuth["modeSource"];
if (authOverride?.mode !== undefined) {
mode = authOverride.mode;
modeSource = "override";
} else if (authConfig.mode) {
mode = authConfig.mode;
modeSource = "config";
} else if (password) {
mode = "password";
modeSource = "password";
} else if (token) {
mode = "token";
modeSource = "token";
} else {
mode = "token";
modeSource = "default";
}
const allowTailscale =
authConfig.allowTailscale ??
(params.tailscaleMode === "serve" && mode !== "password" && mode !== "trusted-proxy");
return {
mode,
modeSource,
token,
password,
allowTailscale,
trustedProxy,
};
}
export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
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)",
);
}
if (auth.mode === "password" && !auth.password) {
throw new Error("gateway auth mode is password, but no password was configured");
}
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)",
);
}
}
}
/**
* 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: {
req?: IncomingMessage;
trustedProxies?: string[];
trustedProxyConfig: GatewayTrustedProxyConfig;
}): { user: string } | { reason: string } {
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 requiredHeaders = trustedProxyConfig.requiredHeaders ?? [];
for (const header of requiredHeaders) {
const value = headerValue(req.headers[header.toLowerCase()]);
if (!value || value.trim() === "") {
return { reason: `trusted_proxy_missing_header_${header}` };
}
}
const userHeaderValue = headerValue(req.headers[trustedProxyConfig.userHeader.toLowerCase()]);
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: GatewayAuthSurface): boolean {
return authSurface === "ws-control-ui";
}
export async function authorizeGatewayConnect(
params: AuthorizeGatewayConnectParams,
): Promise<GatewayAuthResult> {
const { auth, connectAuth, req, trustedProxies } = params;
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
const authSurface = params.authSurface ?? "http";
const allowTailscaleHeaderAuth = shouldAllowTailscaleHeaderAuth(authSurface);
const localDirect = isLocalDirectRequest(
req,
trustedProxies,
params.allowRealIpFallback === true,
);
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) {
return { ok: true, method: "trusted-proxy", user: result.user };
}
return { ok: false, reason: result.reason };
}
if (auth.mode === "none") {
return { ok: true, method: "none" };
}
const limiter = params.rateLimiter;
const ip =
params.clientIp ??
resolveRequestClientIp(req, trustedProxies, params.allowRealIpFallback === true) ??
req?.socket?.remoteAddress;
const rateLimitScope = params.rateLimitScope ?? AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET;
if (limiter) {
const rlCheck: RateLimitCheckResult = limiter.check(ip, rateLimitScope);
if (!rlCheck.allowed) {
return {
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: rlCheck.retryAfterMs,
};
}
}
if (allowTailscaleHeaderAuth && auth.allowTailscale && !localDirect) {
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") {
if (!auth.token) {
return { ok: false, reason: "token_missing_config" };
}
if (!connectAuth?.token) {
limiter?.recordFailure(ip, rateLimitScope);
return { ok: false, reason: "token_missing" };
}
if (!safeEqualSecret(connectAuth.token, auth.token)) {
limiter?.recordFailure(ip, rateLimitScope);
return { ok: false, reason: "token_mismatch" };
}
limiter?.reset(ip, rateLimitScope);
return { ok: true, method: "token" };
}
if (auth.mode === "password") {
const password = connectAuth?.password;
if (!auth.password) {
return { ok: false, reason: "password_missing_config" };
}
if (!password) {
limiter?.recordFailure(ip, rateLimitScope);
return { ok: false, reason: "password_missing" };
}
if (!safeEqualSecret(password, auth.password)) {
limiter?.recordFailure(ip, rateLimitScope);
return { ok: false, reason: "password_mismatch" };
}
limiter?.reset(ip, rateLimitScope);
return { ok: true, method: "password" };
}
limiter?.recordFailure(ip, rateLimitScope);
return { ok: false, reason: "unauthorized" };
}
export async function authorizeHttpGatewayConnect(
params: Omit<AuthorizeGatewayConnectParams, "authSurface">,
): Promise<GatewayAuthResult> {
return authorizeGatewayConnect({
...params,
authSurface: "http",
});
}
export async function authorizeWsControlUiGatewayConnect(
params: Omit<AuthorizeGatewayConnectParams, "authSurface">,
): Promise<GatewayAuthResult> {
return authorizeGatewayConnect({
...params,
authSurface: "ws-control-ui",
});
}