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

@@ -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 () => {
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
<SenderAccess />
</n-tab-pane>
<n-tab-pane name="ipBlacklistSettings" :tab="t('ipBlacklistSettings')">
<IpBlacklistSettings />
</n-tab-pane>
<n-tab-pane name="webhook" :tab="t('webhookSettings')">
<Webhook />
</n-tab-pane>

View File

@@ -0,0 +1,126 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
title: 'IP Blacklist Settings',
tip: 'Block specific IPs from accessing rate-limited APIs. Supports text matching (e.g., "192.168.1") or regex (e.g., "^10\\.0\\.0\\.5$").',
manualInputPrompt: 'Type pattern and press Enter to add',
save: 'Save',
successTip: 'Save Success',
enable_ip_blacklist: 'Enable IP Blacklist',
enable_tip: 'Block IPs matching blacklist patterns from accessing rate-limited APIs',
ip_blacklist: 'IP Blacklist Patterns',
ip_blacklist_placeholder: 'Enter pattern (e.g., 192.168.1 or ^10\\.0\\.0\\.5$)',
},
zh: {
title: 'IP 黑名单设置',
tip: '阻止特定 IP 访问限流 API。支持文本匹配如 "192.168.1")或正则表达式(如 "^10\\.0\\.0\\.5$")。',
manualInputPrompt: '输入匹配模式后按回车键添加',
save: '保存',
successTip: '保存成功',
enable_ip_blacklist: '启用 IP 黑名单',
enable_tip: '阻止匹配黑名单的 IP 访问限流 API',
ip_blacklist: 'IP 黑名单匹配模式',
ip_blacklist_placeholder: '输入匹配模式例如192.168.1 或 ^10\\.0\\.0\\.5$',
}
}
});
const enabled = ref(false)
const ipBlacklist = ref([])
const fetchData = async () => {
try {
loading.value = true
const res = await api.fetch(`/admin/ip_blacklist/settings`)
enabled.value = res.enabled || false
ipBlacklist.value = res.blacklist || []
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false
}
}
const save = async () => {
try {
loading.value = true
await api.fetch(`/admin/ip_blacklist/settings`, {
method: 'POST',
body: JSON.stringify({
enabled: enabled.value,
blacklist: ipBlacklist.value || [],
})
})
message.success(t('successTip'))
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false
}
}
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center">
<n-card :title="t('title')" :bordered="false" embedded style="max-width: 800px;">
<template #header-extra>
<n-button @click="save" type="primary" :loading="loading">
{{ t('save') }}
</n-button>
</template>
<n-space vertical :size="20">
<n-alert :show-icon="false" :bordered="false" type="info">
<span>{{ t("tip") }}</span>
</n-alert>
<n-form-item-row :label="t('enable_ip_blacklist')">
<n-switch v-model:value="enabled" :round="false" />
<n-text depth="3" style="margin-left: 10px; font-size: 12px;">
{{ t('enable_tip') }}
</n-text>
</n-form-item-row>
<n-form-item-row :label="t('ip_blacklist')">
<n-select
v-model:value="ipBlacklist"
filterable
multiple
tag
:placeholder="t('ip_blacklist_placeholder')"
:disabled="!enabled">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
</n-space>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
margin: 20px;
}
</style>

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(