Files
Moltbot/src/gateway/server-runtime-state.ts
Harald Buerbaumer 30b6eccae5 feat(gateway): add auth rate-limiting & brute-force protection (#15035)
* feat(gateway): add auth rate-limiting & brute-force protection

Add a per-IP sliding-window rate limiter to Gateway authentication
endpoints (HTTP, WebSocket upgrade, and WS message-level auth).

When gateway.auth.rateLimit is configured, failed auth attempts are
tracked per client IP. Once the threshold is exceeded within the
sliding window, further attempts are blocked with HTTP 429 + Retry-After
until the lockout period expires. Loopback addresses are exempt by
default so local CLI sessions are never locked out.

The limiter is only created when explicitly configured (undefined
otherwise), keeping the feature fully opt-in and backward-compatible.

* fix(gateway): isolate auth rate-limit scopes and normalize 429 responses

---------

Co-authored-by: buerbaumer <buerbaumer@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 15:32:38 +01:00

216 lines
7.3 KiB
TypeScript

import type { Server as HttpServer } from "node:http";
import { WebSocketServer } from "ws";
import type { CliDeps } from "../cli/deps.js";
import type { createSubsystemLogger } from "../logging/subsystem.js";
import type { PluginRegistry } from "../plugins/registry.js";
import type { RuntimeEnv } from "../runtime.js";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import type { ChatAbortControllerEntry } from "./chat-abort.js";
import type { ControlUiRootState } from "./control-ui.js";
import type { HooksConfigResolved } from "./hooks.js";
import type { DedupeEntry } from "./server-shared.js";
import type { GatewayTlsRuntime } from "./server/tls.js";
import type { GatewayWsClient } from "./server/ws-types.js";
import { CANVAS_HOST_PATH } from "../canvas-host/a2ui.js";
import { type CanvasHostHandler, createCanvasHostHandler } from "../canvas-host/server.js";
import { resolveGatewayListenHosts } from "./net.js";
import { createGatewayBroadcaster } from "./server-broadcast.js";
import {
type ChatRunEntry,
createChatRunState,
createToolEventRecipientRegistry,
} from "./server-chat.js";
import { MAX_PAYLOAD_BYTES } from "./server-constants.js";
import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js";
import { createGatewayHooksRequestHandler } from "./server/hooks.js";
import { listenGatewayHttpServer } from "./server/http-listen.js";
import { createGatewayPluginRequestHandler } from "./server/plugins-http.js";
export async function createGatewayRuntimeState(params: {
cfg: import("../config/config.js").OpenClawConfig;
bindHost: string;
port: number;
controlUiEnabled: boolean;
controlUiBasePath: string;
controlUiRoot?: ControlUiRootState;
openAiChatCompletionsEnabled: boolean;
openResponsesEnabled: boolean;
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
resolvedAuth: ResolvedGatewayAuth;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
gatewayTls?: GatewayTlsRuntime;
hooksConfig: () => HooksConfigResolved | null;
pluginRegistry: PluginRegistry;
deps: CliDeps;
canvasRuntime: RuntimeEnv;
canvasHostEnabled: boolean;
allowCanvasHostInTests?: boolean;
logCanvas: { info: (msg: string) => void; warn: (msg: string) => void };
log: { info: (msg: string) => void; warn: (msg: string) => void };
logHooks: ReturnType<typeof createSubsystemLogger>;
logPlugins: ReturnType<typeof createSubsystemLogger>;
}): Promise<{
canvasHost: CanvasHostHandler | null;
httpServer: HttpServer;
httpServers: HttpServer[];
httpBindHosts: string[];
wss: WebSocketServer;
clients: Set<GatewayWsClient>;
broadcast: (
event: string,
payload: unknown,
opts?: {
dropIfSlow?: boolean;
stateVersion?: { presence?: number; health?: number };
},
) => void;
broadcastToConnIds: (
event: string,
payload: unknown,
connIds: ReadonlySet<string>,
opts?: {
dropIfSlow?: boolean;
stateVersion?: { presence?: number; health?: number };
},
) => void;
agentRunSeq: Map<string, number>;
dedupe: Map<string, DedupeEntry>;
chatRunState: ReturnType<typeof createChatRunState>;
chatRunBuffers: Map<string, string>;
chatDeltaSentAt: Map<string, number>;
addChatRun: (sessionId: string, entry: ChatRunEntry) => void;
removeChatRun: (
sessionId: string,
clientRunId: string,
sessionKey?: string,
) => ChatRunEntry | undefined;
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
toolEventRecipients: ReturnType<typeof createToolEventRecipientRegistry>;
}> {
let canvasHost: CanvasHostHandler | null = null;
if (params.canvasHostEnabled) {
try {
const handler = await createCanvasHostHandler({
runtime: params.canvasRuntime,
rootDir: params.cfg.canvasHost?.root,
basePath: CANVAS_HOST_PATH,
allowInTests: params.allowCanvasHostInTests,
liveReload: params.cfg.canvasHost?.liveReload,
});
if (handler.rootDir) {
canvasHost = handler;
params.logCanvas.info(
`canvas host mounted at http://${params.bindHost}:${params.port}${CANVAS_HOST_PATH}/ (root ${handler.rootDir})`,
);
}
} catch (err) {
params.logCanvas.warn(`canvas host failed to start: ${String(err)}`);
}
}
const clients = new Set<GatewayWsClient>();
const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients });
const handleHooksRequest = createGatewayHooksRequestHandler({
deps: params.deps,
getHooksConfig: params.hooksConfig,
bindHost: params.bindHost,
port: params.port,
logHooks: params.logHooks,
});
const handlePluginRequest = createGatewayPluginRequestHandler({
registry: params.pluginRegistry,
log: params.logPlugins,
});
const bindHosts = await resolveGatewayListenHosts(params.bindHost);
const httpServers: HttpServer[] = [];
const httpBindHosts: string[] = [];
for (const host of bindHosts) {
const httpServer = createGatewayHttpServer({
canvasHost,
clients,
controlUiEnabled: params.controlUiEnabled,
controlUiBasePath: params.controlUiBasePath,
controlUiRoot: params.controlUiRoot,
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
openResponsesEnabled: params.openResponsesEnabled,
openResponsesConfig: params.openResponsesConfig,
handleHooksRequest,
handlePluginRequest,
resolvedAuth: params.resolvedAuth,
rateLimiter: params.rateLimiter,
tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
});
try {
await listenGatewayHttpServer({
httpServer,
bindHost: host,
port: params.port,
});
httpServers.push(httpServer);
httpBindHosts.push(host);
} catch (err) {
if (host === bindHosts[0]) {
throw err;
}
params.log.warn(
`gateway: failed to bind loopback alias ${host}:${params.port} (${String(err)})`,
);
}
}
const httpServer = httpServers[0];
if (!httpServer) {
throw new Error("Gateway HTTP server failed to start");
}
const wss = new WebSocketServer({
noServer: true,
maxPayload: MAX_PAYLOAD_BYTES,
});
for (const server of httpServers) {
attachGatewayUpgradeHandler({
httpServer: server,
wss,
canvasHost,
clients,
resolvedAuth: params.resolvedAuth,
rateLimiter: params.rateLimiter,
});
}
const agentRunSeq = new Map<string, number>();
const dedupe = new Map<string, DedupeEntry>();
const chatRunState = createChatRunState();
const chatRunRegistry = chatRunState.registry;
const chatRunBuffers = chatRunState.buffers;
const chatDeltaSentAt = chatRunState.deltaSentAt;
const addChatRun = chatRunRegistry.add;
const removeChatRun = chatRunRegistry.remove;
const chatAbortControllers = new Map<string, ChatAbortControllerEntry>();
const toolEventRecipients = createToolEventRecipientRegistry();
return {
canvasHost,
httpServer,
httpServers,
httpBindHosts,
wss,
clients,
broadcast,
broadcastToConnIds,
agentRunSeq,
dedupe,
chatRunState,
chatRunBuffers,
chatDeltaSentAt,
addChatRun,
removeChatRun,
chatAbortControllers,
toolEventRecipients,
};
}