feat: add IP blacklist feature for rate-limited APIs (#753)

This commit is contained in:
Dream Hunter
2025-11-03 20:31:32 +08:00
committed by GitHub
parent fac249ed31
commit be36967b80
7 changed files with 361 additions and 0 deletions

View File

@@ -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<HonoCustomType>()
@@ -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);

View File

@@ -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<HonoCustomType>): Promise<Response> {
const settings = await getJsonSetting<IpBlacklistSettings>(
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<HonoCustomType>): Promise<Response> {
const settings = await c.req.json<IpBlacklistSettings>();
// 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,
}

View File

@@ -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",

148
worker/src/ip_blacklist.ts Normal file
View File

@@ -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<HonoCustomType>
): Promise<IpBlacklistSettings> {
const dbSettings = await getJsonSetting<IpBlacklistSettings>(
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<HonoCustomType>
): Promise<Response | null> {
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;
}
}

View File

@@ -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(