-
Notifications
You must be signed in to change notification settings - Fork 37
Internal subnets from 100.64.0.0/10 ratelimiting #33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,301 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // IP Rate Limiting System with VPN/Internal Subnet Detection | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Targets Carrier Grade NAT (CGNAT) 100.64.0.0/10 and other VPN/internal ranges | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * CIDR Block Configuration | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Allows configuration via environment variables with defaults for common VPN/internal subnets | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function getBlockedSubnets(): string[] { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const envSubnets = process.env.RATELIMIT_SUBNETS; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (envSubnets) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return envSubnets.split(",").map((s) => s.trim()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Default: CGNAT range (RFC 6598) - 100.64.0.0/10 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Common VPN internal ranges | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "100.64.0.0/10", // CGNAT (RFC 6598) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "10.0.0.0/8", // RFC 1918 private | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "172.16.0.0/12", // RFC 1918 private | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "192.168.0.0/16", // RFC 1918 private | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "fc00::/7", // IPv6 unique local | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Parse CIDR notation into network address and prefix length | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function parseCidr(cidr: string): { network: bigint; mask: bigint; isV6: boolean } | null { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [ipStr, prefixStr] = cidr.split("/"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!ipStr || !prefixStr) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const prefix = parseInt(prefixStr, 10); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!Number.isFinite(prefix) || prefix < 0) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // IPv6 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (ipStr.includes(":")) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (prefix > 128) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ip = parseIPv6(ipStr); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (ip === null) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const mask = prefix === 0 ? 0n : (~0n << (128n - BigInt(prefix))) & ((1n << 128n) - 1n); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { network: ip & mask, mask, isV6: true }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // IPv4 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (prefix > 32) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ip = parseIPv4(ipStr); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (ip === null) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const mask = prefix === 0 ? 0n : (~0n << (32n - BigInt(prefix))) & 0xffffffffn; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { network: ip & mask, mask, isV6: false }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Parse IPv4 address string to bigint | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function parseIPv4(ip: string): bigint | null { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const parts = ip.split("."); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (parts.length !== 4) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let result = 0n; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const part of parts) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const num = parseInt(part, 10); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!Number.isFinite(num) || num < 0 || num > 255) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| result = (result << 8n) | BigInt(num); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return result; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Parse IPv6 address string to bigint | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function parseIPv6(ip: string): bigint | null { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Handle :: abbreviation | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const parts = ip.split(":"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const emptyIndex = parts.findIndex((p) => p === ""); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Expand :: to full 8 groups | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const expanded: string[] = []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (emptyIndex !== -1 && parts.length < 8) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const missing = 8 - parts.length + 1; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (let i = 0; i < parts.length; i++) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (parts[i] === "" && i === emptyIndex) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (let j = 0; j < missing; j++) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expanded.push("0"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else if (parts[i] !== "") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expanded.push(parts[i]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expanded.push(...parts.filter((p) => p !== "")); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (expanded.length !== 8) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let result = 0n; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const part of expanded) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const num = parseInt(part, 16); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!Number.isFinite(num) || num < 0 || num > 0xffff) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| result = (result << 16n) | BigInt(num); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return result; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+70
to
+101
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When The fix is to split on 🐛 Proposed fix for
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function parseIPv6(ip: string): bigint | null { | |
| // Handle :: abbreviation | |
| const parts = ip.split(":"); | |
| const emptyIndex = parts.findIndex((p) => p === ""); | |
| // Expand :: to full 8 groups | |
| const expanded: string[] = []; | |
| if (emptyIndex !== -1 && parts.length < 8) { | |
| const missing = 8 - parts.length + 1; | |
| for (let i = 0; i < parts.length; i++) { | |
| if (parts[i] === "" && i === emptyIndex) { | |
| for (let j = 0; j < missing; j++) { | |
| expanded.push("0"); | |
| } | |
| } else if (parts[i] !== "") { | |
| expanded.push(parts[i]); | |
| } | |
| } | |
| } else { | |
| expanded.push(...parts.filter((p) => p !== "")); | |
| } | |
| if (expanded.length !== 8) return null; | |
| let result = 0n; | |
| for (const part of expanded) { | |
| const num = parseInt(part, 16); | |
| if (!Number.isFinite(num) || num < 0 || num > 0xffff) return null; | |
| result = (result << 16n) | BigInt(num); | |
| } | |
| return result; | |
| } | |
| function parseIPv6(ip: string): bigint | null { | |
| // Split on "::" to correctly handle leading, middle, and trailing abbreviations | |
| const halves = ip.split("::"); | |
| if (halves.length > 2) return null; // More than one "::" is invalid | |
| let groups: string[]; | |
| if (halves.length === 2) { | |
| const left = halves[0] ? halves[0].split(":") : []; | |
| const right = halves[1] ? halves[1].split(":") : []; | |
| const zerosNeeded = 8 - left.length - right.length; | |
| if (zerosNeeded < 0) return null; | |
| groups = [...left, ...Array(zerosNeeded).fill("0"), ...right]; | |
| } else { | |
| groups = ip.split(":"); | |
| } | |
| if (groups.length !== 8) return null; | |
| let result = 0n; | |
| for (const part of groups) { | |
| const num = parseInt(part, 16); | |
| if (!Number.isFinite(num) || num < 0 || num > 0xffff) return null; | |
| result = (result << 16n) | BigInt(num); | |
| } | |
| return result; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@ratelimit.ts` around lines 70 - 101, The parseIPv6 function fails for
leading/trailing :: because it splits on ":" and drops adjacent empty segments;
fix by detecting and splitting on the literal "::" instead: if the IP contains
"::", split into left and right halves at "::", split each half by ":" (allowing
empty halves), compute how many zero groups to insert to reach 8 total groups,
then concatenate left groups + inserted "0" groups + right groups; validate that
the final array has exactly 8 hex groups and then parse each into a 16-bit value
as before (references: parseIPv6, expanded/result variables).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 High ratelimit.ts:222
The sweep method uses the caller's windowMs to filter all entries, but VPN and standard IPs have different windows. A VPN request (short window) would incorrectly purge valid timestamps from standard IP entries (longer window). Consider checking each key's prefix to apply the correct window size during sweep.
- private sweep(now: number, windowMs: number): void {
+ private sweep(now: number, _windowMs: number): void {
for (const [key, timestamps] of this.windows) {
+ const entryWindowMs = key.startsWith("vpn:") ? this.config.vpnWindowMs : this.config.standardWindowMs;
- const recent = timestamps.filter((timestamp) => now - timestamp <= windowMs);
+ const recent = timestamps.filter((timestamp) => now - timestamp <= entryWindowMs);🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file ratelimit.ts around lines 222-224:
The `sweep` method uses the caller's `windowMs` to filter all entries, but VPN and standard IPs have different windows. A VPN request (short window) would incorrectly purge valid timestamps from standard IP entries (longer window). Consider checking each key's prefix to apply the correct window size during sweep.
Evidence trail:
ratelimit.ts lines 183-192 (isRateLimited method showing different windowMs for VPN vs standard), lines 222-230 (sweep method applying single windowMs to all entries), lines 153-159 (config showing vpnWindowMs is configurable while standardWindowMs is hardcoded), lines 185, 212 (VPN and standard use different key prefixes but stored in same map)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Retry-After always uses vpnWindowMs even when a standard IP is rate-limited, and getVpnRateLimitConfig() is called twice.
isVpn is already resolved in this function. The 429 response body and Retry-After header should reflect the actual window that applies to the IP type. If VPN_RATELIMIT_WINDOW_MS is configured shorter than WINDOW_MS, a standard IP that gets rate-limited will receive a Retry-After value that is too short and will keep retrying prematurely. The function also calls getVpnRateLimitConfig() redundantly on both lines 287 and 293.
♻️ Proposed fix
export function checkVpnRateLimit(
ip: string,
limiter: VpnAwareRateLimiter,
log?: (level: string, component: string, message: string, meta?: Record<string, unknown>) => void,
): Response | null {
const isVpn = isVpnOrInternalIp(ip);
const isLimited = limiter.isRateLimited(ip, isVpn);
if (isLimited) {
const count = limiter.getRequestCount(ip, isVpn);
+ const config = getVpnRateLimitConfig();
+ const windowMs = isVpn ? config.vpnWindowMs : config.standardWindowMs;
+ const retryAfterSecs = Math.ceil(windowMs / 1000);
if (log) {
log("WARN", "ratelimit", `Rate limited ${isVpn ? "VPN/internal" : "regular"} IP`, {
ip,
isVpn,
count,
});
}
return new Response(
JSON.stringify({
error: "Too Many Requests",
- retryAfter: Math.ceil(getVpnRateLimitConfig().vpnWindowMs / 1000),
+ retryAfter: retryAfterSecs,
}),
{
status: 429,
headers: {
"Content-Type": "application/json",
- "Retry-After": String(Math.ceil(getVpnRateLimitConfig().vpnWindowMs / 1000)),
+ "Retry-After": String(retryAfterSecs),
"Cache-Control": "no-store",
},
},
);
}
return null;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function checkVpnRateLimit( | |
| ip: string, | |
| limiter: VpnAwareRateLimiter, | |
| log?: (level: string, component: string, message: string, meta?: Record<string, unknown>) => void, | |
| ): Response | null { | |
| const isVpn = isVpnOrInternalIp(ip); | |
| const isLimited = limiter.isRateLimited(ip, isVpn); | |
| if (isLimited) { | |
| const count = limiter.getRequestCount(ip, isVpn); | |
| if (log) { | |
| log("WARN", "ratelimit", `Rate limited ${isVpn ? "VPN/internal" : "regular"} IP`, { | |
| ip, | |
| isVpn, | |
| count, | |
| }); | |
| } | |
| return new Response( | |
| JSON.stringify({ | |
| error: "Too Many Requests", | |
| retryAfter: Math.ceil(getVpnRateLimitConfig().vpnWindowMs / 1000), | |
| }), | |
| { | |
| status: 429, | |
| headers: { | |
| "Content-Type": "application/json", | |
| "Retry-After": String(Math.ceil(getVpnRateLimitConfig().vpnWindowMs / 1000)), | |
| "Cache-Control": "no-store", | |
| }, | |
| }, | |
| ); | |
| } | |
| return null; | |
| } | |
| export function checkVpnRateLimit( | |
| ip: string, | |
| limiter: VpnAwareRateLimiter, | |
| log?: (level: string, component: string, message: string, meta?: Record<string, unknown>) => void, | |
| ): Response | null { | |
| const isVpn = isVpnOrInternalIp(ip); | |
| const isLimited = limiter.isRateLimited(ip, isVpn); | |
| if (isLimited) { | |
| const count = limiter.getRequestCount(ip, isVpn); | |
| const config = getVpnRateLimitConfig(); | |
| const windowMs = isVpn ? config.vpnWindowMs : config.standardWindowMs; | |
| const retryAfterSecs = Math.ceil(windowMs / 1000); | |
| if (log) { | |
| log("WARN", "ratelimit", `Rate limited ${isVpn ? "VPN/internal" : "regular"} IP`, { | |
| ip, | |
| isVpn, | |
| count, | |
| }); | |
| } | |
| return new Response( | |
| JSON.stringify({ | |
| error: "Too Many Requests", | |
| retryAfter: retryAfterSecs, | |
| }), | |
| { | |
| status: 429, | |
| headers: { | |
| "Content-Type": "application/json", | |
| "Retry-After": String(retryAfterSecs), | |
| "Cache-Control": "no-store", | |
| }, | |
| }, | |
| ); | |
| } | |
| return null; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@ratelimit.ts` around lines 267 - 301, The function checkVpnRateLimit
determines isVpn but always uses getVpnRateLimitConfig().vpnWindowMs for the
response; fix it by selecting the correct windowMs based on isVpn and only
calling config getters once: fetch the VPN config via getVpnRateLimitConfig()
and the standard config via getRateLimitConfig() (or the appropriate standard
config getter used in this codebase) at the start of the rate-limit branch, set
const windowMs = isVpn ? vpnConfig.vpnWindowMs : standardConfig.windowMs, then
use windowMs to compute retryAfter in both the JSON body and the "Retry-After"
header and remove the duplicate getVpnRateLimitConfig() calls; update references
in the Response creation inside checkVpnRateLimit accordingly.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -13,6 +13,11 @@ import { | |||||||||||||||||||||||||||||||||
| type GameState, | ||||||||||||||||||||||||||||||||||
| type RoundState, | ||||||||||||||||||||||||||||||||||
| } from "./game.ts"; | ||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||
| isVpnOrInternalIp, | ||||||||||||||||||||||||||||||||||
| VpnAwareRateLimiter, | ||||||||||||||||||||||||||||||||||
| checkVpnRateLimit, | ||||||||||||||||||||||||||||||||||
| } from "./ratelimit.ts"; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const VERSION = crypto.randomUUID().slice(0, 8); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
@@ -96,6 +101,14 @@ const historyCache = new Map<string, { body: string; expiresAt: number }>(); | |||||||||||||||||||||||||||||||||
| let lastRateWindowSweep = 0; | ||||||||||||||||||||||||||||||||||
| let lastHistoryCacheSweep = 0; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // VPN-aware rate limiter for stricter limits on VPN/internal IPs | ||||||||||||||||||||||||||||||||||
| const vpnRateLimiter = new VpnAwareRateLimiter({ | ||||||||||||||||||||||||||||||||||
| vpnWindowMs: parsePositiveInt(process.env.VPN_RATELIMIT_WINDOW_MS, 60_000), | ||||||||||||||||||||||||||||||||||
| vpnMaxRequests: parsePositiveInt(process.env.VPN_RATELIMIT_MAX_REQ, 10), | ||||||||||||||||||||||||||||||||||
| standardWindowMs: WINDOW_MS, | ||||||||||||||||||||||||||||||||||
| standardMaxRequests: HISTORY_LIMIT_PER_MIN, | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+119
to
+125
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🛠️ Proposed fixIn - vpnMaxRequests: parsePositiveInt(process.env.VPN_RATELIMIT_MAX_REQ, 5),
+ vpnMaxRequests: parsePositiveInt(process.env.VPN_RATELIMIT_MAX_REQ, 10),🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| function parsePositiveInt(value: string | undefined, fallback: number): number { | ||||||||||||||||||||||||||||||||||
| const parsed = Number.parseInt(value ?? "", 10); | ||||||||||||||||||||||||||||||||||
| return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; | ||||||||||||||||||||||||||||||||||
|
|
@@ -306,6 +319,15 @@ const server = Bun.serve<WsData>({ | |||||||||||||||||||||||||||||||||
| const url = new URL(req.url); | ||||||||||||||||||||||||||||||||||
| const ip = getClientIp(req, server); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Check VPN/internal IP rate limiting (very strict limits for VPN/subnet IPs) | ||||||||||||||||||||||||||||||||||
| // Skip health check endpoint from VPN rate limiting | ||||||||||||||||||||||||||||||||||
| if (url.pathname !== "/healthz") { | ||||||||||||||||||||||||||||||||||
| const vpnRateLimitResponse = checkVpnRateLimit(ip, vpnRateLimiter, log); | ||||||||||||||||||||||||||||||||||
macroscopeapp[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||
| if (vpnRateLimitResponse) { | ||||||||||||||||||||||||||||||||||
| return vpnRateLimitResponse; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+426
to
+433
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Static asset requests ( The VPN limiter check runs at line 324 before the 🛠️ Suggested fix- if (url.pathname !== "/healthz") {
+ if (url.pathname !== "/healthz" && !url.pathname.startsWith("/assets/")) {
const vpnRateLimitResponse = checkVpnRateLimit(ip, vpnRateLimiter, log);
if (vpnRateLimitResponse) {
return vpnRateLimitResponse;
}
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (url.pathname.startsWith("/assets/")) { | ||||||||||||||||||||||||||||||||||
| const path = `./public${url.pathname}`; | ||||||||||||||||||||||||||||||||||
| const file = Bun.file(path); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 High
ratelimit.ts:77IPv6 addresses ending with
::(likefc00::) fail to parse. Splittingfc00::produces['fc00', '', ''], so the trailing empty string at index 2 is skipped, leaving only 7 groups. Consider filtering empty strings from the non-expansion path or handling trailing::specially.🚀 Reply "fix it for me" or copy this AI Prompt for your agent: