import { timingSafeEqual } from "node:crypto";import type { MiddlewareHandler } from "hono";import type { AppContext } from "../app-context"; const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);const PUBLIC_PATHS = new Set<string>(["/health"]);const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;const DEFAULT_RATE_LIMIT_MAX_REQUESTS = 120;// Reads (GET/HEAD) get a much higher ceiling than mutations, and streaming /// monitoring endpoints are exempt so the UI's polling and SSE are never// throttled. This closes "GET is unthrottled" without disrupting normal use.const DEFAULT_READ_RATE_LIMIT_MAX_REQUESTS = 1200;const READ_RATE_LIMIT_EXEMPT_PATHS = new Set<string>([ "/health", "/status", "/metrics", "/events", "/api/docs", "/api/spec",]); type MutatingRateLimitEntry = { count: number; resetAt: number;}; const mutatingRateLimitStore = new Map<string, MutatingRateLimitEntry>();const readRateLimitStore = new Map<string, MutatingRateLimitEntry>(); function isReadRateLimitExempt(method: string, path: string): boolean { if (method.toUpperCase() === "OPTIONS") return true; if (READ_RATE_LIMIT_EXEMPT_PATHS.has(path)) return true; // Long-lived SSE / streaming endpoints. return path.endsWith("/stream") || path.endsWith("/events");} function isMutatingRequest(method: string): boolean { return MUTATING_METHODS.has(method.toUpperCase());} function isPublicRequest(method: string, path: string): boolean { return method.toUpperCase() === "OPTIONS" || PUBLIC_PATHS.has(path);} function getClientIpFromRequestHeaders(header: (name: string) => string | undefined): string { // Prefer CF-Connecting-IP: behind Cloudflare (the deployment) it is set by // the edge and is NOT client-controllable. X-Forwarded-For's leading entries // ARE client-appendable, so keying the rate limiter on the first XFF entry let // an attacker rotate it per request and never fill a bucket. Fall back to // x-real-ip, then the LAST XFF hop (added by the nearest proxy) — never the // first — and only when no trustworthy header is present. const cf = header("cf-connecting-ip")?.trim(); if (cf) return cf; const real = header("x-real-ip")?.trim(); if (real) return real; const forwarded = header("x-forwarded-for") ?.split(",") .map((value) => value.trim()) .filter((value) => value.length > 0); if (forwarded && forwarded.length > 0) return forwarded[forwarded.length - 1]!; return "unknown";} const RATE_LIMIT_STORE_CAP = 10_000; // Bound a rate-limit store: drop expired entries, then — if a flood of still-live// entries keeps it over the cap — evict oldest-inserted until under it, so the// map can't grow without bound (memory DoS).function pruneRateLimitStore(store: Map<string, MutatingRateLimitEntry>, now: number): void { if (store.size <= RATE_LIMIT_STORE_CAP) return; for (const [key, entry] of store) { if (entry.resetAt <= now) store.delete(key); } if (store.size > RATE_LIMIT_STORE_CAP) { let toEvict = store.size - RATE_LIMIT_STORE_CAP; for (const key of store.keys()) { store.delete(key); if (--toEvict <= 0) break; } }} function extractAuthToken(header: (name: string) => string | undefined): string | null { const bearer = header("authorization"); if (bearer) { const match = bearer.match(/^Bearer\s+(.+)$/i); if (match && match[1]) { return match[1].trim(); } } const apiKeyHeader = header("x-api-key"); if (apiKeyHeader?.trim()) { return apiKeyHeader.trim(); } return null;} function safeTokenEquals(expected: string, provided: string): boolean { const expectedBuffer = Buffer.from(expected); const providedBuffer = Buffer.from(provided); if (expectedBuffer.length !== providedBuffer.length) { return false; } return timingSafeEqual(expectedBuffer, providedBuffer);} function buildMutatingRateLimitKey(path: string, method: string, clientIp: string): string { return `${clientIp}:${method.toUpperCase()}:${path}`;} export function createMutatingAuthMiddleware(context: AppContext): MiddlewareHandler { return async (ctx, next) => { if (isPublicRequest(ctx.req.method, ctx.req.path)) { return next(); } const expectedApiKey = context.config.api_key?.trim(); if (!expectedApiKey) { return next(); } const providedToken = extractAuthToken((name) => ctx.req.header(name)); if (providedToken && safeTokenEquals(expectedApiKey, providedToken)) { return next(); } ctx.header("WWW-Authenticate", 'Bearer realm="local-studio-controller"'); return ctx.json({ detail: "Unauthorized" }, { status: 401 }); };} export function createMutatingRateLimitMiddleware( _context: AppContext, options: { windowMs?: number; maxRequests?: number; } = {},): MiddlewareHandler { const windowMs = options.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS; const maxRequests = options.maxRequests ?? DEFAULT_RATE_LIMIT_MAX_REQUESTS; return async (ctx, next) => { if (!isMutatingRequest(ctx.req.method)) { return next(); } const now = Date.now(); const clientIp = getClientIpFromRequestHeaders((name) => ctx.req.header(name)); const key = buildMutatingRateLimitKey(ctx.req.path, ctx.req.method, clientIp); const existing = mutatingRateLimitStore.get(key); const inWindow = Boolean(existing && existing.resetAt > now); const entry: MutatingRateLimitEntry = inWindow ? { count: existing!.count + 1, resetAt: existing!.resetAt } : { count: 1, resetAt: now + windowMs }; mutatingRateLimitStore.set(key, entry); const remaining = Math.max(maxRequests - entry.count, 0); ctx.header("X-RateLimit-Limit", String(maxRequests)); ctx.header("X-RateLimit-Remaining", String(remaining)); ctx.header("X-RateLimit-Reset", String(Math.ceil(entry.resetAt / 1000))); if (entry.count > maxRequests) { const retryAfterSeconds = Math.max(Math.ceil((entry.resetAt - now) / 1000), 1); ctx.header("Retry-After", String(retryAfterSeconds)); return ctx.json({ detail: "Rate limit exceeded" }, { status: 429 }); } pruneRateLimitStore(mutatingRateLimitStore, now); return next(); };} export function createReadRateLimitMiddleware( _context: AppContext, options: { windowMs?: number; maxRequests?: number; } = {},): MiddlewareHandler { const windowMs = options.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS; const maxRequests = options.maxRequests ?? DEFAULT_READ_RATE_LIMIT_MAX_REQUESTS; return async (ctx, next) => { // Mutations are covered by their own (stricter) limiter; only throttle reads // here, and skip streaming / monitoring endpoints entirely. if (isMutatingRequest(ctx.req.method) || isReadRateLimitExempt(ctx.req.method, ctx.req.path)) { return next(); } const now = Date.now(); const clientIp = getClientIpFromRequestHeaders((name) => ctx.req.header(name)); const key = buildMutatingRateLimitKey(ctx.req.path, ctx.req.method, clientIp); const existing = readRateLimitStore.get(key); const inWindow = Boolean(existing && existing.resetAt > now); const entry: MutatingRateLimitEntry = inWindow ? { count: existing!.count + 1, resetAt: existing!.resetAt } : { count: 1, resetAt: now + windowMs }; readRateLimitStore.set(key, entry); if (entry.count > maxRequests) { const retryAfterSeconds = Math.max(Math.ceil((entry.resetAt - now) / 1000), 1); ctx.header("Retry-After", String(retryAfterSeconds)); return ctx.json({ detail: "Rate limit exceeded" }, { status: 429 }); } pruneRateLimitStore(readRateLimitStore, now); return next(); };}
import { timingSafeEqual } from "node:crypto";
import type { MiddlewareHandler } from "hono";
import type { AppContext } from "../app-context";
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const PUBLIC_PATHS = new Set<string>(["/health"]);
const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
const DEFAULT_RATE_LIMIT_MAX_REQUESTS = 120;
// Reads (GET/HEAD) get a much higher ceiling than mutations, and streaming /
// monitoring endpoints are exempt so the UI's polling and SSE are never
// throttled. This closes "GET is unthrottled" without disrupting normal use.
const DEFAULT_READ_RATE_LIMIT_MAX_REQUESTS = 1200;
const READ_RATE_LIMIT_EXEMPT_PATHS = new Set<string>([
"/health",
"/status",
"/metrics",
"/events",
"/api/docs",
"/api/spec",
]);
type MutatingRateLimitEntry = {
count: number;
resetAt: number;
};
const mutatingRateLimitStore = new Map<string, MutatingRateLimitEntry>();
const readRateLimitStore = new Map<string, MutatingRateLimitEntry>();
function isReadRateLimitExempt(method: string, path: string): boolean {
if (method.toUpperCase() === "OPTIONS") return true;
if (READ_RATE_LIMIT_EXEMPT_PATHS.has(path)) return true;
// Long-lived SSE / streaming endpoints.
return path.endsWith("/stream") || path.endsWith("/events");
}
function isMutatingRequest(method: string): boolean {
return MUTATING_METHODS.has(method.toUpperCase());
}
function isPublicRequest(method: string, path: string): boolean {
return method.toUpperCase() === "OPTIONS" || PUBLIC_PATHS.has(path);
}
function getClientIpFromRequestHeaders(header: (name: string) => string | undefined): string {
// Prefer CF-Connecting-IP: behind Cloudflare (the deployment) it is set by
// the edge and is NOT client-controllable. X-Forwarded-For's leading entries
// ARE client-appendable, so keying the rate limiter on the first XFF entry let
// an attacker rotate it per request and never fill a bucket. Fall back to
// x-real-ip, then the LAST XFF hop (added by the nearest proxy) — never the
// first — and only when no trustworthy header is present.
const cf = header("cf-connecting-ip")?.trim();
if (cf) return cf;
const real = header("x-real-ip")?.trim();
if (real) return real;
const forwarded = header("x-forwarded-for")
?.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0);
if (forwarded && forwarded.length > 0) return forwarded[forwarded.length - 1]!;
return "unknown";
}
const RATE_LIMIT_STORE_CAP = 10_000;
// Bound a rate-limit store: drop expired entries, then — if a flood of still-live
// entries keeps it over the cap — evict oldest-inserted until under it, so the
// map can't grow without bound (memory DoS).
function pruneRateLimitStore(store: Map<string, MutatingRateLimitEntry>, now: number): void {
if (store.size <= RATE_LIMIT_STORE_CAP) return;
for (const [key, entry] of store) {
if (entry.resetAt <= now) store.delete(key);
}
if (store.size > RATE_LIMIT_STORE_CAP) {
let toEvict = store.size - RATE_LIMIT_STORE_CAP;
for (const key of store.keys()) {
store.delete(key);
if (--toEvict <= 0) break;
}
}
}
function extractAuthToken(header: (name: string) => string | undefined): string | null {
const bearer = header("authorization");
if (bearer) {
const match = bearer.match(/^Bearer\s+(.+)$/i);
if (match && match[1]) {
return match[1].trim();
}
}
const apiKeyHeader = header("x-api-key");
if (apiKeyHeader?.trim()) {
return apiKeyHeader.trim();
}
return null;
}
function safeTokenEquals(expected: string, provided: string): boolean {
const expectedBuffer = Buffer.from(expected);
const providedBuffer = Buffer.from(provided);
if (expectedBuffer.length !== providedBuffer.length) {
return false;
}
return timingSafeEqual(expectedBuffer, providedBuffer);
}
function buildMutatingRateLimitKey(path: string, method: string, clientIp: string): string {
return `${clientIp}:${method.toUpperCase()}:${path}`;
}
export function createMutatingAuthMiddleware(context: AppContext): MiddlewareHandler {
return async (ctx, next) => {
if (isPublicRequest(ctx.req.method, ctx.req.path)) {
return next();
}
const expectedApiKey = context.config.api_key?.trim();
if (!expectedApiKey) {
return next();
}
const providedToken = extractAuthToken((name) => ctx.req.header(name));
if (providedToken && safeTokenEquals(expectedApiKey, providedToken)) {
return next();
}
ctx.header("WWW-Authenticate", 'Bearer realm="local-studio-controller"');
return ctx.json({ detail: "Unauthorized" }, { status: 401 });
};
}
export function createMutatingRateLimitMiddleware(
_context: AppContext,
options: {
windowMs?: number;
maxRequests?: number;
} = {},
): MiddlewareHandler {
const windowMs = options.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS;
const maxRequests = options.maxRequests ?? DEFAULT_RATE_LIMIT_MAX_REQUESTS;
return async (ctx, next) => {
if (!isMutatingRequest(ctx.req.method)) {
return next();
}
const now = Date.now();
const clientIp = getClientIpFromRequestHeaders((name) => ctx.req.header(name));
const key = buildMutatingRateLimitKey(ctx.req.path, ctx.req.method, clientIp);
const existing = mutatingRateLimitStore.get(key);
const inWindow = Boolean(existing && existing.resetAt > now);
const entry: MutatingRateLimitEntry = inWindow
? { count: existing!.count + 1, resetAt: existing!.resetAt }
: { count: 1, resetAt: now + windowMs };
mutatingRateLimitStore.set(key, entry);
const remaining = Math.max(maxRequests - entry.count, 0);
ctx.header("X-RateLimit-Limit", String(maxRequests));
ctx.header("X-RateLimit-Remaining", String(remaining));
ctx.header("X-RateLimit-Reset", String(Math.ceil(entry.resetAt / 1000)));
if (entry.count > maxRequests) {
const retryAfterSeconds = Math.max(Math.ceil((entry.resetAt - now) / 1000), 1);
ctx.header("Retry-After", String(retryAfterSeconds));
return ctx.json({ detail: "Rate limit exceeded" }, { status: 429 });
}
pruneRateLimitStore(mutatingRateLimitStore, now);
return next();
};
}
export function createReadRateLimitMiddleware(
_context: AppContext,
options: {
windowMs?: number;
maxRequests?: number;
} = {},
): MiddlewareHandler {
const windowMs = options.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS;
const maxRequests = options.maxRequests ?? DEFAULT_READ_RATE_LIMIT_MAX_REQUESTS;
return async (ctx, next) => {
// Mutations are covered by their own (stricter) limiter; only throttle reads
// here, and skip streaming / monitoring endpoints entirely.
if (isMutatingRequest(ctx.req.method) || isReadRateLimitExempt(ctx.req.method, ctx.req.path)) {
return next();
}
const now = Date.now();
const clientIp = getClientIpFromRequestHeaders((name) => ctx.req.header(name));
const key = buildMutatingRateLimitKey(ctx.req.path, ctx.req.method, clientIp);
const existing = readRateLimitStore.get(key);
const inWindow = Boolean(existing && existing.resetAt > now);
const entry: MutatingRateLimitEntry = inWindow
? { count: existing!.count + 1, resetAt: existing!.resetAt }
: { count: 1, resetAt: now + windowMs };
readRateLimitStore.set(key, entry);
if (entry.count > maxRequests) {
const retryAfterSeconds = Math.max(Math.ceil((entry.resetAt - now) / 1000), 1);
ctx.header("Retry-After", String(retryAfterSeconds));
return ctx.json({ detail: "Rate limit exceeded" }, { status: 429 });
}
pruneRateLimitStore(readRateLimitStore, now);
return next();
};
}