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:
MythEclipse
2026-06-19 18:10:49 +07:00
parent 6d61dcba8f
commit cb6191902e
13 changed files with 865 additions and 121 deletions
+1 -1
View File
@@ -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);
});
+28 -28
View File
@@ -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);
});
});
});
+73 -32
View File
@@ -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);
}
},
};
}