mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-06-30 03:42:13 +08:00
feat: add IP blacklist feature for rate-limited APIs (#753)
This commit is contained in:
@@ -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);
|
||||
|
||||
68
worker/src/admin_api/ip_blacklist_settings.ts
Normal file
68
worker/src/admin_api/ip_blacklist_settings.ts
Normal 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,
|
||||
}
|
||||
@@ -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
148
worker/src/ip_blacklist.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user