mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-05-06 20:32:55 +08:00
feat: add IP blacklist feature for rate-limited APIs (#753)
This commit is contained in:
@@ -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>
|
||||
|
||||
126
frontend/src/views/admin/IpBlacklistSettings.vue
Normal file
126
frontend/src/views/admin/IpBlacklistSettings.vue
Normal 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>
|
||||
@@ -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