From be36967b8068eb35b16fec5cd00ef6a6c313716d Mon Sep 17 00:00:00 2001 From: Dream Hunter Date: Mon, 3 Nov 2025 20:31:32 +0800 Subject: [PATCH] feat: add IP blacklist feature for rate-limited APIs (#753) --- frontend/src/views/Admin.vue | 6 + .../src/views/admin/IpBlacklistSettings.vue | 126 +++++++++++++++ worker/src/admin_api/index.ts | 5 + worker/src/admin_api/ip_blacklist_settings.ts | 68 ++++++++ worker/src/constants.ts | 1 + worker/src/ip_blacklist.ts | 148 ++++++++++++++++++ worker/src/worker.ts | 7 + 7 files changed, 361 insertions(+) create mode 100644 frontend/src/views/admin/IpBlacklistSettings.vue create mode 100644 worker/src/admin_api/ip_blacklist_settings.ts create mode 100644 worker/src/ip_blacklist.ts diff --git a/frontend/src/views/Admin.vue b/frontend/src/views/Admin.vue index 7952a685..acb2737a 100644 --- a/frontend/src/views/Admin.vue +++ b/frontend/src/views/Admin.vue @@ -25,6 +25,7 @@ import Telegram from './admin/Telegram.vue'; import Webhook from './admin/Webhook.vue'; import MailWebhook from './admin/MailWebhook.vue'; import WorkerConfig from './admin/WorkerConfig.vue'; +import IpBlacklistSettings from './admin/IpBlacklistSettings.vue'; const { adminAuth, showAdminAuth, adminTab, loading, @@ -72,6 +73,7 @@ const { t } = useI18n({ maintenance: 'Maintenance', database: 'Database', workerconfig: 'Worker Config', + ipBlacklistSettings: 'IP Blacklist', appearance: 'Appearance', about: 'About', ok: 'OK', @@ -100,6 +102,7 @@ const { t } = useI18n({ maintenance: '维护', database: '数据库', workerconfig: 'Worker 配置', + ipBlacklistSettings: 'IP 黑名单', appearance: '外观', about: '关于', ok: '确定', @@ -160,6 +163,9 @@ onMounted(async () => { + + + diff --git a/frontend/src/views/admin/IpBlacklistSettings.vue b/frontend/src/views/admin/IpBlacklistSettings.vue new file mode 100644 index 00000000..72efdf62 --- /dev/null +++ b/frontend/src/views/admin/IpBlacklistSettings.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/worker/src/admin_api/index.ts b/worker/src/admin_api/index.ts index 4d974f61..31b93161 100644 --- a/worker/src/admin_api/index.ts +++ b/worker/src/admin_api/index.ts @@ -14,6 +14,7 @@ import worker_config from './worker_config' import admin_mail_api from './admin_mail_api' import { sendMailbyAdmin } from './send_mail' import db_api from './db_api' +import ip_blacklist_settings from './ip_blacklist_settings' import { EmailRuleSettings } from '../models' export const api = new Hono() @@ -372,3 +373,7 @@ api.post("/admin/send_mail", sendMailbyAdmin); api.get('admin/db_version', db_api.getVersion); api.post('admin/db_initialize', db_api.initialize); api.post('admin/db_migration', db_api.migrate); + +// IP blacklist settings +api.get("/admin/ip_blacklist/settings", ip_blacklist_settings.getIpBlacklistSettings); +api.post("/admin/ip_blacklist/settings", ip_blacklist_settings.saveIpBlacklistSettings); diff --git a/worker/src/admin_api/ip_blacklist_settings.ts b/worker/src/admin_api/ip_blacklist_settings.ts new file mode 100644 index 00000000..420e0b34 --- /dev/null +++ b/worker/src/admin_api/ip_blacklist_settings.ts @@ -0,0 +1,68 @@ +import { Context } from "hono"; +import { CONSTANTS } from "../constants"; +import { getJsonSetting, saveSetting } from "../utils"; +import { IpBlacklistSettings } from "../ip_blacklist"; + +/** + * Get IP blacklist settings from database + */ +async function getIpBlacklistSettings(c: Context): Promise { + const settings = await getJsonSetting( + c, CONSTANTS.IP_BLACKLIST_SETTINGS_KEY + ); + + // Return default settings if not found + return c.json(settings || { + enabled: false, + blacklist: [] + }); +} + +/** + * Save IP blacklist settings to database + */ +async function saveIpBlacklistSettings(c: Context): Promise { + const settings = await c.req.json(); + + // Validate settings + if (typeof settings.enabled !== 'boolean') { + return c.text("Invalid enabled value", 400); + } + + if (!Array.isArray(settings.blacklist)) { + return c.text("Invalid blacklist value", 400); + } + + // Add size limit + const MAX_BLACKLIST_SIZE = 1000; + if (settings.blacklist.length > MAX_BLACKLIST_SIZE) { + return c.text( + `Blacklist exceeds maximum size (${MAX_BLACKLIST_SIZE} entries)`, + 400 + ); + } + + // Sanitize patterns (trim and remove empty strings) + // Both regex and plain strings are allowed + const sanitizedBlacklist = settings.blacklist + .map(pattern => pattern.trim()) + .filter(pattern => pattern.length > 0); + + const sanitizedSettings: IpBlacklistSettings = { + enabled: settings.enabled, + blacklist: sanitizedBlacklist + }; + + await saveSetting( + c, + CONSTANTS.IP_BLACKLIST_SETTINGS_KEY, + JSON.stringify(sanitizedSettings) + ); + + return c.json({ success: true }); +} + +export default { + getIpBlacklistSettings, + saveIpBlacklistSettings, +} diff --git a/worker/src/constants.ts b/worker/src/constants.ts index 10f29e77..1bf383fc 100644 --- a/worker/src/constants.ts +++ b/worker/src/constants.ts @@ -15,6 +15,7 @@ export const CONSTANTS = { NO_LIMIT_SEND_ADDRESS_LIST_KEY: 'no_limit_send_address_list', EMAIL_RULE_SETTINGS_KEY: 'email_rule_settings', ROLE_ADDRESS_CONFIG_KEY: 'role_address_config', + IP_BLACKLIST_SETTINGS_KEY: 'ip_blacklist_settings', // KV TG_KV_PREFIX: "temp-mail-telegram", diff --git a/worker/src/ip_blacklist.ts b/worker/src/ip_blacklist.ts new file mode 100644 index 00000000..bc56d4bc --- /dev/null +++ b/worker/src/ip_blacklist.ts @@ -0,0 +1,148 @@ +import { Context } from 'hono'; +import { getJsonSetting } from './utils'; +import { CONSTANTS } from './constants'; + +/** + * IP Blacklist Settings stored in database + */ +export type IpBlacklistSettings = { + enabled: boolean; + blacklist: string[]; // Array of regex patterns or plain strings +} + +/** + * Check if a string is a valid regex pattern + * Heuristic: contains regex special characters + */ +function looksLikeRegex(pattern: string): boolean { + // Check if pattern contains common regex metacharacters + return /[\^$.*+?\[\]{}()|\\]/.test(pattern); +} + +/** + * Check if an IP address matches any blacklist pattern + * Supports both regex patterns and plain string matching + * + * @param ip - The IP address to check (e.g., "192.168.1.100") + * @param blacklist - Array of patterns (regex or plain strings) + * @returns true if IP is blacklisted, false otherwise + * + * @example + * // Regex mode (has special chars: ^ $ . * + ? [ ] { } ( ) | \): + * isIpBlacklisted("192.168.1.100", ["^192\\.168\\.1\\."]) // true (regex match) + * isIpBlacklisted("10.0.0.5", ["^10\\.0\\.0\\.5$"]) // true (exact match) + * isIpBlacklisted("192.168.10.1", ["^192\\.168\\.1\\."]) // false (no match) + * + * // Plain string mode (no special chars - substring matching): + * // Rule: IP contains the pattern string + * isIpBlacklisted("192.168.1.100", ["192.168.1"]) // true (IP包含"192.168.1") + * isIpBlacklisted("192.168.1.255", ["192.168.1"]) // true (IP包含"192.168.1") + * isIpBlacklisted("10.0.0.5", ["10.0.0"]) // true (IP包含"10.0.0") + * isIpBlacklisted("192.168.2.100", ["192.168.1"]) // false (IP不包含"192.168.1") + * isIpBlacklisted("192.168.10.1", ["192.168.1"]) // true (IP包含"192.168.1") + */ +export function isIpBlacklisted(ip: string | null, blacklist: string[]): boolean { + if (!ip || !blacklist || blacklist.length === 0) { + return false; + } + + // Normalize IP (trim whitespace) + const normalizedIp = ip.trim(); + + // Check if IP matches any pattern in blacklist + return blacklist.some(pattern => { + const normalizedPattern = pattern.trim(); + if (!normalizedPattern) { + return false; + } + + try { + // Try to detect if this is a regex pattern + if (looksLikeRegex(normalizedPattern)) { + // Regex mode: test as regular expression + const regex = new RegExp(normalizedPattern); + return regex.test(normalizedIp); + } else { + // Plain string mode: substring matching + // 匹配规则:IP中包含设置的字符串就算匹配 + // Example: "192.168.1.100".includes("192.168.1") → true + return normalizedIp.includes(normalizedPattern); + } + } catch (error) { + // If regex parsing fails, fall back to plain string matching + console.warn(`Pattern "${normalizedPattern}" failed regex parsing, using plain matching`); + return normalizedIp.includes(normalizedPattern); + } + }); +} + +/** + * Get IP blacklist settings from database + * + * @param c - Hono context + * @returns IP blacklist settings + */ +export async function getIpBlacklistSettings( + c: Context +): Promise { + const dbSettings = await getJsonSetting( + c, CONSTANTS.IP_BLACKLIST_SETTINGS_KEY + ); + + if (dbSettings) { + return { + enabled: dbSettings.enabled || false, + blacklist: dbSettings.blacklist || [] + }; + } + + // Return default settings + return { + enabled: false, + blacklist: [] + }; +} + +/** + * Middleware to check IP blacklist for rate-limited endpoints + * Returns 403 response if IP is blacklisted, null if any error occurs + * + * @param c - Hono context + * @returns Response if blacklisted, null otherwise (including errors) + */ +export async function checkIpBlacklist( + c: Context +): Promise { + try { + // Get IP blacklist settings from database + const settings = await getIpBlacklistSettings(c); + + // Check if blacklist feature is enabled + if (!settings.enabled) { + return null; + } + + // Get IP address from CloudFlare header + const reqIp = c.req.raw.headers.get("cf-connecting-ip"); + if (!reqIp) { + return null; + } + + // Get blacklist + if (!settings.blacklist || settings.blacklist.length === 0) { + return null; + } + + // Check if IP is blacklisted + if (isIpBlacklisted(reqIp, settings.blacklist)) { + console.warn(`Blocked blacklisted IP: ${reqIp} for path: ${c.req.path}`); + return c.text(`Access denied: IP ${reqIp} is blacklisted`, 403); + } + + return null; + } catch (error) { + // Log error but don't block request + console.error('Error checking IP blacklist:', error); + return null; + } +} diff --git a/worker/src/worker.ts b/worker/src/worker.ts index 9a4214a2..c2e9ba96 100644 --- a/worker/src/worker.ts +++ b/worker/src/worker.ts @@ -14,6 +14,7 @@ import i18n from './i18n'; import { email } from './email'; import { scheduled } from './scheduled'; import { getAdminPasswords, getPasswords, getBooleanValue, getStringArray } from './utils'; +import { checkIpBlacklist } from './ip_blacklist'; const API_PATHS = [ "/api/", @@ -55,6 +56,12 @@ app.use('/*', async (c, next) => { || c.req.path.startsWith("/user_api/register") || c.req.path.startsWith("/user_api/verify_code") ) { + // Check IP blacklist first (early rejection for blacklisted IPs) + const blacklistResponse = await checkIpBlacklist(c); + if (blacklistResponse) { + return blacklistResponse; + } + const reqIp = c.req.raw.headers.get("cf-connecting-ip") if (reqIp && c.env.RATE_LIMITER) { const { success } = await c.env.RATE_LIMITER.limit(