From c3058817ffb32bd1dc24b548082f1149a10b1ae2 Mon Sep 17 00:00:00 2001 From: Dream Hunter Date: Sat, 11 Apr 2026 21:06:13 +0800 Subject: [PATCH] feat(admin): add IP whitelist (strict allowlist mode) (#920) (#971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * fix(admin): revert P2 - keep simple ?? defaults for backward compat Co-Authored-By: Claude Opus 4.6 * 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 * docs(admin): add IP blacklist/whitelist documentation (zh + en) Co-Authored-By: Claude Opus 4.6 * 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 * fix(admin): suppress no-useless-escape lint warning in whitelist regex check Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- CHANGELOG.md | 2 + CHANGELOG_EN.md | 2 + e2e/tests/api/ip-whitelist.spec.ts | 274 ++++++++++++++++++ .../src/views/admin/IpBlacklistSettings.vue | 48 +++ vitepress-docs/docs/en/guide/feature/admin.md | 29 ++ vitepress-docs/docs/zh/guide/feature/admin.md | 29 ++ worker/src/admin_api/ip_blacklist_settings.ts | 37 +++ worker/src/ip_blacklist.ts | 239 +++++++++++---- 8 files changed, 605 insertions(+), 55 deletions(-) create mode 100644 e2e/tests/api/ip-whitelist.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a379145d..318c4904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 6f66113e..6e22aaa5 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -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) diff --git a/e2e/tests/api/ip-whitelist.spec.ts b/e2e/tests/api/ip-whitelist.spec.ts new file mode 100644 index 00000000..209d728f --- /dev/null +++ b/e2e/tests/api/ip-whitelist.spec.ts @@ -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, + }, + }); + }); +}); + diff --git a/frontend/src/views/admin/IpBlacklistSettings.vue b/frontend/src/views/admin/IpBlacklistSettings.vue index 25d6de07..a381887f 100644 --- a/frontend/src/views/admin/IpBlacklistSettings.vue +++ b/frontend/src/views/admin/IpBlacklistSettings.vue @@ -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 () => {
{{ t("tip_scope") }}
+
• {{ t("tip_whitelist") }}
• {{ t("tip_ip") }}
• {{ t("tip_asn") }}
• {{ t("tip_fingerprint") }}
@@ -130,6 +153,31 @@ onMounted(async () => {
+ + + + {{ t('enable_whitelist_tip') }} + + + + + + + + + + + diff --git a/vitepress-docs/docs/en/guide/feature/admin.md b/vitepress-docs/docs/en/guide/feature/admin.md index 5fff19f5..f6678050 100644 --- a/vitepress-docs/docs/en/guide/feature/admin.md +++ b/vitepress-docs/docs/en/guide/feature/admin.md @@ -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). diff --git a/vitepress-docs/docs/zh/guide/feature/admin.md b/vitepress-docs/docs/zh/guide/feature/admin.md index d7392736..9375cb97 100644 --- a/vitepress-docs/docs/zh/guide/feature/admin.md +++ b/vitepress-docs/docs/zh/guide/feature/admin.md @@ -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 小时后自动重置。 diff --git a/worker/src/admin_api/ip_blacklist_settings.ts b/worker/src/admin_api/ip_blacklist_settings.ts index 6f8423c8..fdc19704 100644 --- a/worker/src/admin_api/ip_blacklist_settings.ts +++ b/worker/src/admin_api/ip_blacklist_settings.ts @@ -18,6 +18,8 @@ async function getIpBlacklistSettings(c: Context): Promise): Promise(); + // 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): Promise): Promise 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): Promise 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 }; diff --git a/worker/src/ip_blacklist.ts b/worker/src/ip_blacklist.ts index 41b71014..4ab2ffb1 100644 --- a/worker/src/ip_blacklist.ts +++ b/worker/src/ip_blacklist.ts @@ -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, + 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, + 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, + 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, + settings: IpBlacklistSettings, + reqIp: string +): Promise { + 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 ): Promise { 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);