341 lines
9.4 KiB
TypeScript
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(),
|
|
};
|
|
}
|