177 lines
4.8 KiB
TypeScript
177 lines
4.8 KiB
TypeScript
/**
|
|
* Security Middleware — bot protection, rate limiting, and MCP guard.
|
|
*
|
|
* Layer 2 defense (Hono-level). Layer 1 is Traefik rate limiting at the edge.
|
|
*
|
|
* Exports:
|
|
* createSecurityMiddleware(opts) → Hono MiddlewareHandler
|
|
* mcpGuard → Hono MiddlewareHandler for /api/mcp/*
|
|
*/
|
|
|
|
import type { MiddlewareHandler } from "hono";
|
|
|
|
// ── IP extraction (Cloudflare tunnel → X-Forwarded-For fallback) ──
|
|
|
|
function getClientIP(headers: Headers): string {
|
|
return (
|
|
headers.get("cf-connecting-ip") ||
|
|
headers.get("x-forwarded-for")?.split(",")[0].trim() ||
|
|
"unknown"
|
|
);
|
|
}
|
|
|
|
// ── User-Agent filtering ──
|
|
|
|
const BAD_UA_PATTERNS = [
|
|
"scrapy", "python-requests", "masscan", "nikto", "sqlmap", "zgrab",
|
|
"nmap", "libwww-perl", "mj12bot", "ahrefsbot", "semrushbot", "dotbot",
|
|
"blexbot", "petalbot", "dataforseobot",
|
|
];
|
|
|
|
const ALLOW_UA_PATTERNS = [
|
|
"mozilla/5.0", "applewebkit", "gecko", "claude", "anthropic",
|
|
"chatgpt", "gptbot", "openai", "mcp-client",
|
|
];
|
|
|
|
function isBadUA(ua: string): boolean {
|
|
if (!ua) return true; // empty UA = suspicious
|
|
const lower = ua.toLowerCase();
|
|
// Check allow list first
|
|
for (const allow of ALLOW_UA_PATTERNS) {
|
|
if (lower.includes(allow)) return false;
|
|
}
|
|
// Check block list
|
|
for (const bad of BAD_UA_PATTERNS) {
|
|
if (lower.includes(bad)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ── Sliding-window rate limiter (in-memory, no packages) ──
|
|
|
|
interface RateBucket {
|
|
timestamps: number[];
|
|
}
|
|
|
|
const rateBuckets = new Map<string, RateBucket>();
|
|
|
|
// Cleanup stale buckets every 5 minutes
|
|
setInterval(() => {
|
|
const cutoff = Date.now() - 120_000; // 2min window
|
|
for (const [key, bucket] of rateBuckets) {
|
|
bucket.timestamps = bucket.timestamps.filter((t) => t > cutoff);
|
|
if (bucket.timestamps.length === 0) rateBuckets.delete(key);
|
|
}
|
|
}, 5 * 60 * 1000);
|
|
|
|
function checkRateLimit(key: string, maxPerMinute: number): boolean {
|
|
const now = Date.now();
|
|
const windowStart = now - 60_000;
|
|
|
|
let bucket = rateBuckets.get(key);
|
|
if (!bucket) {
|
|
bucket = { timestamps: [] };
|
|
rateBuckets.set(key, bucket);
|
|
}
|
|
|
|
// Prune old entries
|
|
bucket.timestamps = bucket.timestamps.filter((t) => t > windowStart);
|
|
|
|
if (bucket.timestamps.length >= maxPerMinute) {
|
|
return false; // rate limited
|
|
}
|
|
|
|
bucket.timestamps.push(now);
|
|
return true; // allowed
|
|
}
|
|
|
|
// ── Rate limit tiers ──
|
|
|
|
interface RateLimitTier {
|
|
pattern: RegExp;
|
|
anonymous: number;
|
|
authenticated: number;
|
|
}
|
|
|
|
const RATE_TIERS: RateLimitTier[] = [
|
|
{ pattern: /^\/api\/mi\//, anonymous: 10, authenticated: 30 },
|
|
{ pattern: /^\/api\/mcp/, anonymous: 30, authenticated: 120 },
|
|
{ pattern: /^\/api\/auth\//, anonymous: 10, authenticated: 10 },
|
|
{ pattern: /^\/api\//, anonymous: 60, authenticated: 300 },
|
|
];
|
|
|
|
function getTier(path: string): RateLimitTier {
|
|
for (const tier of RATE_TIERS) {
|
|
if (tier.pattern.test(path)) return tier;
|
|
}
|
|
return RATE_TIERS[RATE_TIERS.length - 1]; // general /api/* fallback
|
|
}
|
|
|
|
// ── Main security middleware ──
|
|
|
|
export interface SecurityMiddlewareOpts {
|
|
/** Skip rate limiting for these paths */
|
|
skipPaths?: string[];
|
|
}
|
|
|
|
const RATE_LIMIT_DISABLED = process.env.DISABLE_RATE_LIMIT === "1" || process.env.NODE_ENV === "test";
|
|
|
|
export function createSecurityMiddleware(
|
|
opts: SecurityMiddlewareOpts = {},
|
|
): MiddlewareHandler {
|
|
return async (c, next) => {
|
|
const path = c.req.path;
|
|
|
|
// Skip non-API routes
|
|
if (!path.startsWith("/api/")) return next();
|
|
|
|
// Skip configured paths
|
|
if (opts.skipPaths?.some((p) => path.startsWith(p))) return next();
|
|
|
|
// ── UA filter ──
|
|
const ua = c.req.header("user-agent") || "";
|
|
if (isBadUA(ua)) {
|
|
return c.json({ error: "Forbidden" }, 403);
|
|
}
|
|
|
|
// ── Rate limiting ──
|
|
if (!RATE_LIMIT_DISABLED) {
|
|
const ip = getClientIP(c.req.raw.headers);
|
|
const hasAuth = !!c.req.header("authorization")?.startsWith("Bearer ");
|
|
const tier = getTier(path);
|
|
const limit = hasAuth ? tier.authenticated : tier.anonymous;
|
|
const bucketKey = `${ip}:${tier.pattern.source}`;
|
|
|
|
if (!checkRateLimit(bucketKey, limit)) {
|
|
return c.json({ error: "Too many requests" }, 429);
|
|
}
|
|
}
|
|
|
|
return next();
|
|
};
|
|
}
|
|
|
|
// ── MCP endpoint guard ──
|
|
|
|
const AGENT_UA_PATTERNS = [
|
|
"claude", "anthropic", "openai", "gemini", "mcp-client", "litellm",
|
|
"chatgpt", "gptbot",
|
|
];
|
|
|
|
export const mcpGuard: MiddlewareHandler = async (c, next) => {
|
|
// Allow if Bearer token present
|
|
if (c.req.header("authorization")?.startsWith("Bearer ")) return next();
|
|
|
|
// Allow if internal key matches
|
|
const internalKey = process.env.INTERNAL_API_KEY;
|
|
if (internalKey && c.req.header("x-internal-key") === internalKey) return next();
|
|
|
|
// Allow known agent UAs
|
|
const ua = (c.req.header("user-agent") || "").toLowerCase();
|
|
for (const pattern of AGENT_UA_PATTERNS) {
|
|
if (ua.includes(pattern)) return next();
|
|
}
|
|
|
|
return c.json({ error: "MCP endpoint requires authentication" }, 401);
|
|
};
|