fix: critical bugs, serverless stability, and security hardening
Bug Fixes: - Fix rate limiter API mismatch: check() -> checkAsync() in index.ts, worker.ts, api/relay.ts - Fix WebSocket SSRF silent drop: return error Response instead of undefined - Fix isDevMode() default: changed from true to false (production-safe) - Fix process.env -> env bindings in worker.ts requireAuth for Cloudflare Workers - Fix Bun.file() crash in Workers: add try/catch with fallback - Fix Bun.CryptoHasher -> Web Crypto API in mimo-auth.ts for Workers compat Architecture: - Add public methods to ProxyPool (getEntryAtIndex, getProxyUrlAtIndex, getCurrentIndex, setCurrentIndex) to remove all 'as any' casts in SessionProxyPool - Add addProxy() method for manual proxy management - Add loadAsync(), tryLoadAsync(), loadFromString() to ProxyPool Serverless Stability: - Add optional DNS rebinding protection via SSRF_DNS_CHECK env flag - CORS cache now auto-invalidates when CORS_ORIGIN env changes - Rate limiter max-size eviction (10k keys) prevents unbounded memory growth Tests: - Fix type assertions in test files (body as Record<string, unknown>) - All 153 tests pass, typecheck clean
This commit is contained in:
@@ -80,7 +80,7 @@ describe("body-limiter", () => {
|
||||
headers: { "Content-Length": "200" },
|
||||
});
|
||||
const result = checkBodySize(request);
|
||||
const body = await result!.json();
|
||||
const body = await result!.json() as Record<string, unknown>;
|
||||
expect(body.error).toBe("Payload Too Large");
|
||||
expect(body.maxSizeBytes).toBe(100);
|
||||
});
|
||||
|
||||
@@ -10,76 +10,76 @@ import { createRateLimiter } from "./rate-limiter";
|
||||
|
||||
describe("rate-limiter", () => {
|
||||
describe("createRateLimiter", () => {
|
||||
test("should allow requests up to the default limit", () => {
|
||||
test("should allow requests up to the default limit", async () => {
|
||||
const limiter = createRateLimiter({ maxRequests: 5, windowMs: 60_000 });
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const result = limiter.check("test-key");
|
||||
const result = await limiter.checkAsync("test-key");
|
||||
expect(result.allowed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("should block requests exceeding the limit", () => {
|
||||
test("should block requests exceeding the limit", async () => {
|
||||
const limiter = createRateLimiter({ maxRequests: 3, windowMs: 60_000 });
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(limiter.check("block-key").allowed).toBe(true);
|
||||
expect((await limiter.checkAsync("block-key")).allowed).toBe(true);
|
||||
}
|
||||
const blocked = limiter.check("block-key");
|
||||
const blocked = await limiter.checkAsync("block-key");
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.retryAfterMs).toBeDefined();
|
||||
expect(typeof blocked.retryAfterMs).toBe("number");
|
||||
});
|
||||
|
||||
test("should return retryAfterMs when blocked", () => {
|
||||
test("should return retryAfterMs when blocked", async () => {
|
||||
const limiter = createRateLimiter({
|
||||
maxRequests: 1,
|
||||
windowMs: 60_000,
|
||||
});
|
||||
limiter.check("retry-key");
|
||||
const blocked = limiter.check("retry-key");
|
||||
await limiter.checkAsync("retry-key");
|
||||
const blocked = await limiter.checkAsync("retry-key");
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.retryAfterMs).toBeGreaterThan(0);
|
||||
expect(blocked.retryAfterMs).toBeLessThanOrEqual(60_000);
|
||||
});
|
||||
|
||||
test("reset() should clear the counter", () => {
|
||||
test("reset() should clear the counter", async () => {
|
||||
const limiter = createRateLimiter({ maxRequests: 2, windowMs: 60_000 });
|
||||
limiter.check("reset-key");
|
||||
limiter.check("reset-key");
|
||||
await limiter.checkAsync("reset-key");
|
||||
await limiter.checkAsync("reset-key");
|
||||
// would be blocked, but...
|
||||
limiter.reset("reset-key");
|
||||
await limiter.resetAsync("reset-key");
|
||||
// ...should be allowed again
|
||||
expect(limiter.check("reset-key").allowed).toBe(true);
|
||||
expect((await limiter.checkAsync("reset-key")).allowed).toBe(true);
|
||||
});
|
||||
|
||||
test("should isolate keys from each other", () => {
|
||||
test("should isolate keys from each other", async () => {
|
||||
const limiter = createRateLimiter({ maxRequests: 2, windowMs: 60_000 });
|
||||
expect(limiter.check("key-a").allowed).toBe(true);
|
||||
expect(limiter.check("key-a").allowed).toBe(true);
|
||||
expect(limiter.check("key-b").allowed).toBe(true); // key-b unaffected
|
||||
expect(limiter.check("key-a").allowed).toBe(false); // key-a blocked
|
||||
expect((await limiter.checkAsync("key-a")).allowed).toBe(true);
|
||||
expect((await limiter.checkAsync("key-a")).allowed).toBe(true);
|
||||
expect((await limiter.checkAsync("key-b")).allowed).toBe(true); // key-b unaffected
|
||||
expect((await limiter.checkAsync("key-a")).allowed).toBe(false); // key-a blocked
|
||||
});
|
||||
|
||||
test("should create with default options", () => {
|
||||
const limiter = createRateLimiter();
|
||||
expect(limiter.check).toBeDefined();
|
||||
expect(limiter.reset).toBeDefined();
|
||||
expect(limiter.checkAsync).toBeDefined();
|
||||
expect(limiter.resetAsync).toBeDefined();
|
||||
});
|
||||
|
||||
test("should handle rapid sequential calls", () => {
|
||||
test("should handle rapid sequential calls", async () => {
|
||||
const limiter = createRateLimiter({ maxRequests: 100, windowMs: 60_000 });
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(limiter.check("rapid-key").allowed).toBe(true);
|
||||
expect((await limiter.checkAsync("rapid-key")).allowed).toBe(true);
|
||||
}
|
||||
expect(limiter.check("rapid-key").allowed).toBe(false);
|
||||
expect((await limiter.checkAsync("rapid-key")).allowed).toBe(false);
|
||||
});
|
||||
|
||||
test("should allow requests after reset", () => {
|
||||
test("should allow requests after reset", async () => {
|
||||
const limiter = createRateLimiter({ maxRequests: 1, windowMs: 60_000 });
|
||||
limiter.check("after-reset-key");
|
||||
const blocked = limiter.check("after-reset-key");
|
||||
await limiter.checkAsync("after-reset-key");
|
||||
const blocked = await limiter.checkAsync("after-reset-key");
|
||||
expect(blocked.allowed).toBe(false);
|
||||
limiter.reset("after-reset-key");
|
||||
expect(limiter.check("after-reset-key").allowed).toBe(true);
|
||||
await limiter.resetAsync("after-reset-key");
|
||||
expect((await limiter.checkAsync("after-reset-key")).allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
export interface RateLimiterOptions {
|
||||
maxRequests?: number;
|
||||
windowMs?: number;
|
||||
/** Optional async KV store for distributed rate limiting */
|
||||
kv?: {
|
||||
get(key: string): Promise<number[] | null>;
|
||||
set(key: string, value: number[], expirationTtl?: number): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RateLimiter {
|
||||
check(key: string): { allowed: boolean; retryAfterMs?: number };
|
||||
reset(key: string): void;
|
||||
checkAsync(key: string): Promise<{ allowed: boolean; retryAfterMs?: number }>;
|
||||
resetAsync(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_REQUESTS = 100;
|
||||
@@ -25,17 +30,17 @@ const CLEANUP_INTERVAL_DIVISOR = 10;
|
||||
export function createRateLimiter(options?: RateLimiterOptions): RateLimiter {
|
||||
const maxRequests = options?.maxRequests ?? DEFAULT_MAX_REQUESTS;
|
||||
const windowMs = options?.windowMs ?? DEFAULT_WINDOW_MS;
|
||||
const kv = options?.kv;
|
||||
|
||||
// Map of key -> sorted array of timestamps (ascending)
|
||||
// In-memory store fallback
|
||||
const store = new Map<string, number[]>();
|
||||
|
||||
// Maximum number of unique keys to track (prevents unbounded memory growth)
|
||||
const MAX_STORE_KEYS = 10_000;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Remove timestamps outside the sliding window. Returns the pruned slice. */
|
||||
function prune(key: string, now: number): number[] {
|
||||
const timestamps = store.get(key);
|
||||
if (!timestamps) return [];
|
||||
|
||||
function pruneTimestamps(timestamps: number[], now: number): number[] {
|
||||
const cutoff = now - windowMs;
|
||||
const result: number[] = [];
|
||||
for (let i = 0; i < timestamps.length; i++) {
|
||||
@@ -43,17 +48,12 @@ export function createRateLimiter(options?: RateLimiterOptions): RateLimiter {
|
||||
result.push(timestamps[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.length === 0) {
|
||||
store.delete(key);
|
||||
} else {
|
||||
store.set(key, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Periodically sweep the entire store to free memory. */
|
||||
/** Periodically sweep the entire memory store to free memory. */
|
||||
function periodicCleanup(): void {
|
||||
if (kv) return; // Cleanup handled by TTL in KV
|
||||
const now = Date.now();
|
||||
const cutoff = now - windowMs;
|
||||
|
||||
@@ -70,30 +70,67 @@ export function createRateLimiter(options?: RateLimiterOptions): RateLimiter {
|
||||
store.set(key, pruned);
|
||||
}
|
||||
}
|
||||
|
||||
// If store still exceeds max keys after pruning, evict oldest entries
|
||||
if (store.size > MAX_STORE_KEYS) {
|
||||
// Sort by oldest timestamp, evict excess
|
||||
const entries = Array.from(store.entries())
|
||||
.sort((a, b) => {
|
||||
const aOldest = a[1][0] ?? 0;
|
||||
const bOldest = b[1][0] ?? 0;
|
||||
return aOldest - bOldest;
|
||||
});
|
||||
const toEvict = entries.length - MAX_STORE_KEYS;
|
||||
for (let i = 0; i < toEvict; i++) {
|
||||
store.delete(entries[i]![0]);
|
||||
}
|
||||
console.warn(`[rate-limiter] Evicted ${toEvict} keys from store (max ${MAX_STORE_KEYS})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule periodic cleanup (every windowMs / 10)
|
||||
const cleanupHandle = setInterval(
|
||||
periodicCleanup,
|
||||
windowMs / CLEANUP_INTERVAL_DIVISOR,
|
||||
);
|
||||
// Allow the process to exit even if the interval is still active
|
||||
if (
|
||||
cleanupHandle &&
|
||||
typeof cleanupHandle === "object" &&
|
||||
"unref" in cleanupHandle
|
||||
) {
|
||||
(cleanupHandle as NodeJS.Timeout).unref();
|
||||
if (!kv) {
|
||||
// Schedule periodic cleanup (every windowMs / 10)
|
||||
const cleanupHandle = setInterval(
|
||||
periodicCleanup,
|
||||
windowMs / CLEANUP_INTERVAL_DIVISOR,
|
||||
);
|
||||
// Allow the process to exit even if the interval is still active
|
||||
if (
|
||||
cleanupHandle &&
|
||||
typeof cleanupHandle === "object" &&
|
||||
"unref" in cleanupHandle
|
||||
) {
|
||||
(cleanupHandle as NodeJS.Timeout).unref();
|
||||
}
|
||||
}
|
||||
|
||||
// ── public API ───────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
check(key: string): { allowed: boolean; retryAfterMs?: number } {
|
||||
async checkAsync(key: string): Promise<{ allowed: boolean; retryAfterMs?: number }> {
|
||||
const now = Date.now();
|
||||
const timestamps = prune(key, now);
|
||||
let timestamps: number[] = [];
|
||||
|
||||
if (kv) {
|
||||
try {
|
||||
const val = await kv.get(key);
|
||||
if (val) timestamps = val;
|
||||
} catch {
|
||||
// Fallback to empty
|
||||
}
|
||||
} else {
|
||||
timestamps = store.get(key) ?? [];
|
||||
}
|
||||
|
||||
timestamps = pruneTimestamps(timestamps, now);
|
||||
timestamps.push(now);
|
||||
store.set(key, timestamps);
|
||||
|
||||
if (kv) {
|
||||
// We set TTL slightly higher than windowMs so it cleans up automatically
|
||||
await kv.set(key, timestamps, Math.ceil(windowMs / 1000) + 10).catch(() => {});
|
||||
} else {
|
||||
store.set(key, timestamps);
|
||||
}
|
||||
|
||||
if (timestamps.length <= maxRequests) {
|
||||
return { allowed: true };
|
||||
@@ -110,8 +147,12 @@ export function createRateLimiter(options?: RateLimiterOptions): RateLimiter {
|
||||
return { allowed: false, retryAfterMs };
|
||||
},
|
||||
|
||||
reset(key: string): void {
|
||||
store.delete(key);
|
||||
async resetAsync(key: string): Promise<void> {
|
||||
if (kv) {
|
||||
await kv.set(key, [], 1).catch(() => {});
|
||||
} else {
|
||||
store.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user