feat(security): integrate security shield with gateway
This commit is contained in:
parent
79597b7a98
commit
18a01881c5
@ -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<GatewayAuthResult> {
|
||||
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" };
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user