diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index c57eef322..44f535402 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -3,6 +3,7 @@ import type { IncomingMessage } from "node:http"; import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js"; import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js"; import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js"; +import { checkAuthRateLimit, logAuthFailure } from "../security/middleware.js"; export type ResolvedGatewayAuthMode = "token" | "password"; export type ResolvedGatewayAuth = { @@ -207,11 +208,23 @@ export async function authorizeGatewayConnect(params: { req?: IncomingMessage; trustedProxies?: string[]; tailscaleWhois?: TailscaleWhoisLookup; + deviceId?: string; }): Promise { - const { auth, connectAuth, req, trustedProxies } = params; + const { auth, connectAuth, req, trustedProxies, deviceId } = params; const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity; const localDirect = isLocalDirectRequest(req, trustedProxies); + // Security: Check auth rate limit + if (req) { + const rateCheck = checkAuthRateLimit(req, deviceId); + if (!rateCheck.allowed) { + return { + ok: false, + reason: rateCheck.reason ?? "rate_limit_exceeded", + }; + } + } + if (auth.allowTailscale && !localDirect) { const tailscaleCheck = await resolveVerifiedTailscaleUser({ req, @@ -234,6 +247,10 @@ export async function authorizeGatewayConnect(params: { return { ok: false, reason: "token_missing" }; } if (!safeEqual(connectAuth.token, auth.token)) { + // Security: Log failed auth for intrusion detection + if (req) { + logAuthFailure(req, "token_mismatch", deviceId); + } return { ok: false, reason: "token_mismatch" }; } return { ok: true, method: "token" }; @@ -248,10 +265,18 @@ export async function authorizeGatewayConnect(params: { return { ok: false, reason: "password_missing" }; } if (!safeEqual(password, auth.password)) { + // Security: Log failed auth for intrusion detection + if (req) { + logAuthFailure(req, "password_mismatch", deviceId); + } return { ok: false, reason: "password_mismatch" }; } return { ok: true, method: "password" }; } + // Security: Log unauthorized attempts + if (req) { + logAuthFailure(req, "unauthorized", deviceId); + } return { ok: false, reason: "unauthorized" }; } diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index e84c0ed43..64d930958 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -28,6 +28,8 @@ import { } from "./hooks.js"; import { applyHookMappings } from "./hooks-mapping.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; +import { checkWebhookRateLimit } from "../security/middleware.js"; +import { SecurityShield } from "../security/shield.js"; import { handleOpenResponsesHttpRequest } from "./openresponses-http.js"; import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js"; @@ -91,6 +93,21 @@ export function createHooksRequestHandler( ); } + // Security: Check webhook rate limit + const subPath = url.pathname.slice(basePath.length).replace(/^\/+/, ""); + const rateCheck = checkWebhookRateLimit({ + token: token, + path: subPath, + ip: SecurityShield.extractIp(req), + }); + if (!rateCheck.allowed) { + res.statusCode = 429; + res.setHeader("Retry-After", String(Math.ceil((rateCheck.retryAfterMs ?? 60000) / 1000))); + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Too Many Requests"); + return true; + } + if (req.method !== "POST") { res.statusCode = 405; res.setHeader("Allow", "POST"); @@ -99,7 +116,6 @@ export function createHooksRequestHandler( return true; } - const subPath = url.pathname.slice(basePath.length).replace(/^\/+/, ""); if (!subPath) { res.statusCode = 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index efa91be76..591f1fd06 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -58,6 +58,7 @@ import { loadGatewayModelCatalog } from "./server-model-catalog.js"; import { NodeRegistry } from "./node-registry.js"; import { createNodeSubscriptionManager } from "./server-node-subscriptions.js"; import { safeParseJson } from "./server-methods/nodes.helpers.js"; +import { initSecurityShield } from "../security/shield.js"; import { loadGatewayPlugins } from "./server-plugins.js"; import { createGatewayReloadHandlers } from "./server-reload-handlers.js"; import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js"; @@ -215,6 +216,10 @@ export async function startGatewayServer( startDiagnosticHeartbeat(); } setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true }); + + // Initialize security shield with configuration + initSecurityShield(cfgAtStart.security?.shield); + initSubagentRegistry(); const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId); diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts index 5ae89dbd9..a98bf9926 100644 --- a/src/pairing/pairing-store.ts +++ b/src/pairing/pairing-store.ts @@ -7,6 +7,7 @@ import lockfile from "proper-lockfile"; import { getPairingAdapter } from "../channels/plugins/pairing.js"; import type { ChannelId, ChannelPairingAdapter } from "../channels/plugins/types.js"; import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; +import { checkPairingRateLimit } from "../security/middleware.js"; const PAIRING_CODE_LENGTH = 8; const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; @@ -328,6 +329,19 @@ export async function upsertChannelPairingRequest(params: { pairingAdapter?: ChannelPairingAdapter; }): Promise<{ code: string; created: boolean }> { const env = params.env ?? process.env; + + // Security: Check pairing rate limit + const sender = normalizeId(params.id); + const rateCheck = checkPairingRateLimit({ + channel: String(params.channel), + sender, + ip: "unknown", // Pairing happens at channel level, not HTTP + }); + if (!rateCheck.allowed) { + // Rate limited - return empty code without creating request + return { code: "", created: false }; + } + const filePath = resolvePairingPath(params.channel, env); return await withFileLock( filePath,