Security: harden gateway auth exposure
This commit is contained in:
parent
3f83afe4a6
commit
c430980f68
@ -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",
|
||||
@ -381,6 +382,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. /moltbot).",
|
||||
"gateway.controlUi.allowInsecureAuth":
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"];
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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" },
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
47
src/gateway/server-runtime-config.test.ts
Normal file
47
src/gateway/server-runtime-config.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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)");
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}).`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user