mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-07-06 14:52:09 +08:00
feat: add daily request limit and refactor access control (#759)
- Add daily request limit per IP in blacklist settings (1-1,000,000/day) - Refactor access control logic: merge blacklist and rate limit checks - Remove RATE_LIMIT_API_DAILY_REQUESTS env var, use database config instead - Move x-custom-auth check earlier in middleware chain - Add comprehensive English documentation (31 new guide pages) - Improve code structure and error handling 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,9 @@ async function getIpBlacklistSettings(c: Context<HonoCustomType>): Promise<Respo
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: []
|
||||
fingerprintBlacklist: [],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,6 +37,23 @@ async function saveIpBlacklistSettings(c: Context<HonoCustomType>): Promise<Resp
|
||||
return c.text("Invalid blacklist value", 400);
|
||||
}
|
||||
|
||||
if (!Array.isArray(settings.asnBlacklist)) {
|
||||
return c.text("Invalid asnBlacklist value", 400);
|
||||
}
|
||||
|
||||
if (!Array.isArray(settings.fingerprintBlacklist)) {
|
||||
return c.text("Invalid fingerprintBlacklist value", 400);
|
||||
}
|
||||
|
||||
if (typeof settings.enableDailyLimit !== 'boolean') {
|
||||
return c.text("Invalid enableDailyLimit value", 400);
|
||||
}
|
||||
|
||||
const limit = Number(settings.dailyRequestLimit);
|
||||
if (isNaN(limit) || limit < 1 || limit > 1000000) {
|
||||
return c.text("Invalid dailyRequestLimit value (must be between 1 and 1000000)", 400);
|
||||
}
|
||||
|
||||
// Add size limit
|
||||
const MAX_BLACKLIST_SIZE = 1000;
|
||||
if (settings.blacklist.length > MAX_BLACKLIST_SIZE) {
|
||||
@@ -44,55 +63,41 @@ async function saveIpBlacklistSettings(c: Context<HonoCustomType>): Promise<Resp
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.asnBlacklist.length > MAX_BLACKLIST_SIZE) {
|
||||
return c.text(
|
||||
`ASN blacklist exceeds maximum size (${MAX_BLACKLIST_SIZE} entries)`,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.fingerprintBlacklist.length > MAX_BLACKLIST_SIZE) {
|
||||
return c.text(
|
||||
`Fingerprint blacklist exceeds maximum size (${MAX_BLACKLIST_SIZE} entries)`,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// Sanitize patterns (trim and remove empty strings)
|
||||
// Both regex and plain strings are allowed
|
||||
const sanitizedBlacklist = settings.blacklist
|
||||
.map(pattern => pattern.trim())
|
||||
.filter(pattern => pattern.length > 0);
|
||||
|
||||
// Validate and sanitize ASN blacklist if provided
|
||||
let sanitizedAsnBlacklist: string[] = [];
|
||||
if (settings.asnBlacklist) {
|
||||
if (!Array.isArray(settings.asnBlacklist)) {
|
||||
return c.text("Invalid asnBlacklist value", 400);
|
||||
}
|
||||
const sanitizedAsnBlacklist = settings.asnBlacklist
|
||||
.map(pattern => pattern.trim())
|
||||
.filter(pattern => pattern.length > 0);
|
||||
|
||||
if (settings.asnBlacklist.length > MAX_BLACKLIST_SIZE) {
|
||||
return c.text(
|
||||
`ASN blacklist exceeds maximum size (${MAX_BLACKLIST_SIZE} entries)`,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
sanitizedAsnBlacklist = settings.asnBlacklist
|
||||
.map(pattern => pattern.trim())
|
||||
.filter(pattern => pattern.length > 0);
|
||||
}
|
||||
|
||||
// Validate and sanitize fingerprint blacklist if provided
|
||||
let sanitizedFingerprintBlacklist: string[] = [];
|
||||
if (settings.fingerprintBlacklist) {
|
||||
if (!Array.isArray(settings.fingerprintBlacklist)) {
|
||||
return c.text("Invalid fingerprintBlacklist value", 400);
|
||||
}
|
||||
|
||||
if (settings.fingerprintBlacklist.length > MAX_BLACKLIST_SIZE) {
|
||||
return c.text(
|
||||
`Fingerprint blacklist exceeds maximum size (${MAX_BLACKLIST_SIZE} entries)`,
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
sanitizedFingerprintBlacklist = settings.fingerprintBlacklist
|
||||
.map(pattern => pattern.trim())
|
||||
.filter(pattern => pattern.length > 0);
|
||||
}
|
||||
const sanitizedFingerprintBlacklist = settings.fingerprintBlacklist
|
||||
.map(pattern => pattern.trim())
|
||||
.filter(pattern => pattern.length > 0);
|
||||
|
||||
const sanitizedSettings: IpBlacklistSettings = {
|
||||
enabled: settings.enabled,
|
||||
blacklist: sanitizedBlacklist,
|
||||
asnBlacklist: sanitizedAsnBlacklist,
|
||||
fingerprintBlacklist: sanitizedFingerprintBlacklist
|
||||
fingerprintBlacklist: sanitizedFingerprintBlacklist,
|
||||
enableDailyLimit: settings.enableDailyLimit,
|
||||
dailyRequestLimit: settings.dailyRequestLimit
|
||||
};
|
||||
|
||||
await saveSetting(
|
||||
|
||||
@@ -6,10 +6,12 @@ import { CONSTANTS } from './constants';
|
||||
* IP Blacklist Settings stored in database
|
||||
*/
|
||||
export type IpBlacklistSettings = {
|
||||
enabled: boolean;
|
||||
blacklist: string[]; // Array of regex patterns or plain strings
|
||||
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
|
||||
enableDailyLimit?: boolean; // Enable daily request limit per IP
|
||||
dailyRequestLimit?: number; // Maximum requests per IP per day
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,21 +61,19 @@ function isBlacklisted(value: string | null | undefined, blacklist: string[], ca
|
||||
const flags = caseSensitive ? '' : 'i';
|
||||
const regex = new RegExp(normalizedPattern, flags);
|
||||
return regex.test(normalizedValue);
|
||||
} else {
|
||||
// Plain string mode: substring matching
|
||||
if (caseSensitive) {
|
||||
return normalizedValue.includes(normalizedPattern);
|
||||
} else {
|
||||
return normalizedValue.toLowerCase().includes(normalizedPattern.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
} else {
|
||||
return normalizedValue.toLowerCase().includes(normalizedPattern.toLowerCase());
|
||||
}
|
||||
return normalizedValue.toLowerCase().includes(normalizedPattern.toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -82,49 +82,30 @@ function isBlacklisted(value: string | null | undefined, blacklist: string[], ca
|
||||
* Get IP blacklist settings from database
|
||||
*
|
||||
* @param c - Hono context
|
||||
* @returns IP blacklist settings
|
||||
* @returns IP blacklist settings (may be null or have undefined fields)
|
||||
*/
|
||||
export async function getIpBlacklistSettings(
|
||||
c: Context<HonoCustomType>
|
||||
): Promise<IpBlacklistSettings> {
|
||||
const dbSettings = await getJsonSetting<IpBlacklistSettings>(
|
||||
): Promise<IpBlacklistSettings | null> {
|
||||
return await getJsonSetting<IpBlacklistSettings>(
|
||||
c, CONSTANTS.IP_BLACKLIST_SETTINGS_KEY
|
||||
);
|
||||
|
||||
if (dbSettings) {
|
||||
return {
|
||||
enabled: dbSettings.enabled || false,
|
||||
blacklist: dbSettings.blacklist || [],
|
||||
asnBlacklist: dbSettings.asnBlacklist || [],
|
||||
fingerprintBlacklist: dbSettings.fingerprintBlacklist || []
|
||||
};
|
||||
}
|
||||
|
||||
// Return default settings
|
||||
return {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to check IP blacklist for rate-limited endpoints
|
||||
* Returns 403 response if IP is blacklisted, null if any error occurs
|
||||
* 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
|
||||
*
|
||||
* @param c - Hono context
|
||||
* @returns Response if blacklisted, null otherwise (including errors)
|
||||
* @returns Response if blocked, null otherwise (including errors)
|
||||
*/
|
||||
export async function checkIpBlacklist(
|
||||
export async function checkAccessControl(
|
||||
c: Context<HonoCustomType>
|
||||
): Promise<Response | null> {
|
||||
try {
|
||||
// Get IP blacklist settings from database
|
||||
const settings = await getIpBlacklistSettings(c);
|
||||
|
||||
// Check if blacklist feature is enabled
|
||||
if (!settings.enabled) {
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -134,38 +115,56 @@ export async function checkIpBlacklist(
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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);
|
||||
|
||||
// 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);
|
||||
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;
|
||||
} catch (error) {
|
||||
// Log error but don't block request
|
||||
console.error('Error checking IP blacklist:', error);
|
||||
console.error('Error checking IP blacklist and rate limit:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
3
worker/src/types.d.ts
vendored
3
worker/src/types.d.ts
vendored
@@ -86,9 +86,6 @@ type Bindings = {
|
||||
|
||||
// webhook config
|
||||
FRONTEND_URL: string | undefined
|
||||
|
||||
// rate limiter config
|
||||
RATE_LIMIT_API_DAILY_REQUESTS: number | string | undefined
|
||||
}
|
||||
|
||||
type JwtPayload = {
|
||||
|
||||
@@ -14,7 +14,7 @@ import i18n from './i18n';
|
||||
import { email } from './email';
|
||||
import { scheduled } from './scheduled';
|
||||
import { getAdminPasswords, getPasswords, getBooleanValue, getStringArray } from './utils';
|
||||
import { checkIpBlacklist } from './ip_blacklist';
|
||||
import { checkAccessControl } from './ip_blacklist';
|
||||
|
||||
const API_PATHS = [
|
||||
"/api/",
|
||||
@@ -49,6 +49,17 @@ app.use('/*', async (c, next) => {
|
||||
const lang = c.req.raw.headers.get("x-lang");
|
||||
if (lang) { c.set("lang", lang); }
|
||||
const msgs = i18n.getMessages(lang || c.env.DEFAULT_LANG);
|
||||
|
||||
// check header x-custom-auth
|
||||
const passwords = getPasswords(c);
|
||||
if (passwords && passwords.length > 0) {
|
||||
const auth = c.req.raw.headers.get("x-custom-auth");
|
||||
if (!auth || !passwords.includes(auth)) {
|
||||
return c.text(msgs.CustomAuthPasswordMsg, 401)
|
||||
}
|
||||
}
|
||||
|
||||
// rate limit for specific endpoints
|
||||
if (
|
||||
c.req.path.startsWith("/api/new_address")
|
||||
|| c.req.path.startsWith("/api/send_mail")
|
||||
@@ -56,12 +67,6 @@ app.use('/*', async (c, next) => {
|
||||
|| c.req.path.startsWith("/user_api/register")
|
||||
|| c.req.path.startsWith("/user_api/verify_code")
|
||||
) {
|
||||
// Check IP blacklist first (early rejection for blacklisted IPs)
|
||||
const blacklistResponse = await checkIpBlacklist(c);
|
||||
if (blacklistResponse) {
|
||||
return blacklistResponse;
|
||||
}
|
||||
|
||||
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
|
||||
if (reqIp && c.env.RATE_LIMITER) {
|
||||
const { success } = await c.env.RATE_LIMITER.limit(
|
||||
@@ -71,18 +76,10 @@ app.use('/*', async (c, next) => {
|
||||
return c.text(`IP=${reqIp} Rate limit exceeded for ${c.req.path}`, 429)
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (reqIp && c.env.KV && c.env.RATE_LIMIT_API_DAILY_REQUESTS) {
|
||||
const daily_count_key = `limit|${reqIp}|${new Date().toISOString().slice(0, 10)}`
|
||||
const dailyLimit = parseInt(c.env.RATE_LIMIT_API_DAILY_REQUESTS.toString(), 10);
|
||||
const current_count = parseInt(await c.env.KV.get(daily_count_key) || "0", 10);
|
||||
if (current_count && current_count >= dailyLimit) {
|
||||
return c.text(`IP=${reqIp} Exceeded daily limit of ${dailyLimit} requests`, 429);
|
||||
}
|
||||
await c.env.KV.put(daily_count_key, ((current_count || 0) + 1).toString(), { expirationTtl: 24 * 60 * 60 });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
// Check access control (blacklist and daily limit)
|
||||
const accessControlResponse = await checkAccessControl(c);
|
||||
if (accessControlResponse) {
|
||||
return accessControlResponse;
|
||||
}
|
||||
}
|
||||
// webhook check
|
||||
@@ -148,16 +145,6 @@ const checkoutUserRolePayload = async (
|
||||
|
||||
// api auth
|
||||
app.use('/api/*', async (c, next) => {
|
||||
// check header x-custom-auth
|
||||
const passwords = getPasswords(c);
|
||||
if (passwords && passwords.length > 0) {
|
||||
const auth = c.req.raw.headers.get("x-custom-auth");
|
||||
if (!auth || !passwords.includes(auth)) {
|
||||
const lang = c.req.raw.headers.get("x-lang") || c.env.DEFAULT_LANG;
|
||||
const messages = i18n.getMessages(lang);
|
||||
return c.text(messages.CustomAuthPasswordMsg, 401)
|
||||
}
|
||||
}
|
||||
if (c.req.path.startsWith("/api/new_address")) {
|
||||
await checkUserPayload(c);
|
||||
await next();
|
||||
|
||||
Reference in New Issue
Block a user