diff --git a/src/config/schema.ts b/src/config/schema.ts index 9b5ad8be6..e178803bf 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -132,6 +132,7 @@ const FIELD_LABELS: Record = { "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", @@ -381,6 +382,8 @@ const FIELD_HELP: Record = { "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. /moltbot).", "gateway.controlUi.allowInsecureAuth": diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index a0d562f7b..0f345db37 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -79,6 +79,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; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index ce4115517..48733c7ca 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -327,6 +327,7 @@ export const MoltbotSchema = 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() diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 1adc367a2..49703582a 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -32,6 +32,19 @@ type TailscaleUser = { type TailscaleWhoisLookup = (ip: string) => Promise; +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(); + 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"]; @@ -206,6 +270,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({ @@ -213,40 +288,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"); } diff --git a/src/gateway/http-common.ts b/src/gateway/http-common.ts index 993a3ac36..de2a4a117 100644 --- a/src/gateway/http-common.ts +++ b/src/gateway/http-common.ts @@ -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" }, diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 5a05f08d5..e08a691a4 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -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; } diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 147ca5fb9..9aa31df1d 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -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; } diff --git a/src/gateway/server-runtime-config.test.ts b/src/gateway/server-runtime-config.test.ts new file mode 100644 index 000000000..6edfcf795 --- /dev/null +++ b/src/gateway/server-runtime-config.test.ts @@ -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", + }); + }); +}); diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index 2d699988a..eb22b7faa 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -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.CLAWDBOT_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 CLAWDBOT_GATEWAY_TOKEN/CLAWDBOT_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, diff --git a/src/gateway/server-startup-log.ts b/src/gateway/server-startup-log.ts index cf6d2575c..7dde89ae9 100644 --- a/src/gateway/server-startup-log.ts +++ b/src/gateway/server-startup-log.ts @@ -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; @@ -10,7 +11,7 @@ export function logGatewayStartup(params: { bindHosts?: string[]; port: number; tlsEnabled?: boolean; - log: { info: (msg: string, meta?: Record) => void }; + log: { info: (msg: string, meta?: Record) => 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)"); } diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index d1f6ae511..833cb0a3e 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -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; } diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index fa45bf3dc..924ab1576 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -29,6 +29,7 @@ import { sendInvalidRequest, sendJson, sendMethodNotAllowed, + sendRateLimited, sendUnauthorized, } from "./http-common.js"; @@ -89,6 +90,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; } diff --git a/src/security/audit.ts b/src/security/audit.ts index 7aebd6928..5be1600c6 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -4,7 +4,7 @@ import type { ChannelId } from "../channels/plugins/types.js"; import type { MoltbotConfig } 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}).`, }); }