mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-05-06 20:32:55 +08:00
* feat(admin): add IP whitelist (strict allowlist mode) (#920) - Add enableWhitelist/whitelist fields to IpBlacklistSettings - Implement three-layer access control: whitelist → blacklist → daily limit - Whitelist uses exact match for IPv4/IPv6, regex for patterns - Whitelisted IPs skip blacklist checks (trusted) - Fail-closed when cf-connecting-ip missing under whitelist mode - Frontend: independent whitelist toggle + empty list protection - Backend: backward compatible (old frontends get defaults) - E2E tests: config validation + runtime behavior - Docs: CHANGELOG zh/en updated Closes #920 * fix(admin): address PR review feedback on IP whitelist - Add IPv4-mapped IPv6 (::ffff:x.x.x.x) exact match in isWhitelisted - Include error.message in whitelist regex parse failure log - Include actual/max size in whitelist size limit error message Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(admin): validate whitelist regex on save and preserve existing whitelist on partial update - Reject invalid regex patterns in whitelist at save time to prevent runtime lockout - Preserve existing enableWhitelist/whitelist from DB when older clients omit these fields Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(admin): revert P2 - keep simple ?? defaults for backward compat Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(admin): validate whitelist elements are strings before trimming Prevents 500 error when whitelist contains non-string elements (e.g. numbers, null) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs(admin): add IP blacklist/whitelist documentation (zh + en) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(admin): fix fingerprint blacklist bypass when cf-connecting-ip absent, improve e2e tests - Split checkBlacklist into checkFingerprintBlacklist (IP-independent) and checkIpAsnBlacklist - Fingerprint check now runs before the !reqIp early-return to prevent bypass - Add afterEach reset to config test group, extract RESET_SETTINGS constant - Strengthen whitelist-blocks test to deterministic 403 assertion - Add e2e tests: invalid regex rejection, non-string element rejection, fingerprint-blocks-without-IP Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(admin): suppress no-useless-escape lint warning in whitelist regex check Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,8 @@
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| IP 黑名单设置新增 **IP 白名单(严格模式)**:启用后仅允许匹配白名单的 IP 访问受限流保护的 API(创建邮箱、发送邮件、外部发送邮件、用户注册、验证码校验),其他所有 IP 一律拒绝(#920)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Admin| 修复 `/admin/address` 与 `/admin/users` 在使用完整邮箱(query 长度超过 50 字节)作为搜索条件时报错 `D1_ERROR: LIKE or GLOB pattern too complex` 的问题,长查询自动改用 `instr()` 绕开 D1 的 LIKE pattern 长度限制(#956)
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| Add **IP Whitelist (strict mode)** to IP blacklist settings: when enabled, ONLY whitelisted IPs can access rate-limited APIs (create address, send mail, external send mail, user register, verify code); all other IPs are denied (#920)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Admin| Fix `D1_ERROR: LIKE or GLOB pattern too complex` on `/admin/address` and `/admin/users` when searching by full email address (query length pushes the LIKE pattern over D1's 50-byte limit). Long queries now fall back to `instr()` to bypass the LIKE pattern length cap (#956)
|
||||
|
||||
274
e2e/tests/api/ip-whitelist.spec.ts
Normal file
274
e2e/tests/api/ip-whitelist.spec.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_PASSWORD = 'e2e-admin-pass';
|
||||
|
||||
const RESET_SETTINGS = {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: false,
|
||||
whitelist: [],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
};
|
||||
|
||||
test.describe('IP Whitelist Settings', () => {
|
||||
test.afterEach(async ({ request }) => {
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: RESET_SETTINGS,
|
||||
});
|
||||
});
|
||||
|
||||
test('get default IP whitelist settings returns disabled with empty list', async ({ request }) => {
|
||||
const res = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const settings = await res.json();
|
||||
expect(settings.enableWhitelist).toBeFalsy();
|
||||
expect(settings.whitelist).toEqual([]);
|
||||
});
|
||||
|
||||
test('save and retrieve IP whitelist settings', async ({ request }) => {
|
||||
// Save whitelist settings
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: ['1.2.3.4', '^192\\.168\\.1\\.\\d+$'],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
const saveBody = await saveRes.json();
|
||||
expect(saveBody.success).toBe(true);
|
||||
|
||||
// Retrieve and verify
|
||||
const getRes = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
});
|
||||
expect(getRes.ok()).toBe(true);
|
||||
const settings = await getRes.json();
|
||||
expect(settings.enableWhitelist).toBe(true);
|
||||
expect(settings.whitelist).toEqual(['1.2.3.4', '^192\\.168\\.1\\.\\d+$']);
|
||||
});
|
||||
|
||||
test('whitelist rejects empty list when enabled', async ({ request }) => {
|
||||
// Note: Frontend blocks this, but backend allows it (empty list = ignored)
|
||||
// This test verifies backend behavior
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: [],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
// Backend accepts empty whitelist (it will be ignored at runtime)
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
});
|
||||
|
||||
test('whitelist validates array type', async ({ request }) => {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: 'not-an-array', // Invalid type
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(false);
|
||||
expect(saveRes.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('whitelist enforces max size limit', async ({ request }) => {
|
||||
const largeList = Array.from({ length: 1001 }, (_, i) => `1.2.3.${i % 256}`);
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: largeList,
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(false);
|
||||
expect(saveRes.status()).toBe(400);
|
||||
const body = await saveRes.text();
|
||||
expect(body).toContain('whitelist');
|
||||
expect(body).toContain('1000');
|
||||
});
|
||||
|
||||
test('backward compatibility: old frontend without whitelist fields', async ({ request }) => {
|
||||
// Simulate old frontend that doesn't send enableWhitelist/whitelist
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: true,
|
||||
blacklist: ['10.0.0.1'],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
// enableWhitelist and whitelist omitted
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
// Should succeed with defaults applied
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
// Verify defaults were applied
|
||||
const getRes = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
});
|
||||
expect(getRes.ok()).toBe(true);
|
||||
const settings = await getRes.json();
|
||||
expect(settings.enableWhitelist).toBe(false);
|
||||
expect(settings.whitelist).toEqual([]);
|
||||
});
|
||||
|
||||
test('whitelist sanitizes patterns (trims and removes empty)', async ({ request }) => {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: [' 1.2.3.4 ', '', ' ', '5.6.7.8'],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
const getRes = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
});
|
||||
expect(getRes.ok()).toBe(true);
|
||||
const settings = await getRes.json();
|
||||
// Empty strings should be filtered out, whitespace trimmed
|
||||
expect(settings.whitelist).toEqual(['1.2.3.4', '5.6.7.8']);
|
||||
});
|
||||
|
||||
test('whitelist rejects invalid regex pattern', async ({ request }) => {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: { ...RESET_SETTINGS, whitelist: ['^[1.2.3.4$'] }, // invalid regex
|
||||
});
|
||||
expect(saveRes.ok()).toBe(false);
|
||||
expect(saveRes.status()).toBe(400);
|
||||
expect(await saveRes.text()).toContain('whitelist');
|
||||
});
|
||||
|
||||
test('whitelist rejects non-string elements', async ({ request }) => {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: { ...RESET_SETTINGS, whitelist: [1, null] },
|
||||
});
|
||||
expect(saveRes.ok()).toBe(false);
|
||||
expect(saveRes.status()).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('IP Whitelist Runtime Behavior', () => {
|
||||
test('whitelist with empty list allows requests (protection mode)', async ({ request }) => {
|
||||
// Enable whitelist with empty list
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: [],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
// Try to create address (rate-limited endpoint)
|
||||
// Should succeed because empty whitelist is ignored
|
||||
const res = await createTestAddress(request, 'whitelist-empty');
|
||||
expect(res.jwt).toBeTruthy();
|
||||
expect(res.address).toBeTruthy();
|
||||
});
|
||||
|
||||
test('whitelist blocks requests when IP does not match whitelist', async ({ request }) => {
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
...RESET_SETTINGS,
|
||||
enableWhitelist: true,
|
||||
whitelist: ['1.2.3.4'],
|
||||
},
|
||||
});
|
||||
|
||||
// In e2e, cf-connecting-ip is absent → fail-closed → 403
|
||||
const res = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||
data: { name: `whitelist-block-${Date.now()}`, domain: 'test.example.com' },
|
||||
});
|
||||
expect(res.status()).toBe(403);
|
||||
const body = await res.text();
|
||||
expect(body).toContain('IP');
|
||||
});
|
||||
|
||||
test('fingerprint blacklist blocks even when cf-connecting-ip is absent', async ({ request }) => {
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
...RESET_SETTINGS,
|
||||
enabled: true,
|
||||
fingerprintBlacklist: ['blocked-fingerprint-123'],
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||
headers: { 'x-fingerprint': 'blocked-fingerprint-123' },
|
||||
data: { name: `fp-block-${Date.now()}`, domain: 'test.example.com' },
|
||||
});
|
||||
expect(res.status()).toBe(403);
|
||||
expect(await res.text()).toContain('fingerprint');
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
// Reset whitelist to disabled after each test
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: false,
|
||||
whitelist: [],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,12 @@ const { t } = useI18n({
|
||||
successTip: 'Save Success',
|
||||
enable_ip_blacklist: 'Enable IP Blacklist',
|
||||
enable_tip: 'Block IPs matching blacklist patterns from accessing rate-limited APIs',
|
||||
enable_ip_whitelist: 'Enable IP Whitelist (Strict)',
|
||||
enable_whitelist_tip: 'Strict mode: ONLY IPs matching the whitelist can access rate-limited APIs. All other IPs will be denied.',
|
||||
ip_whitelist: 'IP Whitelist Patterns',
|
||||
ip_whitelist_placeholder: 'Exact IP (e.g., 1.2.3.4) or anchored regex (e.g., ^192\\.168\\.1\\.\\d+$)',
|
||||
tip_whitelist: 'IP Whitelist: Strict allowlist — plain entries must be EXACT IP matches (no substring). Use anchored regex (^...$) for ranges. Whitelisted IPs skip blacklist checks.',
|
||||
whitelist_empty_warning: 'IP whitelist is enabled but the list is empty. This is ignored by the server to prevent lockout. Please add at least one entry before enabling.',
|
||||
ip_blacklist: 'IP Blacklist Patterns',
|
||||
ip_blacklist_placeholder: 'Enter pattern (e.g., 192.168.1 or ^10\\.0\\.0\\.5$)',
|
||||
asn_blacklist: 'ASN Organization Blacklist',
|
||||
@@ -40,6 +46,12 @@ const { t } = useI18n({
|
||||
successTip: '保存成功',
|
||||
enable_ip_blacklist: '启用 IP 黑名单',
|
||||
enable_tip: '阻止匹配黑名单的 IP 访问限流 API',
|
||||
enable_ip_whitelist: '启用 IP 白名单(严格模式)',
|
||||
enable_whitelist_tip: '严格模式:仅允许匹配白名单的 IP 访问限流 API,其他所有 IP 将被拒绝',
|
||||
ip_whitelist: 'IP 白名单匹配模式',
|
||||
ip_whitelist_placeholder: '精确 IP(如 1.2.3.4)或锚定正则(如 ^192\\.168\\.1\\.\\d+$)',
|
||||
tip_whitelist: 'IP 白名单: 严格放行名单——纯文本必须是精确 IP(不支持子串匹配), 批量放行请用锚定正则 ^...$. 命中白名单的 IP 将跳过黑名单检查.',
|
||||
whitelist_empty_warning: 'IP 白名单已启用但列表为空,服务端将忽略该开关以防止锁死。请先添加至少一条白名单条目再启用。',
|
||||
ip_blacklist: 'IP 黑名单匹配模式',
|
||||
ip_blacklist_placeholder: '输入匹配模式(例如:192.168.1 或 ^10\\.0\\.0\\.5$)',
|
||||
asn_blacklist: 'ASN 组织(运营商)黑名单',
|
||||
@@ -63,6 +75,8 @@ const enabled = ref(false)
|
||||
const ipBlacklist = ref([])
|
||||
const asnBlacklist = ref([])
|
||||
const fingerprintBlacklist = ref([])
|
||||
const enableWhitelist = ref(false)
|
||||
const ipWhitelist = ref([])
|
||||
const enableDailyLimit = ref(false)
|
||||
const dailyRequestLimit = ref(1000)
|
||||
|
||||
@@ -74,6 +88,8 @@ const fetchData = async () => {
|
||||
ipBlacklist.value = res.blacklist || []
|
||||
asnBlacklist.value = res.asnBlacklist || []
|
||||
fingerprintBlacklist.value = res.fingerprintBlacklist || []
|
||||
enableWhitelist.value = res.enableWhitelist || false
|
||||
ipWhitelist.value = res.whitelist || []
|
||||
enableDailyLimit.value = res.enableDailyLimit || false
|
||||
dailyRequestLimit.value = res.dailyRequestLimit || 1000
|
||||
} catch (error) {
|
||||
@@ -84,6 +100,10 @@ const fetchData = async () => {
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (enableWhitelist.value && (!ipWhitelist.value || ipWhitelist.value.length === 0)) {
|
||||
message.warning(t('whitelist_empty_warning'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
loading.value = true
|
||||
await api.fetch(`/admin/ip_blacklist/settings`, {
|
||||
@@ -93,6 +113,8 @@ const save = async () => {
|
||||
blacklist: ipBlacklist.value || [],
|
||||
asnBlacklist: asnBlacklist.value || [],
|
||||
fingerprintBlacklist: fingerprintBlacklist.value || [],
|
||||
enableWhitelist: enableWhitelist.value,
|
||||
whitelist: ipWhitelist.value || [],
|
||||
enableDailyLimit: enableDailyLimit.value,
|
||||
dailyRequestLimit: dailyRequestLimit.value
|
||||
})
|
||||
@@ -123,6 +145,7 @@ onMounted(async () => {
|
||||
<n-alert :show-icon="false" :bordered="false" type="info">
|
||||
<div style="line-height: 1.8;">
|
||||
<div><strong>{{ t("tip_scope") }}</strong></div>
|
||||
<div>• {{ t("tip_whitelist") }}</div>
|
||||
<div>• {{ t("tip_ip") }}</div>
|
||||
<div>• {{ t("tip_asn") }}</div>
|
||||
<div>• {{ t("tip_fingerprint") }}</div>
|
||||
@@ -130,6 +153,31 @@ onMounted(async () => {
|
||||
</div>
|
||||
</n-alert>
|
||||
|
||||
<n-form-item-row :label="t('enable_ip_whitelist')">
|
||||
<n-switch v-model:value="enableWhitelist" :round="false" />
|
||||
<n-text depth="3" style="margin-left: 10px; font-size: 12px;">
|
||||
{{ t('enable_whitelist_tip') }}
|
||||
</n-text>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-form-item-row :label="t('ip_whitelist')">
|
||||
<n-select
|
||||
v-model:value="ipWhitelist"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
:placeholder="t('ip_whitelist_placeholder')"
|
||||
:disabled="!enableWhitelist">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<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;">
|
||||
|
||||
@@ -26,3 +26,32 @@ When searching for email addresses, pagination automatically resets to page 1.
|
||||
## If your website is for private access only, you can disable this check
|
||||
|
||||
`DISABLE_ADMIN_PASSWORD_CHECK = true`
|
||||
|
||||
## IP Blacklist / Whitelist
|
||||
|
||||
Configure access control in Admin Console → **IP Blacklist Settings**. Applies to: create address, send mail, external send mail API, user registration, and verify code endpoints.
|
||||
|
||||
### IP Whitelist (Strict Mode)
|
||||
|
||||
When enabled, **only** whitelisted IPs can access protected endpoints; all others receive 403.
|
||||
|
||||
- Plain entries: exact match (no substring), e.g. `1.2.3.4`
|
||||
- Regex entries: use anchored patterns, e.g. `^192\.168\.1\.\d+$`
|
||||
- Whitelisted IPs skip blacklist checks
|
||||
- If whitelist is enabled but the list is empty, the server ignores the switch (fail-open to prevent lockout)
|
||||
|
||||
### IP Blacklist
|
||||
|
||||
When enabled, matching IPs receive 403. Supports substring text matching or regex.
|
||||
|
||||
### ASN Organization Blacklist
|
||||
|
||||
Block by ISP/provider name, case-insensitive. Supports text or regex matching.
|
||||
|
||||
### Browser Fingerprint Blacklist
|
||||
|
||||
Block by `x-fingerprint` request header. Supports exact or regex matching.
|
||||
|
||||
### Daily Request Limit
|
||||
|
||||
Limit the maximum number of requests per IP per day (1–1,000,000). Exceeding the limit returns 429. Counter resets every 24 hours (UTC date boundary).
|
||||
|
||||
@@ -26,3 +26,32 @@
|
||||
## 如果你的网站只可私人访问,可通过此禁用检查
|
||||
|
||||
`DISABLE_ADMIN_PASSWORD_CHECK = true`
|
||||
|
||||
## IP 黑名单 / 白名单
|
||||
|
||||
在 Admin 控制台 → **IP 黑名单设置** 页面可配置访问控制,作用于以下接口:创建邮箱地址、发送邮件、外部发送邮件 API、用户注册、验证码校验。
|
||||
|
||||
### IP 白名单(严格模式)
|
||||
|
||||
启用后,**仅**匹配白名单的 IP 才能访问受保护接口,其他所有 IP 一律返回 403。
|
||||
|
||||
- 纯文本条目:精确匹配(不支持子串),例如 `1.2.3.4`
|
||||
- 正则条目:使用锚定正则,例如 `^192\.168\.1\.\d+$`
|
||||
- 白名单命中的 IP 会跳过黑名单检查
|
||||
- 白名单启用但列表为空时,服务端忽略该开关(防止锁死)
|
||||
|
||||
### IP 黑名单
|
||||
|
||||
启用后,匹配黑名单的 IP 返回 403。支持文本子串匹配或正则表达式。
|
||||
|
||||
### ASN 组织黑名单
|
||||
|
||||
按运营商/ISP 拉黑,不区分大小写,支持文本匹配或正则。
|
||||
|
||||
### 浏览器指纹黑名单
|
||||
|
||||
按 `x-fingerprint` 请求头拉黑,支持精确匹配或正则。
|
||||
|
||||
### 每日请求限流
|
||||
|
||||
限制单个 IP 每天最多请求次数(1–1,000,000),超出返回 429。计数以 UTC 日期为周期,24 小时后自动重置。
|
||||
|
||||
@@ -18,6 +18,8 @@ async function getIpBlacklistSettings(c: Context<HonoCustomType>): Promise<Respo
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: false,
|
||||
whitelist: [],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000
|
||||
});
|
||||
@@ -30,6 +32,10 @@ async function saveIpBlacklistSettings(c: Context<HonoCustomType>): Promise<Resp
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const settings = await c.req.json<IpBlacklistSettings>();
|
||||
|
||||
// Backward compatibility: default new fields if absent (older frontends)
|
||||
settings.enableWhitelist = settings.enableWhitelist ?? false;
|
||||
settings.whitelist = settings.whitelist ?? [];
|
||||
|
||||
// Validate settings
|
||||
if (typeof settings.enabled !== 'boolean') {
|
||||
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: enabled`, 400);
|
||||
@@ -47,6 +53,14 @@ async function saveIpBlacklistSettings(c: Context<HonoCustomType>): Promise<Resp
|
||||
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: fingerprintBlacklist`, 400);
|
||||
}
|
||||
|
||||
if (typeof settings.enableWhitelist !== 'boolean') {
|
||||
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: enableWhitelist`, 400);
|
||||
}
|
||||
|
||||
if (!Array.isArray(settings.whitelist)) {
|
||||
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: whitelist`, 400);
|
||||
}
|
||||
|
||||
if (typeof settings.enableDailyLimit !== 'boolean') {
|
||||
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: enableDailyLimit`, 400);
|
||||
}
|
||||
@@ -70,6 +84,10 @@ async function saveIpBlacklistSettings(c: Context<HonoCustomType>): Promise<Resp
|
||||
return c.text(`${msgs.BlacklistExceedsMaxSizeMsg}: fingerprintBlacklist (${MAX_BLACKLIST_SIZE})`, 400);
|
||||
}
|
||||
|
||||
if (settings.whitelist.length > MAX_BLACKLIST_SIZE) {
|
||||
return c.text(`${msgs.BlacklistExceedsMaxSizeMsg}: whitelist (${settings.whitelist.length}/${MAX_BLACKLIST_SIZE})`, 400);
|
||||
}
|
||||
|
||||
// Sanitize patterns (trim and remove empty strings)
|
||||
// Both regex and plain strings are allowed
|
||||
const sanitizedBlacklist = settings.blacklist
|
||||
@@ -84,11 +102,30 @@ async function saveIpBlacklistSettings(c: Context<HonoCustomType>): Promise<Resp
|
||||
.map(pattern => pattern.trim())
|
||||
.filter(pattern => pattern.length > 0);
|
||||
|
||||
const sanitizedWhitelist: string[] = [];
|
||||
for (const pattern of settings.whitelist) {
|
||||
if (typeof pattern !== 'string') {
|
||||
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: whitelist element must be a string`, 400);
|
||||
}
|
||||
const p = pattern.trim();
|
||||
if (!p) continue;
|
||||
// Validate regex patterns before saving to prevent runtime lockout
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
if (/[\^$.*+?\[\]{}()|\\]/.test(p)) {
|
||||
try { new RegExp(p); } catch {
|
||||
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: whitelist invalid regex: ${p}`, 400);
|
||||
}
|
||||
}
|
||||
sanitizedWhitelist.push(p);
|
||||
}
|
||||
|
||||
const sanitizedSettings: IpBlacklistSettings = {
|
||||
enabled: settings.enabled,
|
||||
blacklist: sanitizedBlacklist,
|
||||
asnBlacklist: sanitizedAsnBlacklist,
|
||||
fingerprintBlacklist: sanitizedFingerprintBlacklist,
|
||||
enableWhitelist: settings.enableWhitelist,
|
||||
whitelist: sanitizedWhitelist,
|
||||
enableDailyLimit: settings.enableDailyLimit,
|
||||
dailyRequestLimit: settings.dailyRequestLimit
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ export type IpBlacklistSettings = {
|
||||
blacklist?: string[]; // Array of regex patterns or plain strings
|
||||
asnBlacklist?: string[]; // Array of ASN organization patterns (e.g., "Google LLC", "Amazon")
|
||||
fingerprintBlacklist?: string[]; // Array of browser fingerprint patterns
|
||||
enableWhitelist?: boolean; // Enable IP whitelist (strict allowlist mode)
|
||||
whitelist?: string[]; // Array of exact IPs or anchored regex; only matching IPs are allowed
|
||||
enableDailyLimit?: boolean; // Enable daily request limit per IP
|
||||
dailyRequestLimit?: number; // Maximum requests per IP per day
|
||||
}
|
||||
@@ -78,6 +80,61 @@ function isBlacklisted(value: string | null | undefined, blacklist: string[], ca
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitelist-style match: strict allowlist, independent from blacklist semantics.
|
||||
* Plain IPv4/IPv6 entries are matched EXACTLY (not as regex) to avoid unintended matches.
|
||||
* Only explicit regex patterns (containing metacharacters beyond dots/colons) are treated as regex.
|
||||
*
|
||||
* Examples:
|
||||
* "1.2.3.4" → exact match only (NOT treated as regex /1.2.3.4/)
|
||||
* "2001:db8::1" → exact match only
|
||||
* "^192\\.168\\.1\\.\\d+$" → regex (contains anchors/escapes)
|
||||
*/
|
||||
function isWhitelisted(value: string | null | undefined, whitelist: string[] | undefined): boolean {
|
||||
if (!value || !whitelist || whitelist.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedValue = value.trim();
|
||||
|
||||
return whitelist.some(pattern => {
|
||||
const normalizedPattern = pattern.trim();
|
||||
if (!normalizedPattern) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv4 detection: digits and dots only → exact match (bypass regex heuristic)
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(normalizedPattern)) {
|
||||
return normalizedValue === normalizedPattern;
|
||||
}
|
||||
|
||||
// IPv4-mapped IPv6: ::ffff:1.2.3.4 → exact match
|
||||
if (/^::ffff:\d+\.\d+\.\d+\.\d+$/i.test(normalizedPattern)) {
|
||||
return normalizedValue === normalizedPattern;
|
||||
}
|
||||
|
||||
// IPv6 detection: hex digits and colons → exact match
|
||||
if (/^[0-9a-fA-F:]+$/.test(normalizedPattern) && normalizedPattern.includes(':')) {
|
||||
return normalizedValue === normalizedPattern;
|
||||
}
|
||||
|
||||
// Regex detection: contains metacharacters beyond dots/colons
|
||||
if (looksLikeRegex(normalizedPattern)) {
|
||||
try {
|
||||
const regex = new RegExp(normalizedPattern);
|
||||
return regex.test(normalizedValue);
|
||||
} catch (error) {
|
||||
// Invalid regex in a whitelist = never match (fail closed)
|
||||
console.warn(`Whitelist regex "${normalizedPattern}" failed to parse: ${(error as Error).message}, treating as no-match`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: other plain strings → exact match
|
||||
return normalizedValue === normalizedPattern;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get IP blacklist settings from database
|
||||
*
|
||||
@@ -93,75 +150,147 @@ export async function getIpBlacklistSettings(
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to check access control (blacklist and rate limiting) for rate-limited endpoints
|
||||
* Returns 403/429 response if blocked, null if allowed or any error occurs
|
||||
* Layer 1 — IP whitelist check (strict allowlist mode).
|
||||
* Independent from blacklist. Fails closed when client IP is missing.
|
||||
*
|
||||
* @param c - Hono context
|
||||
* @returns Response if blocked, null otherwise (including errors)
|
||||
* Returns:
|
||||
* - { response } — request is blocked (403)
|
||||
* - { hit: true } — whitelist active and the IP matched (trusted, skip blacklist)
|
||||
* - { hit: false } — whitelist not active or list empty (proceed normally)
|
||||
*/
|
||||
function checkIpWhitelist(
|
||||
c: Context<HonoCustomType>,
|
||||
settings: IpBlacklistSettings,
|
||||
reqIp: string | null
|
||||
): { response?: Response; hit: boolean } {
|
||||
const active = !!(settings.enableWhitelist && settings.whitelist && settings.whitelist.length > 0);
|
||||
if (!active) return { hit: false };
|
||||
|
||||
if (!reqIp) {
|
||||
console.warn(`Blocked request without cf-connecting-ip under whitelist mode for path: ${c.req.path}`);
|
||||
return { response: c.text(`Access denied: client IP unavailable`, 403), hit: false };
|
||||
}
|
||||
|
||||
if (isWhitelisted(reqIp, settings.whitelist)) {
|
||||
return { hit: true };
|
||||
}
|
||||
|
||||
console.warn(`Blocked non-whitelisted IP: ${reqIp} for path: ${c.req.path}`);
|
||||
return { response: c.text(`Access denied: IP ${reqIp} is not whitelisted`, 403), hit: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer 2a — Fingerprint blacklist check. Does NOT require a client IP.
|
||||
* Must run before the IP-based early-return so fingerprint bans cannot be bypassed.
|
||||
*/
|
||||
function checkFingerprintBlacklist(
|
||||
c: Context<HonoCustomType>,
|
||||
settings: IpBlacklistSettings,
|
||||
): Response | null {
|
||||
if (!settings.enabled) return null;
|
||||
if (!settings.fingerprintBlacklist || settings.fingerprintBlacklist.length === 0) return null;
|
||||
|
||||
const fingerprint = c.req.raw.headers.get("x-fingerprint");
|
||||
if (fingerprint && isBlacklisted(fingerprint, settings.fingerprintBlacklist, true)) {
|
||||
console.warn(`Blocked blacklisted fingerprint: ${fingerprint} for path: ${c.req.path}`);
|
||||
return c.text(`Access denied: Browser fingerprint is blacklisted`, 403);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer 2b — IP + ASN blacklist check. Requires a client IP.
|
||||
*/
|
||||
function checkIpAsnBlacklist(
|
||||
c: Context<HonoCustomType>,
|
||||
settings: IpBlacklistSettings,
|
||||
reqIp: string
|
||||
): Response | null {
|
||||
if (!settings.enabled) return null;
|
||||
|
||||
if (settings.blacklist && settings.blacklist.length > 0) {
|
||||
if (isBlacklisted(reqIp, settings.blacklist, true)) {
|
||||
console.warn(`Blocked blacklisted IP: ${reqIp} for path: ${c.req.path}`);
|
||||
return c.text(`Access denied: IP ${reqIp} is blacklisted`, 403);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.asnBlacklist && settings.asnBlacklist.length > 0) {
|
||||
const asOrganization = c.req.raw.cf?.asOrganization;
|
||||
if (asOrganization && isBlacklisted(asOrganization as string, settings.asnBlacklist, false)) {
|
||||
console.warn(`Blocked blacklisted ASN: ${asOrganization} (IP: ${reqIp}) for path: ${c.req.path}`);
|
||||
return c.text(`Access denied: ASN organization is blacklisted`, 403);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer 3 — Daily request limit per IP. Always runs (protects backend resources).
|
||||
*/
|
||||
async function checkDailyLimit(
|
||||
c: Context<HonoCustomType>,
|
||||
settings: IpBlacklistSettings,
|
||||
reqIp: string
|
||||
): Promise<Response | null> {
|
||||
if (!settings.enableDailyLimit || !settings.dailyRequestLimit || !c.env.KV) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const daily_count_key = `limit|${reqIp}|${new Date().toISOString().slice(0, 10)}`;
|
||||
const dailyLimit = settings.dailyRequestLimit;
|
||||
const current_count = parseInt(await c.env.KV.get(daily_count_key) || "0", 10);
|
||||
|
||||
if (current_count && current_count >= dailyLimit) {
|
||||
console.warn(`Blocked IP ${reqIp} exceeded daily limit of ${dailyLimit} requests for path: ${c.req.path}`);
|
||||
return c.text(`IP=${reqIp} Exceeded daily limit of ${dailyLimit} requests`, 429);
|
||||
}
|
||||
|
||||
// Increment counter with 24-hour expiration
|
||||
await c.env.KV.put(daily_count_key, ((current_count || 0) + 1).toString(), { expirationTtl: 24 * 60 * 60 });
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to check access control for rate-limited endpoints.
|
||||
* Composes three independent layers in order:
|
||||
* Layer 1 — IP whitelist (strict allowlist; hit = trust, skip blacklist)
|
||||
* Layer 2 — Blacklist (IP / ASN / fingerprint)
|
||||
* Layer 3 — Daily request limit
|
||||
*
|
||||
* Returns 403/429 response if blocked, null if allowed or any error occurs.
|
||||
*/
|
||||
export async function checkAccessControl(
|
||||
c: Context<HonoCustomType>
|
||||
): Promise<Response | null> {
|
||||
try {
|
||||
// Get IP blacklist settings from database
|
||||
const settings = await getIpBlacklistSettings(c);
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
if (!settings) return null;
|
||||
|
||||
// Get IP address from CloudFlare header
|
||||
const reqIp = c.req.raw.headers.get("cf-connecting-ip");
|
||||
if (!reqIp) {
|
||||
return null;
|
||||
|
||||
// Layer 1: whitelist
|
||||
const whitelistResult = checkIpWhitelist(c, settings, reqIp);
|
||||
if (whitelistResult.response) return whitelistResult.response;
|
||||
|
||||
// Layer 2a: fingerprint blacklist (does not require IP)
|
||||
if (!whitelistResult.hit) {
|
||||
const fingerprintResp = checkFingerprintBlacklist(c, settings);
|
||||
if (fingerprintResp) return fingerprintResp;
|
||||
}
|
||||
|
||||
// Check if blacklist feature is enabled
|
||||
if (settings.enabled) {
|
||||
// Check if IP is blacklisted (case-sensitive matching)
|
||||
if (settings.blacklist && settings.blacklist.length > 0) {
|
||||
if (isBlacklisted(reqIp, settings.blacklist, true)) {
|
||||
console.warn(`Blocked blacklisted IP: ${reqIp} for path: ${c.req.path}`);
|
||||
return c.text(`Access denied: IP ${reqIp} is blacklisted`, 403);
|
||||
}
|
||||
}
|
||||
// Without a client IP, skip IP-keyed layers below
|
||||
if (!reqIp) return null;
|
||||
|
||||
// Check ASN organization blacklist
|
||||
if (settings.asnBlacklist && settings.asnBlacklist.length > 0) {
|
||||
const asOrganization = c.req.raw.cf?.asOrganization;
|
||||
// Check ASN with case-insensitive matching
|
||||
if (asOrganization && isBlacklisted(asOrganization as string, settings.asnBlacklist, false)) {
|
||||
console.warn(`Blocked blacklisted ASN: ${asOrganization} (IP: ${reqIp}) for path: ${c.req.path}`);
|
||||
return c.text(`Access denied: ASN organization is blacklisted`, 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Check browser fingerprint blacklist
|
||||
if (settings.fingerprintBlacklist && settings.fingerprintBlacklist.length > 0) {
|
||||
const fingerprint = c.req.raw.headers.get("x-fingerprint");
|
||||
// Check fingerprint with case-sensitive matching
|
||||
if (fingerprint && isBlacklisted(fingerprint, settings.fingerprintBlacklist, true)) {
|
||||
console.warn(`Blocked blacklisted fingerprint: ${fingerprint} (IP: ${reqIp}) for path: ${c.req.path}`);
|
||||
return c.text(`Access denied: Browser fingerprint is blacklisted`, 403);
|
||||
}
|
||||
}
|
||||
// Layer 2b: IP + ASN blacklist (skipped when whitelist trusted the IP)
|
||||
if (!whitelistResult.hit) {
|
||||
const ipAsnResp = checkIpAsnBlacklist(c, settings, reqIp);
|
||||
if (ipAsnResp) return ipAsnResp;
|
||||
}
|
||||
|
||||
// Check daily request limit (independent of blacklist feature)
|
||||
if (settings.enableDailyLimit && settings.dailyRequestLimit && c.env.KV) {
|
||||
const daily_count_key = `limit|${reqIp}|${new Date().toISOString().slice(0, 10)}`;
|
||||
const dailyLimit = settings.dailyRequestLimit;
|
||||
const current_count = parseInt(await c.env.KV.get(daily_count_key) || "0", 10);
|
||||
|
||||
if (current_count && current_count >= dailyLimit) {
|
||||
console.warn(`Blocked IP ${reqIp} exceeded daily limit of ${dailyLimit} requests for path: ${c.req.path}`);
|
||||
return c.text(`IP=${reqIp} Exceeded daily limit of ${dailyLimit} requests`, 429);
|
||||
}
|
||||
|
||||
// Increment counter with 24-hour expiration
|
||||
await c.env.KV.put(daily_count_key, ((current_count || 0) + 1).toString(), { expirationTtl: 24 * 60 * 60 });
|
||||
}
|
||||
|
||||
return null;
|
||||
// Layer 3: daily limit (always enforced)
|
||||
return await checkDailyLimit(c, settings, reqIp);
|
||||
} catch (error) {
|
||||
// Log error but don't block request
|
||||
console.error('Error checking IP blacklist and rate limit:', error);
|
||||
|
||||
Reference in New Issue
Block a user