This commit is contained in:
Waseem Ahmed 2026-01-30 10:10:17 +01:00 committed by GitHub
commit 766f0f6f2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 215 additions and 11 deletions

View File

@ -132,6 +132,7 @@ const FIELD_LABELS: Record<string, string> = {
"gateway.remote.tlsFingerprint": "Remote Gateway TLS Fingerprint",
"gateway.auth.token": "Gateway Token",
"gateway.auth.password": "Gateway Password",
"gateway.auth.minLength": "Gateway Auth Minimum Length",
"tools.media.image.enabled": "Enable Image Understanding",
"tools.media.image.maxBytes": "Image Understanding Max Bytes",
"tools.media.image.maxChars": "Image Understanding Max Chars",
@ -382,6 +383,8 @@ const FIELD_HELP: Record<string, string> = {
"gateway.auth.token":
"Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.",
"gateway.auth.password": "Required for Tailscale funnel.",
"gateway.auth.minLength":
"Minimum token/password length required for non-loopback binds (default: 24).",
"gateway.controlUi.basePath":
"Optional URL prefix where the Control UI is served (e.g. /openclaw).",
"gateway.controlUi.allowInsecureAuth":

View File

@ -81,6 +81,8 @@ export type GatewayAuthConfig = {
token?: string;
/** Shared password for password mode (consider env instead). */
password?: string;
/** Minimum auth token/password length when binding beyond loopback. Default: 24. */
minLength?: number;
/** Allow Tailscale identity headers when serve mode is enabled. */
allowTailscale?: boolean;
};

View File

@ -328,6 +328,7 @@ export const OpenClawSchema = z
mode: z.union([z.literal("token"), z.literal("password")]).optional(),
token: z.string().optional(),
password: z.string().optional(),
minLength: z.number().int().min(8).max(128).optional(),
allowTailscale: z.boolean().optional(),
})
.strict()

View File

@ -32,6 +32,19 @@ type TailscaleUser = {
type TailscaleWhoisLookup = (ip: string) => Promise<TailscaleWhoisIdentity | null>;
export const DEFAULT_GATEWAY_AUTH_MIN_LENGTH = 24;
type AuthFailureState = {
count: number;
firstSeen: number;
blockedUntil?: number;
};
const AUTH_FAILURE_WINDOW_MS = 60_000;
const AUTH_FAILURE_BLOCK_MS = 5 * 60_000;
const AUTH_FAILURE_LIMIT = 10;
const authFailuresByIp = new Map<string, AuthFailureState>();
function safeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
@ -103,6 +116,57 @@ export function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: str
return (hostIsLocal || hostIsTailscaleServe) && (!hasForwarded || remoteIsTrustedProxy);
}
function resolveAuthRateLimitKey(
req?: IncomingMessage,
trustedProxies?: string[],
): string | null {
if (!req) return null;
if (isLocalDirectRequest(req, trustedProxies)) return null;
const clientIp = resolveRequestClientIp(req, trustedProxies);
if (!clientIp || isLoopbackAddress(clientIp)) return null;
return clientIp;
}
function isAuthRateLimited(key: string, now: number): boolean {
const state = authFailuresByIp.get(key);
if (!state) return false;
if (state.blockedUntil && state.blockedUntil > now) return true;
if (now - state.firstSeen > AUTH_FAILURE_WINDOW_MS) {
authFailuresByIp.delete(key);
}
return false;
}
function recordAuthFailure(key: string, now: number): void {
const state = authFailuresByIp.get(key);
if (!state || now - state.firstSeen > AUTH_FAILURE_WINDOW_MS) {
authFailuresByIp.set(key, { count: 1, firstSeen: now });
return;
}
const nextCount = state.count + 1;
const blockedUntil =
nextCount >= AUTH_FAILURE_LIMIT ? now + AUTH_FAILURE_BLOCK_MS : state.blockedUntil;
authFailuresByIp.set(key, { ...state, count: nextCount, blockedUntil });
}
function clearAuthFailures(key: string | null): void {
if (!key) return;
authFailuresByIp.delete(key);
}
function shouldRecordAuthFailure(reason: string | undefined): boolean {
return (
reason === "token_missing" ||
reason === "token_mismatch" ||
reason === "password_missing" ||
reason === "password_mismatch" ||
reason === "tailscale_user_missing" ||
reason === "tailscale_proxy_missing" ||
reason === "tailscale_whois_failed" ||
reason === "tailscale_user_mismatch"
);
}
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
if (!req) return null;
const login = req.headers["tailscale-user-login"];
@ -211,6 +275,17 @@ export async function authorizeGatewayConnect(params: {
const { auth, connectAuth, req, trustedProxies } = params;
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
const localDirect = isLocalDirectRequest(req, trustedProxies);
const now = Date.now();
const rateLimitKey = resolveAuthRateLimitKey(req, trustedProxies);
if (rateLimitKey && isAuthRateLimited(rateLimitKey, now)) {
return { ok: false, reason: "rate_limited" };
}
const fail = (reason: GatewayAuthResult["reason"]) => {
if (rateLimitKey && shouldRecordAuthFailure(reason)) {
recordAuthFailure(rateLimitKey, now);
}
return { ok: false, reason };
};
if (auth.allowTailscale && !localDirect) {
const tailscaleCheck = await resolveVerifiedTailscaleUser({
@ -218,40 +293,44 @@ export async function authorizeGatewayConnect(params: {
tailscaleWhois,
});
if (tailscaleCheck.ok) {
clearAuthFailures(rateLimitKey);
return {
ok: true,
method: "tailscale",
user: tailscaleCheck.user.login,
};
}
return fail(tailscaleCheck.reason);
}
if (auth.mode === "token") {
if (!auth.token) {
return { ok: false, reason: "token_missing_config" };
return fail("token_missing_config");
}
if (!connectAuth?.token) {
return { ok: false, reason: "token_missing" };
return fail("token_missing");
}
if (!safeEqual(connectAuth.token, auth.token)) {
return { ok: false, reason: "token_mismatch" };
return fail("token_mismatch");
}
clearAuthFailures(rateLimitKey);
return { ok: true, method: "token" };
}
if (auth.mode === "password") {
const password = connectAuth?.password;
if (!auth.password) {
return { ok: false, reason: "password_missing_config" };
return fail("password_missing_config");
}
if (!password) {
return { ok: false, reason: "password_missing" };
return fail("password_missing");
}
if (!safeEqual(password, auth.password)) {
return { ok: false, reason: "password_mismatch" };
return fail("password_mismatch");
}
clearAuthFailures(rateLimitKey);
return { ok: true, method: "password" };
}
return { ok: false, reason: "unauthorized" };
return fail("unauthorized");
}

View File

@ -25,6 +25,12 @@ export function sendUnauthorized(res: ServerResponse) {
});
}
export function sendRateLimited(res: ServerResponse) {
sendJson(res, 429, {
error: { message: "Too many requests", type: "rate_limited" },
});
}
export function sendInvalidRequest(res: ServerResponse, message: string) {
sendJson(res, 400, {
error: { message, type: "invalid_request_error" },

View File

@ -11,6 +11,7 @@ import {
readJsonBodyOrError,
sendJson,
sendMethodNotAllowed,
sendRateLimited,
sendUnauthorized,
setSseHeaders,
writeDone,
@ -172,6 +173,10 @@ export async function handleOpenAiHttpRequest(
trustedProxies: opts.trustedProxies,
});
if (!authResult.ok) {
if (authResult.reason === "rate_limited") {
sendRateLimited(res);
return true;
}
sendUnauthorized(res);
return true;
}

View File

@ -20,6 +20,7 @@ import {
readJsonBodyOrError,
sendJson,
sendMethodNotAllowed,
sendRateLimited,
sendUnauthorized,
setSseHeaders,
writeDone,
@ -335,6 +336,10 @@ export async function handleOpenResponsesHttpRequest(
trustedProxies: opts.trustedProxies,
});
if (!authResult.ok) {
if (authResult.reason === "rate_limited") {
sendRateLimited(res);
return true;
}
sendUnauthorized(res);
return true;
}

View File

@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import type { MoltbotConfig } from "../config/config.js";
import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js";
describe("resolveGatewayRuntimeConfig", () => {
it("rejects weak auth when binding beyond loopback", async () => {
const cfg: MoltbotConfig = {
gateway: {
auth: {
mode: "token",
token: "short-token",
},
},
};
await expect(
resolveGatewayRuntimeConfig({
cfg,
port: 18789,
host: "0.0.0.0",
}),
).rejects.toThrow("weak shared secret");
});
it("allows custom auth minimum length", async () => {
const cfg: MoltbotConfig = {
gateway: {
auth: {
mode: "token",
token: "long-enough-token",
minLength: 10,
},
},
};
await expect(
resolveGatewayRuntimeConfig({
cfg,
port: 18789,
host: "0.0.0.0",
}),
).resolves.toMatchObject({
bindHost: "0.0.0.0",
});
});
});

View File

@ -6,6 +6,7 @@ import type {
} from "../config/config.js";
import {
assertGatewayAuthConfigured,
DEFAULT_GATEWAY_AUTH_MIN_LENGTH,
type ResolvedGatewayAuth,
resolveGatewayAuth,
} from "./auth.js";
@ -75,6 +76,12 @@ export async function resolveGatewayRuntimeConfig(params: {
typeof resolvedAuth.password === "string" && resolvedAuth.password.trim().length > 0;
const hasSharedSecret =
(authMode === "token" && hasToken) || (authMode === "password" && hasPassword);
const minAuthLength = params.cfg.gateway?.auth?.minLength ?? DEFAULT_GATEWAY_AUTH_MIN_LENGTH;
const tokenLength = hasToken ? resolvedAuth.token?.trim().length ?? 0 : 0;
const passwordLength = hasPassword ? resolvedAuth.password?.trim().length ?? 0 : 0;
const isWeakSharedSecret =
(authMode === "token" && tokenLength > 0 && tokenLength < minAuthLength) ||
(authMode === "password" && passwordLength > 0 && passwordLength < minAuthLength);
const hooksConfig = resolveHooksConfig(params.cfg);
const canvasHostEnabled =
process.env.OPENCLAW_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false;
@ -93,6 +100,11 @@ export async function resolveGatewayRuntimeConfig(params: {
`refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token/password, or set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD)`,
);
}
if (!isLoopbackHost(bindHost) && isWeakSharedSecret) {
throw new Error(
`refusing to bind gateway to ${bindHost}:${params.port} with a weak shared secret (min length ${minAuthLength}; use a long random token/password or set gateway.bind=loopback)`,
);
}
return {
bindHost,

View File

@ -3,6 +3,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import type { loadConfig } from "../config/config.js";
import { getResolvedLoggerSettings } from "../logging.js";
import { DEFAULT_GATEWAY_AUTH_MIN_LENGTH, resolveGatewayAuth } from "./auth.js";
export function logGatewayStartup(params: {
cfg: ReturnType<typeof loadConfig>;
@ -10,7 +11,7 @@ export function logGatewayStartup(params: {
bindHosts?: string[];
port: number;
tlsEnabled?: boolean;
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
log: { info: (msg: string, meta?: Record<string, unknown>) => void; warn?: (msg: string) => void };
isNixMode: boolean;
}) {
const { provider: agentProvider, model: agentModel } = resolveConfiguredModelRef({
@ -34,6 +35,29 @@ export function logGatewayStartup(params: {
params.log.info(`listening on ${scheme}://${formatHost(host)}:${params.port}`);
}
params.log.info(`log file: ${getResolvedLoggerSettings().file}`);
const minAuthLength = params.cfg.gateway?.auth?.minLength ?? DEFAULT_GATEWAY_AUTH_MIN_LENGTH;
const tailscaleMode = params.cfg.gateway?.tailscale?.mode ?? "off";
const resolvedAuth = resolveGatewayAuth({
authConfig: params.cfg.gateway?.auth,
env: process.env,
tailscaleMode,
});
const tokenLength = resolvedAuth.token?.trim().length ?? 0;
const passwordLength = resolvedAuth.password?.trim().length ?? 0;
const activeLength = resolvedAuth.mode === "password" ? passwordLength : tokenLength;
const label = resolvedAuth.mode === "password" ? "password" : "token";
if (activeLength > 0) {
const message = `gateway auth: ${label} length ${activeLength} (min ${minAuthLength})`;
if (activeLength < minAuthLength) {
params.log.warn?.(`${message} - weak`);
} else {
params.log.info(`${message} - ok`);
}
} else if (resolvedAuth.allowTailscale && tailscaleMode === "serve") {
params.log.info("gateway auth: tailscale serve identity allowed (no shared secret configured)");
} else {
params.log.warn?.("gateway auth: no shared secret configured");
}
if (params.isNixMode) {
params.log.info("gateway: running in Nix mode (config managed externally)");
}

View File

@ -115,6 +115,8 @@ function formatGatewayAuthFailureMessage(params: {
return "unauthorized: tailscale identity check failed (use Tailscale Serve auth or gateway token/password)";
case "tailscale_user_mismatch":
return "unauthorized: tailscale identity mismatch (use Tailscale Serve auth or gateway token/password)";
case "rate_limited":
return "unauthorized: too many failed attempts (try again later)";
default:
break;
}

View File

@ -30,6 +30,7 @@ import {
sendInvalidRequest,
sendJson,
sendMethodNotAllowed,
sendRateLimited,
sendUnauthorized,
} from "./http-common.js";
@ -111,6 +112,10 @@ export async function handleToolsInvokeHttpRequest(
trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies,
});
if (!authResult.ok) {
if (authResult.reason === "rate_limited") {
sendRateLimited(res);
return true;
}
sendUnauthorized(res);
return true;
}

View File

@ -4,7 +4,7 @@ import type { ChannelId } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveBrowserConfig, resolveProfile } from "../browser/config.js";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { DEFAULT_GATEWAY_AUTH_MIN_LENGTH, resolveGatewayAuth } from "../gateway/auth.js";
import { formatCliCommand } from "../cli/command-format.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { probeGateway } from "../gateway/probe.js";
@ -264,6 +264,7 @@ function collectGatewayConfigFindings(
const hasPassword = typeof auth.password === "string" && auth.password.trim().length > 0;
const hasSharedSecret =
(auth.mode === "token" && hasToken) || (auth.mode === "password" && hasPassword);
const minAuthLength = cfg.gateway?.auth?.minLength ?? DEFAULT_GATEWAY_AUTH_MIN_LENGTH;
const hasTailscaleAuth = auth.allowTailscale === true && tailscaleMode === "serve";
const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth;
@ -344,12 +345,24 @@ function collectGatewayConfigFindings(
const token =
typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
if (auth.mode === "token" && token && token.length < 24) {
if (auth.mode === "token" && token && token.length < minAuthLength) {
findings.push({
checkId: "gateway.token_too_short",
severity: "warn",
title: "Gateway token looks short",
detail: `gateway auth token is ${token.length} chars; prefer a long random token.`,
detail: `gateway auth token is ${token.length} chars (min length ${minAuthLength}).`,
});
}
const password =
typeof auth.password === "string" && auth.password.trim().length > 0
? auth.password.trim()
: null;
if (auth.mode === "password" && password && password.length < minAuthLength) {
findings.push({
checkId: "gateway.password_too_short",
severity: "warn",
title: "Gateway password looks short",
detail: `gateway auth password is ${password.length} chars (min length ${minAuthLength}).`,
});
}