mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-08-30 04:26:42 +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>
300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
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
|
|
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
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
// eslint-disable-next-line no-useless-escape
|
|
return /[\^$.*+?\[\]{}()|\\]/.test(pattern);
|
|
}
|
|
|
|
/**
|
|
* Check if a value matches any blacklist pattern
|
|
* Supports both regex patterns and plain string matching
|
|
*
|
|
* @param value - The value to check (e.g., IP address, ASN organization)
|
|
* @param blacklist - Array of patterns (regex or plain strings)
|
|
* @param caseSensitive - Whether to use case-sensitive matching for plain strings (default: true for IP, false for ASN)
|
|
* @returns true if value is blacklisted, false otherwise
|
|
*
|
|
* @example
|
|
* // IP address matching (case-sensitive):
|
|
* isBlacklisted("192.168.1.100", ["192.168.1"], true) // true (substring match)
|
|
* isBlacklisted("10.0.0.5", ["^10\\.0\\.0\\.5$"], true) // true (regex match)
|
|
*
|
|
* // ASN organization matching (case-insensitive):
|
|
* isBlacklisted("Google LLC", ["google"], false) // true (case-insensitive)
|
|
* isBlacklisted("Amazon.com, Inc.", ["amazon"], false) // true
|
|
*/
|
|
function isBlacklisted(value: string | null | undefined, blacklist: string[], caseSensitive: boolean = true): boolean {
|
|
if (!value || !blacklist || blacklist.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const normalizedValue = value.trim();
|
|
|
|
return blacklist.some(pattern => {
|
|
const normalizedPattern = pattern.trim();
|
|
if (!normalizedPattern) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
if (looksLikeRegex(normalizedPattern)) {
|
|
// For regex patterns, add 'i' flag if case-insensitive matching is needed
|
|
const flags = caseSensitive ? '' : 'i';
|
|
const regex = new RegExp(normalizedPattern, flags);
|
|
return regex.test(normalizedValue);
|
|
}
|
|
|
|
// Plain string mode: substring matching
|
|
if (caseSensitive) {
|
|
return normalizedValue.includes(normalizedPattern);
|
|
}
|
|
return normalizedValue.toLowerCase().includes(normalizedPattern.toLowerCase());
|
|
} catch (error) {
|
|
console.warn(`Pattern "${normalizedPattern}" failed regex parsing, using plain matching`);
|
|
if (caseSensitive) {
|
|
return normalizedValue.includes(normalizedPattern);
|
|
}
|
|
return normalizedValue.toLowerCase().includes(normalizedPattern.toLowerCase());
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*
|
|
* @param c - Hono context
|
|
* @returns IP blacklist settings (may be null or have undefined fields)
|
|
*/
|
|
export async function getIpBlacklistSettings(
|
|
c: Context<HonoCustomType>
|
|
): Promise<IpBlacklistSettings | null> {
|
|
return await getJsonSetting<IpBlacklistSettings>(
|
|
c, CONSTANTS.IP_BLACKLIST_SETTINGS_KEY
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Layer 1 — IP whitelist check (strict allowlist mode).
|
|
* Independent from blacklist. Fails closed when client IP is missing.
|
|
*
|
|
* 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 {
|
|
const settings = await getIpBlacklistSettings(c);
|
|
if (!settings) return null;
|
|
|
|
const reqIp = c.req.raw.headers.get("cf-connecting-ip");
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Without a client IP, skip IP-keyed layers below
|
|
if (!reqIp) return null;
|
|
|
|
// Layer 2b: IP + ASN blacklist (skipped when whitelist trusted the IP)
|
|
if (!whitelistResult.hit) {
|
|
const ipAsnResp = checkIpAsnBlacklist(c, settings, reqIp);
|
|
if (ipAsnResp) return ipAsnResp;
|
|
}
|
|
|
|
// 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);
|
|
return null;
|
|
}
|
|
}
|