feat(admin): add IP whitelist (strict allowlist mode) (#920) (#971)

* 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:
Dream Hunter
2026-04-11 21:06:13 +08:00
committed by GitHub
parent 16c4e43871
commit c3058817ff
8 changed files with 605 additions and 55 deletions

View File

@@ -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
};

View File

@@ -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);