From c0b1b6528e0e9721363a41942b9d86c719f46721 Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Wed, 17 Dec 2025 21:38:21 -0500 Subject: [PATCH 1/3] feat: rate limiter added --- create-db/__tests__/rate-limiter.test.ts | 37 +++++++++++ create-db/src/index.ts | 19 ++++++ create-db/src/rate-limiter.ts | 84 ++++++++++++++++++++++++ create-db/src/types.ts | 5 ++ 4 files changed, 145 insertions(+) create mode 100644 create-db/__tests__/rate-limiter.test.ts create mode 100644 create-db/src/rate-limiter.ts diff --git a/create-db/__tests__/rate-limiter.test.ts b/create-db/__tests__/rate-limiter.test.ts new file mode 100644 index 0000000..6c876de --- /dev/null +++ b/create-db/__tests__/rate-limiter.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { globalRateLimiter } from "../src/rate-limiter.js"; + +describe("RateLimiter", () => { + it("should have default configuration of 5 requests per minute", () => { + const config = globalRateLimiter.getConfig(); + + expect(config.maxRequests).toBe(5); + expect(config.windowMs).toBe(60000); // 1 minute + }); + + it("should allow requests within the limit", () => { + // Note: This test may fail if other tests have used the global rate limiter + // In real usage, the rate limiter is global and shared across all calls + const config = globalRateLimiter.getConfig(); + const currentCount = globalRateLimiter.getCurrentCount(); + + // Only test if we have room in the limit + if (currentCount < config.maxRequests) { + expect(globalRateLimiter.checkLimit()).toBe(true); + } + }); + + it("should track current count", () => { + const countBefore = globalRateLimiter.getCurrentCount(); + expect(countBefore).toBeGreaterThanOrEqual(0); + expect(countBefore).toBeLessThanOrEqual(5); + }); + + it("should return time until reset", () => { + const timeUntilReset = globalRateLimiter.getTimeUntilReset(); + + // Time until reset should be between 0 and the window size + expect(timeUntilReset).toBeGreaterThanOrEqual(0); + expect(timeUntilReset).toBeLessThanOrEqual(60000); + }); +}); diff --git a/create-db/src/index.ts b/create-db/src/index.ts index d9f3aee..bb0eb32 100644 --- a/create-db/src/index.ts +++ b/create-db/src/index.ts @@ -32,6 +32,7 @@ import { getRegionClosestToLocation, } from "./geolocation.js"; import { checkOnline, getRegions, validateRegion } from "./regions.js"; +import { globalRateLimiter } from "./rate-limiter.js"; export type { Region, @@ -390,6 +391,24 @@ const caller = createRouterClient(router, { context: {} }); export async function create( options?: ProgrammaticCreateOptions ): Promise { + // Check rate limit + if (!globalRateLimiter.checkLimit()) { + const retryAfterMs = globalRateLimiter.getTimeUntilReset(); + const config = globalRateLimiter.getConfig(); + const retryAfterSeconds = Math.ceil(retryAfterMs / 1000); + + return { + success: false, + error: "RATE_LIMIT_EXCEEDED", + message: `Rate limit exceeded. You can create up to ${config.maxRequests} databases per ${Math.ceil(config.windowMs / 1000)} seconds. Please try again in ${retryAfterSeconds} seconds.`, + rateLimitInfo: { + retryAfterMs, + currentCount: globalRateLimiter.getCurrentCount(), + maxRequests: config.maxRequests, + }, + }; + } + return createDatabaseCoreWithUrl( options?.region || "us-east-1", options?.userAgent diff --git a/create-db/src/rate-limiter.ts b/create-db/src/rate-limiter.ts new file mode 100644 index 0000000..dd270fc --- /dev/null +++ b/create-db/src/rate-limiter.ts @@ -0,0 +1,84 @@ +/** + * Simple in-memory rate limiter for database creation + * Security feature - always enabled with fixed limits + */ + +interface RequestRecord { + timestamp: number; +} + +// Fixed configuration for security +const MAX_REQUESTS = 5; +const WINDOW_MS = 60000; // 1 minute + +class RateLimiter { + private requests: RequestRecord[] = []; + + /** + * Check if a new request is allowed under the rate limit + * @returns true if allowed, false if rate limit exceeded + */ + checkLimit(): boolean { + const now = Date.now(); + + // Remove expired requests outside the time window + this.requests = this.requests.filter( + (record) => now - record.timestamp < WINDOW_MS + ); + + // Check if we've exceeded the limit + if (this.requests.length >= MAX_REQUESTS) { + return false; + } + + // Add this request to the tracking + this.requests.push({ timestamp: now }); + return true; + } + + /** + * Get the time in milliseconds until the rate limit resets + */ + getTimeUntilReset(): number { + if (this.requests.length === 0) { + return 0; + } + + const oldestRequest = this.requests[0]; + if (!oldestRequest) { + return 0; + } + + const timeSinceOldest = Date.now() - oldestRequest.timestamp; + const timeUntilReset = WINDOW_MS - timeSinceOldest; + + return Math.max(0, timeUntilReset); + } + + /** + * Get the current number of requests in the window + */ + getCurrentCount(): number { + const now = Date.now(); + this.requests = this.requests.filter( + (record) => now - record.timestamp < WINDOW_MS + ); + return this.requests.length; + } + + /** + * Get the current configuration (read-only) + * @internal + */ + getConfig(): { maxRequests: number; windowMs: number } { + return { + maxRequests: MAX_REQUESTS, + windowMs: WINDOW_MS, + }; + } +} + +// Global rate limiter instance +const globalRateLimiter = new RateLimiter(); + +export { globalRateLimiter }; diff --git a/create-db/src/types.ts b/create-db/src/types.ts index 3654c0f..b8a6a08 100644 --- a/create-db/src/types.ts +++ b/create-db/src/types.ts @@ -54,6 +54,11 @@ export interface DatabaseError { raw?: string; details?: unknown; status?: number; + rateLimitInfo?: { + retryAfterMs: number; + currentCount: number; + maxRequests: number; + }; } export type CreateDatabaseResult = DatabaseResult | DatabaseError; From bbe7180b205c3191c3e1ffe175eaf9ca7f1fed16 Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Thu, 18 Dec 2025 10:10:42 -0500 Subject: [PATCH 2/3] fix: rate limiter moved to create-db-worker --- create-db-worker/src/index.ts | 48 +++++++++++++- create-db-worker/wrangler.jsonc | 9 +++ create-db/__tests__/rate-limiter.test.ts | 37 ----------- create-db/src/database.ts | 21 +++++- create-db/src/index.ts | 29 ++------ create-db/src/rate-limiter.ts | 84 ------------------------ 6 files changed, 83 insertions(+), 145 deletions(-) delete mode 100644 create-db/__tests__/rate-limiter.test.ts delete mode 100644 create-db/src/rate-limiter.ts diff --git a/create-db-worker/src/index.ts b/create-db-worker/src/index.ts index f202f7c..72c5d8a 100644 --- a/create-db-worker/src/index.ts +++ b/create-db-worker/src/index.ts @@ -6,6 +6,7 @@ interface Env { DELETE_DB_WORKFLOW: Workflow; DELETE_STALE_WORKFLOW: Workflow; CREATE_DB_RATE_LIMITER: RateLimit; + PROGRAMMATIC_RATE_LIMITER: RateLimit; CREATE_DB_DATASET: AnalyticsEngineDataset; POSTHOG_API_KEY?: string; POSTHOG_API_HOST?: string; @@ -130,6 +131,7 @@ export default { name?: string; analytics?: { eventName?: string; properties?: Record }; userAgent?: string; + source?: 'programmatic' | 'cli'; }; let body: CreateDbBody = {}; @@ -140,7 +142,51 @@ export default { return new Response('Invalid JSON body', { status: 400 }); } - const { region, name, analytics: analyticsData, userAgent } = body; + const { region, name, analytics: analyticsData, userAgent, source } = body; + + // Apply stricter rate limiting for programmatic requests + if (source === 'programmatic') { + const programmaticKey = `programmatic:${clientIP}`; + try { + const res = await env.PROGRAMMATIC_RATE_LIMITER.limit({ + key: programmaticKey, + }); + + if (!res.success) { + return new Response( + JSON.stringify({ + error: 'RATE_LIMIT_EXCEEDED', + message: 'Rate limit exceeded for programmatic database creation. You can create up to 1 database per minute. Please try again later.', + rateLimitInfo: { + retryAfterMs: 60000, // Approximate - Cloudflare doesn't expose exact timing + currentCount: 1, + maxRequests: 1, + }, + }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': '60', + }, + }, + ); + } + } catch (e) { + console.error('Programmatic rate limiter error:', e); + // Fail closed for programmatic requests + return new Response( + JSON.stringify({ + error: 'rate_limiter_error', + message: 'Rate limiter temporarily unavailable. Please try again later.', + }), + { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + } if (!region || !name) { return new Response('Missing region or name in request body', { status: 400 }); } diff --git a/create-db-worker/wrangler.jsonc b/create-db-worker/wrangler.jsonc index 07913a8..1126fbf 100644 --- a/create-db-worker/wrangler.jsonc +++ b/create-db-worker/wrangler.jsonc @@ -40,6 +40,15 @@ "period": 60, }, }, + { + "name": "PROGRAMMATIC_RATE_LIMITER", + "type": "ratelimit", + "namespace_id": "1006", + "simple": { + "limit": 1, + "period": 60, + }, + }, ], }, } diff --git a/create-db/__tests__/rate-limiter.test.ts b/create-db/__tests__/rate-limiter.test.ts deleted file mode 100644 index 6c876de..0000000 --- a/create-db/__tests__/rate-limiter.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { globalRateLimiter } from "../src/rate-limiter.js"; - -describe("RateLimiter", () => { - it("should have default configuration of 5 requests per minute", () => { - const config = globalRateLimiter.getConfig(); - - expect(config.maxRequests).toBe(5); - expect(config.windowMs).toBe(60000); // 1 minute - }); - - it("should allow requests within the limit", () => { - // Note: This test may fail if other tests have used the global rate limiter - // In real usage, the rate limiter is global and shared across all calls - const config = globalRateLimiter.getConfig(); - const currentCount = globalRateLimiter.getCurrentCount(); - - // Only test if we have room in the limit - if (currentCount < config.maxRequests) { - expect(globalRateLimiter.checkLimit()).toBe(true); - } - }); - - it("should track current count", () => { - const countBefore = globalRateLimiter.getCurrentCount(); - expect(countBefore).toBeGreaterThanOrEqual(0); - expect(countBefore).toBeLessThanOrEqual(5); - }); - - it("should return time until reset", () => { - const timeUntilReset = globalRateLimiter.getTimeUntilReset(); - - // Time until reset should be between 0 and the window size - expect(timeUntilReset).toBeGreaterThanOrEqual(0); - expect(timeUntilReset).toBeLessThanOrEqual(60000); - }); -}); diff --git a/create-db/src/database.ts b/create-db/src/database.ts index 8c21252..904b1ee 100644 --- a/create-db/src/database.ts +++ b/create-db/src/database.ts @@ -14,7 +14,8 @@ export async function createDatabaseCore( createDbWorkerUrl: string, claimDbWorkerUrl: string, userAgent?: string, - cliRunId?: string + cliRunId?: string, + source?: "programmatic" | "cli" ): Promise { const name = new Date().toISOString(); const runId = cliRunId ?? randomUUID(); @@ -27,6 +28,7 @@ export async function createDatabaseCore( name, utm_source: getCommandName(), userAgent, + source: source || "cli", }), }); @@ -37,6 +39,23 @@ export async function createDatabaseCore( runId, createDbWorkerUrl ); + + // Try to parse the rate limit response from the server + try { + const errorData = await resp.json(); + if (errorData.error === "RATE_LIMIT_EXCEEDED" && errorData.rateLimitInfo) { + return { + success: false, + error: "RATE_LIMIT_EXCEEDED", + message: errorData.message, + rateLimitInfo: errorData.rateLimitInfo, + status: 429, + }; + } + } catch { + // If parsing fails, fall through to generic message + } + return { success: false, error: "rate_limit_exceeded", diff --git a/create-db/src/index.ts b/create-db/src/index.ts index bb0eb32..baed48d 100644 --- a/create-db/src/index.ts +++ b/create-db/src/index.ts @@ -32,7 +32,6 @@ import { getRegionClosestToLocation, } from "./geolocation.js"; import { checkOnline, getRegions, validateRegion } from "./regions.js"; -import { globalRateLimiter } from "./rate-limiter.js"; export type { Region, @@ -78,14 +77,16 @@ const validateRegionWithUrl = (region: string) => const createDatabaseCoreWithUrl = ( region: string, userAgent?: string, - cliRunId?: string + cliRunId?: string, + source?: "programmatic" | "cli" ) => createDatabaseCore( region, CREATE_DB_WORKER_URL, CLAIM_DB_WORKER_URL, userAgent, - cliRunId + cliRunId, + source ); const router = os.router({ @@ -391,27 +392,11 @@ const caller = createRouterClient(router, { context: {} }); export async function create( options?: ProgrammaticCreateOptions ): Promise { - // Check rate limit - if (!globalRateLimiter.checkLimit()) { - const retryAfterMs = globalRateLimiter.getTimeUntilReset(); - const config = globalRateLimiter.getConfig(); - const retryAfterSeconds = Math.ceil(retryAfterMs / 1000); - - return { - success: false, - error: "RATE_LIMIT_EXCEEDED", - message: `Rate limit exceeded. You can create up to ${config.maxRequests} databases per ${Math.ceil(config.windowMs / 1000)} seconds. Please try again in ${retryAfterSeconds} seconds.`, - rateLimitInfo: { - retryAfterMs, - currentCount: globalRateLimiter.getCurrentCount(), - maxRequests: config.maxRequests, - }, - }; - } - return createDatabaseCoreWithUrl( options?.region || "us-east-1", - options?.userAgent + options?.userAgent, + undefined, + "programmatic" ); } diff --git a/create-db/src/rate-limiter.ts b/create-db/src/rate-limiter.ts deleted file mode 100644 index dd270fc..0000000 --- a/create-db/src/rate-limiter.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Simple in-memory rate limiter for database creation - * Security feature - always enabled with fixed limits - */ - -interface RequestRecord { - timestamp: number; -} - -// Fixed configuration for security -const MAX_REQUESTS = 5; -const WINDOW_MS = 60000; // 1 minute - -class RateLimiter { - private requests: RequestRecord[] = []; - - /** - * Check if a new request is allowed under the rate limit - * @returns true if allowed, false if rate limit exceeded - */ - checkLimit(): boolean { - const now = Date.now(); - - // Remove expired requests outside the time window - this.requests = this.requests.filter( - (record) => now - record.timestamp < WINDOW_MS - ); - - // Check if we've exceeded the limit - if (this.requests.length >= MAX_REQUESTS) { - return false; - } - - // Add this request to the tracking - this.requests.push({ timestamp: now }); - return true; - } - - /** - * Get the time in milliseconds until the rate limit resets - */ - getTimeUntilReset(): number { - if (this.requests.length === 0) { - return 0; - } - - const oldestRequest = this.requests[0]; - if (!oldestRequest) { - return 0; - } - - const timeSinceOldest = Date.now() - oldestRequest.timestamp; - const timeUntilReset = WINDOW_MS - timeSinceOldest; - - return Math.max(0, timeUntilReset); - } - - /** - * Get the current number of requests in the window - */ - getCurrentCount(): number { - const now = Date.now(); - this.requests = this.requests.filter( - (record) => now - record.timestamp < WINDOW_MS - ); - return this.requests.length; - } - - /** - * Get the current configuration (read-only) - * @internal - */ - getConfig(): { maxRequests: number; windowMs: number } { - return { - maxRequests: MAX_REQUESTS, - windowMs: WINDOW_MS, - }; - } -} - -// Global rate limiter instance -const globalRateLimiter = new RateLimiter(); - -export { globalRateLimiter }; From 46b9bf2b8ac1a970c1815ad5befa8dd867bf36fe Mon Sep 17 00:00:00 2001 From: Aidan McAlister Date: Thu, 18 Dec 2025 10:11:26 -0500 Subject: [PATCH 3/3] fix: rate limit changed to 5 per minute --- create-db-worker/wrangler.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/create-db-worker/wrangler.jsonc b/create-db-worker/wrangler.jsonc index 1126fbf..dd03219 100644 --- a/create-db-worker/wrangler.jsonc +++ b/create-db-worker/wrangler.jsonc @@ -45,7 +45,7 @@ "type": "ratelimit", "namespace_id": "1006", "simple": { - "limit": 1, + "limit": 5, "period": 60, }, },