Files
2026-07-13 13:39:12 +08:00

341 lines
9.4 KiB
TypeScript

/**
* 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<string, { until: number; reason: string }>;
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(),
};
}