mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
* 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
491 lines
15 KiB
TypeScript
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",
|
|
});
|
|
}
|