/** * IP Filter Middleware — Phase 6 * * IP-based access control with blacklist, whitelist, priority modes, and temporary bans. */ import { isIP } from "node:net"; import { getDbInstance } from "../../src/lib/db/core.ts"; // In-memory IP lists let _config = { enabled: false, mode: "blacklist", blacklist: new Set(), whitelist: new Set(), tempBans: new Map(), }; // Persistence (#6131): the config used to live in memory only, so every restart // (i.e. every OmniRoute update) reset it to Disabled + empty lists. It is now // persisted to the key_value table (namespace 'ipFilter', key 'config') and // lazily loaded on first access. better-sqlite3 is synchronous, so both the load // and the save stay in the sync hot path without extra startup wiring. tempBans // are intentionally NOT persisted — they are ephemeral, TTL-swept runtime state. const IP_FILTER_NAMESPACE = "ipFilter"; const IP_FILTER_KEY = "config"; let _loaded = false; function ensureLoaded() { if (_loaded) return; // Mark loaded up-front so a DB failure (build phase / cloud / migration not yet // run) degrades to in-memory only instead of retrying on every request. _loaded = true; try { const row = getDbInstance() .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") .get(IP_FILTER_NAMESPACE, IP_FILTER_KEY) as { value?: string } | undefined; if (!row?.value) return; const parsed = JSON.parse(row.value) as { enabled?: boolean; mode?: string; blacklist?: string[]; whitelist?: string[]; }; _config.enabled = parsed.enabled === true; if (typeof parsed.mode === "string") _config.mode = parsed.mode; _config.blacklist = new Set(Array.isArray(parsed.blacklist) ? parsed.blacklist : []); _config.whitelist = new Set(Array.isArray(parsed.whitelist) ? parsed.whitelist : []); } catch { // No DB / table yet — keep the in-memory defaults. } } function persist() { try { const payload = JSON.stringify({ enabled: _config.enabled, mode: _config.mode, blacklist: Array.from(_config.blacklist), whitelist: Array.from(_config.whitelist), }); getDbInstance() .prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)") .run(IP_FILTER_NAMESPACE, IP_FILTER_KEY, payload); } catch { // Best-effort persistence: never let a DB write failure break the request path. } } const _tempBanSweep = setInterval(() => { const now = Date.now(); const bans = _config.tempBans as Map; for (const [ip, entry] of bans) { if (now >= entry.until) bans.delete(ip); } }, 60_000); if (typeof _tempBanSweep === "object" && "unref" in _tempBanSweep) { (_tempBanSweep as { unref?: () => void }).unref?.(); } /** * Configure the IP filter */ export function configureIPFilter(config) { ensureLoaded(); if (config.enabled !== undefined) _config.enabled = config.enabled; if (config.mode) _config.mode = config.mode; if (config.blacklist) _config.blacklist = new Set(config.blacklist); if (config.whitelist) _config.whitelist = new Set(config.whitelist); persist(); } /** * Get current IP filter config (for API) */ export function getIPFilterConfig() { ensureLoaded(); return { enabled: _config.enabled, mode: _config.mode, blacklist: Array.from(_config.blacklist), whitelist: Array.from(_config.whitelist), tempBans: Array.from(_config.tempBans.entries()).map(([ip, info]) => ({ ip, until: new Date(info.until).toISOString(), reason: info.reason, remainingMs: Math.max(0, info.until - Date.now()), })), }; } /** * Check if an IP is allowed * @param {string} ip * @returns {{ allowed: boolean, reason?: string }} */ export function checkIP(ip) { ensureLoaded(); if (!_config.enabled) return { allowed: true }; if (!ip) return { allowed: true }; const normalizedIP = normalizeIP(ip); // Check temp bans first (highest priority) const ban = _config.tempBans.get(normalizedIP); if (ban) { if (Date.now() < ban.until) { return { allowed: false, reason: `Temporarily banned: ${ban.reason}` }; } _config.tempBans.delete(normalizedIP); // Expired } switch (_config.mode) { case "whitelist": // Only whitelisted IPs allowed if (!matchesAny(normalizedIP, _config.whitelist)) { return { allowed: false, reason: "IP not in whitelist" }; } return { allowed: true }; case "whitelist-priority": // Whitelist overrides blacklist if (matchesAny(normalizedIP, _config.whitelist)) { return { allowed: true }; } if (matchesAny(normalizedIP, _config.blacklist)) { return { allowed: false, reason: "IP blacklisted" }; } return { allowed: true }; case "blacklist": default: // Blacklisted IPs blocked if (matchesAny(normalizedIP, _config.blacklist)) { return { allowed: false, reason: "IP blacklisted" }; } return { allowed: true }; } } /** * Temporarily ban an IP */ export function tempBanIP(ip, durationMs, reason = "Automated ban") { const normalizedIP = normalizeIP(ip); _config.tempBans.set(normalizedIP, { until: Date.now() + durationMs, reason, }); } /** * Remove a temporary ban */ export function removeTempBan(ip) { _config.tempBans.delete(normalizeIP(ip)); } /** * Add IP to blacklist */ export function addToBlacklist(ip) { ensureLoaded(); _config.blacklist.add(normalizeIP(ip)); persist(); } /** * Remove IP from blacklist */ export function removeFromBlacklist(ip) { ensureLoaded(); _config.blacklist.delete(normalizeIP(ip)); persist(); } /** * Add IP to whitelist */ export function addToWhitelist(ip) { ensureLoaded(); _config.whitelist.add(normalizeIP(ip)); persist(); } /** * Remove IP from whitelist */ export function removeFromWhitelist(ip) { ensureLoaded(); _config.whitelist.delete(normalizeIP(ip)); persist(); } /** * Express/Next.js middleware factory */ export function createIPFilterMiddleware() { return (req, res, next) => { const ip = extractClientIP(req); const { allowed, reason } = checkIP(ip); if (!allowed) { const statusCode = 403; if (res.status) { // Express return res.status(statusCode).json({ error: reason || "Access denied" }); } // Raw response res.writeHead(statusCode, { "Content-Type": "application/json" }); return res.end(JSON.stringify({ error: reason || "Access denied" })); } if (next) next(); }; } /** * For Next.js App Router — check IP from request object */ export function checkRequestIP(request) { const ip = pickFirstValidIp(request.headers?.get?.("cf-connecting-ip")) || pickFirstValidIp(request.headers?.get?.("x-forwarded-for")) || pickFirstValidIp(request.headers?.get?.("x-real-ip")) || normalizeIP(request.ip || "") || "unknown"; return checkIP(ip); } // ─── Internal Helpers ─────────────────────────────────────────────────────── function normalizeIP(ip) { if (!ip) return ""; // Remove IPv6 prefix from IPv4-mapped addresses return ip.replace(/^::ffff:/, "").trim(); } function pickFirstValidIp(rawValue) { if (typeof rawValue !== "string" || rawValue.trim().length === 0) return null; const candidates = rawValue.split(","); for (const candidate of candidates) { const normalized = normalizeIP(candidate); if (normalized && isIP(normalized) !== 0) { return normalized; } } return null; } function matchesAny(ip, ipSet) { // Direct match if (ipSet.has(ip)) return true; // CIDR match for (const entry of ipSet) { if (entry.includes("/") && matchesCIDR(ip, entry)) return true; // Wildcard match (e.g., "192.168.*.*") if (entry.includes("*") && matchesWildcard(ip, entry)) return true; } return false; } function matchesCIDR(ip, cidr) { try { const [range, bits] = cidr.split("/"); const mask = parseInt(bits, 10); if (isNaN(mask) || mask < 0 || mask > 32) return false; const ipNum = ipToNum(ip); const rangeNum = ipToNum(range); if (ipNum === null || rangeNum === null) return false; const maskBits = (-1 << (32 - mask)) >>> 0; return (ipNum & maskBits) === (rangeNum & maskBits); } catch { return false; } } function ipToNum(ip) { const parts = ip.split("."); if (parts.length !== 4) return null; let num = 0; for (const p of parts) { const n = parseInt(p, 10); if (isNaN(n) || n < 0 || n > 255) return null; num = (num << 8) | n; } return num >>> 0; } function matchesWildcard(ip, pattern) { const ipParts = ip.split("."); const patParts = pattern.split("."); if (ipParts.length !== 4 || patParts.length !== 4) return false; return ipParts.every((part, i) => patParts[i] === "*" || part === patParts[i]); } function extractClientIP(req) { const headers = req.headers || {}; return ( pickFirstValidIp(headers["cf-connecting-ip"]) || pickFirstValidIp(headers["x-forwarded-for"]) || pickFirstValidIp(headers["x-real-ip"]) || pickFirstValidIp(req.socket?.remoteAddress) || pickFirstValidIp(req.ip) || "unknown" ); } /** * Reset config (for testing) */ export function resetIPFilter() { _loaded = false; _config = { enabled: false, mode: "blacklist", blacklist: new Set(), whitelist: new Set(), tempBans: new Map(), }; }