refactor(security): unify hook rate-limit and hook module loading

This commit is contained in:
Peter Steinberger
2026-02-22 08:56:24 +01:00
parent 7cf280805c
commit 9f97555b5e
6 changed files with 152 additions and 82 deletions

View File

@@ -31,11 +31,14 @@ export interface RateLimitConfig {
lockoutMs?: number;
/** Exempt loopback (localhost) addresses from rate limiting. @default true */
exemptLoopback?: boolean;
/** Background prune interval in milliseconds; set <= 0 to disable auto-prune. @default 60_000 */
pruneIntervalMs?: number;
}
export const AUTH_RATE_LIMIT_SCOPE_DEFAULT = "default";
export const AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET = "shared-secret";
export const AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN = "device-token";
export const AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH = "hook-auth";
export interface RateLimitEntry {
/** Timestamps (epoch ms) of recent failed attempts inside the window. */
@@ -94,13 +97,14 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter
const windowMs = config?.windowMs ?? DEFAULT_WINDOW_MS;
const lockoutMs = config?.lockoutMs ?? DEFAULT_LOCKOUT_MS;
const exemptLoopback = config?.exemptLoopback ?? true;
const pruneIntervalMs = config?.pruneIntervalMs ?? PRUNE_INTERVAL_MS;
const entries = new Map<string, RateLimitEntry>();
// Periodic cleanup to avoid unbounded map growth.
const pruneTimer = setInterval(() => prune(), PRUNE_INTERVAL_MS);
const pruneTimer = pruneIntervalMs > 0 ? setInterval(() => prune(), pruneIntervalMs) : null;
// Allow the Node.js process to exit even if the timer is still active.
if (pruneTimer.unref) {
if (pruneTimer?.unref) {
pruneTimer.unref();
}
@@ -218,7 +222,9 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter
}
function dispose(): void {
clearInterval(pruneTimer);
if (pruneTimer) {
clearInterval(pruneTimer);
}
entries.clear();
}

View File

@@ -1,6 +1,6 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { CONFIG_PATH, type HookMappingConfig, type HooksConfig } from "../config/config.js";
import { importFileModule, resolveFunctionModuleExport } from "../hooks/module-loader.js";
import type { HookMessageChannel } from "./hooks.js";
export type HookMappingResolved = {
@@ -330,19 +330,22 @@ async function loadTransform(transform: HookMappingTransformResolved): Promise<H
if (cached) {
return cached;
}
const url = pathToFileURL(transform.modulePath).href;
const mod = (await import(url)) as Record<string, unknown>;
const mod = await importFileModule({ modulePath: transform.modulePath });
const fn = resolveTransformFn(mod, transform.exportName);
transformCache.set(cacheKey, fn);
return fn;
}
function resolveTransformFn(mod: Record<string, unknown>, exportName?: string): HookTransformFn {
const candidate = exportName ? mod[exportName] : (mod.default ?? mod.transform);
if (typeof candidate !== "function") {
const candidate = resolveFunctionModuleExport<HookTransformFn>({
mod,
exportName,
fallbackExportNames: ["default", "transform"],
});
if (!candidate) {
throw new Error("hook transform module must export a function");
}
return candidate as HookTransformFn;
return candidate;
}
function resolvePath(baseDir: string, target: string): string {

View File

@@ -19,7 +19,12 @@ import { loadConfig } from "../config/config.js";
import type { createSubsystemLogger } from "../logging/subsystem.js";
import { safeEqualSecret } from "../security/secret-equal.js";
import { handleSlackHttpRequest } from "../slack/http/index.js";
import { normalizeRateLimitClientIp, type AuthRateLimiter } from "./auth-rate-limit.js";
import {
AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH,
createAuthRateLimiter,
normalizeRateLimitClientIp,
type AuthRateLimiter,
} from "./auth-rate-limit.js";
import {
authorizeHttpGatewayConnect,
isLocalDirectRequest,
@@ -58,11 +63,9 @@ import type { GatewayWsClient } from "./server/ws-types.js";
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
type HookAuthFailure = { count: number; windowStartedAtMs: number };
const HOOK_AUTH_FAILURE_LIMIT = 20;
const HOOK_AUTH_FAILURE_WINDOW_MS = 60_000;
const HOOK_AUTH_FAILURE_TRACK_MAX = 2048;
type HookDispatchers = {
dispatchWakeHook: (value: { text: string; mode: "now" | "next-heartbeat" }) => void;
@@ -219,60 +222,19 @@ export function createHooksRequestHandler(
} & HookDispatchers,
): HooksRequestHandler {
const { getHooksConfig, bindHost, port, logHooks, dispatchAgentHook, dispatchWakeHook } = opts;
const hookAuthFailures = new Map<string, HookAuthFailure>();
const hookAuthLimiter = createAuthRateLimiter({
maxAttempts: HOOK_AUTH_FAILURE_LIMIT,
windowMs: HOOK_AUTH_FAILURE_WINDOW_MS,
lockoutMs: HOOK_AUTH_FAILURE_WINDOW_MS,
exemptLoopback: false,
// Handler lifetimes are tied to gateway runtime/tests; skip background timer fanout.
pruneIntervalMs: 0,
});
const resolveHookClientKey = (req: IncomingMessage): string => {
return normalizeRateLimitClientIp(req.socket?.remoteAddress);
};
const recordHookAuthFailure = (
clientKey: string,
nowMs: number,
): { throttled: boolean; retryAfterSeconds?: number } => {
if (!hookAuthFailures.has(clientKey) && hookAuthFailures.size >= HOOK_AUTH_FAILURE_TRACK_MAX) {
// Prune expired entries instead of clearing all state.
for (const [key, entry] of hookAuthFailures) {
if (nowMs - entry.windowStartedAtMs >= HOOK_AUTH_FAILURE_WINDOW_MS) {
hookAuthFailures.delete(key);
}
}
// If still at capacity after pruning, drop the oldest half.
if (hookAuthFailures.size >= HOOK_AUTH_FAILURE_TRACK_MAX) {
let toRemove = Math.floor(hookAuthFailures.size / 2);
for (const key of hookAuthFailures.keys()) {
if (toRemove <= 0) {
break;
}
hookAuthFailures.delete(key);
toRemove--;
}
}
}
const current = hookAuthFailures.get(clientKey);
const expired = !current || nowMs - current.windowStartedAtMs >= HOOK_AUTH_FAILURE_WINDOW_MS;
const next: HookAuthFailure = expired
? { count: 1, windowStartedAtMs: nowMs }
: { count: current.count + 1, windowStartedAtMs: current.windowStartedAtMs };
// Delete-before-set refreshes Map insertion order so recently-active
// clients are not evicted before dormant ones during oldest-half eviction.
if (hookAuthFailures.has(clientKey)) {
hookAuthFailures.delete(clientKey);
}
hookAuthFailures.set(clientKey, next);
if (next.count <= HOOK_AUTH_FAILURE_LIMIT) {
return { throttled: false };
}
const retryAfterMs = Math.max(1, next.windowStartedAtMs + HOOK_AUTH_FAILURE_WINDOW_MS - nowMs);
return {
throttled: true,
retryAfterSeconds: Math.ceil(retryAfterMs / 1000),
};
};
const clearHookAuthFailure = (clientKey: string) => {
hookAuthFailures.delete(clientKey);
};
return async (req, res) => {
const hooksConfig = getHooksConfig();
if (!hooksConfig) {
@@ -296,9 +258,9 @@ export function createHooksRequestHandler(
const token = extractHookToken(req);
const clientKey = resolveHookClientKey(req);
if (!safeEqualSecret(token, hooksConfig.token)) {
const throttle = recordHookAuthFailure(clientKey, Date.now());
if (throttle.throttled) {
const retryAfter = throttle.retryAfterSeconds ?? 1;
const throttle = hookAuthLimiter.check(clientKey, AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH);
if (!throttle.allowed) {
const retryAfter = throttle.retryAfterMs > 0 ? Math.ceil(throttle.retryAfterMs / 1000) : 1;
res.statusCode = 429;
res.setHeader("Retry-After", String(retryAfter));
res.setHeader("Content-Type", "text/plain; charset=utf-8");
@@ -306,12 +268,13 @@ export function createHooksRequestHandler(
logHooks.warn(`hook auth throttled for ${clientKey}; retry-after=${retryAfter}s`);
return true;
}
hookAuthLimiter.recordFailure(clientKey, AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH);
res.statusCode = 401;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Unauthorized");
return true;
}
clearHookAuthFailure(clientKey);
hookAuthLimiter.reset(clientKey, AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH);
if (req.method !== "POST") {
res.statusCode = 405;