From eae14978d631aed5e99ff98c3edfdaae2ef9632b Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Wed, 28 Jan 2026 22:51:38 -0800 Subject: [PATCH 1/5] Replace KV-based rate limiting with built-in API Switch from Workers KV to Cloudflare's built-in Rate Limiting API. Benefits: - Free and unlimited (no KV write limits) - In-memory, faster (no network round-trips) - Simpler code (30 lines to 15 lines) - Same functionality (per-minute and per-hour limits) Changes: - wrangler.toml: Replace kv_namespaces with rate_limit binding - index.js: Simplify checkRateLimit() to use env.RATE_LIMITER.limit() - README.md: Remove KV setup instructions Closes #129 --- workers/osa-worker/README.md | 24 +++------------ workers/osa-worker/index.js | 50 ++++++++++++++++++-------------- workers/osa-worker/wrangler.toml | 20 ++++--------- 3 files changed, 39 insertions(+), 55 deletions(-) diff --git a/workers/osa-worker/README.md b/workers/osa-worker/README.md index a847f83..3d2dd25 100644 --- a/workers/osa-worker/README.md +++ b/workers/osa-worker/README.md @@ -2,7 +2,7 @@ Security proxy for the Open Science Assistant backend. Provides: - **Turnstile verification** (visible widget) for bot protection -- **Rate limiting** (IP-based, per-minute and per-hour) +- **Rate limiting** (IP-based, per-minute and per-hour, using Cloudflare's built-in API) - **CORS validation** for allowed origins - **API key injection** for backend authentication - **BYOK mode** for CLI/programmatic access @@ -39,23 +39,7 @@ npm install -g wrangler wrangler login ``` -### 2. Create KV namespaces for rate limiting - -```bash -# Production -wrangler kv:namespace create "RATE_LIMITER" -# Copy the ID and update wrangler.toml - -# Development -wrangler kv:namespace create "RATE_LIMITER" --env dev -# Copy the ID and update wrangler.toml [env.dev.kv_namespaces] -``` - -### 3. Update wrangler.toml - -Replace `REPLACE_WITH_KV_ID` and `REPLACE_WITH_DEV_KV_ID` with the IDs from step 2. - -### 4. Set up Turnstile +### 2. Set up Turnstile 1. Go to Cloudflare Dashboard > Turnstile 2. Create a new widget with **Visible** mode @@ -67,7 +51,7 @@ Replace `REPLACE_WITH_KV_ID` and `REPLACE_WITH_DEV_KV_ID` with the IDs from step 4. Copy the Site Key (for frontend integration) 5. Copy the Secret Key (for this worker) -### 5. Set secrets +### 3. Set secrets ```bash # Backend API key (generate with: python -c "import secrets; print(secrets.token_urlsafe(32))") @@ -81,7 +65,7 @@ wrangler secret put BACKEND_API_KEY --env dev wrangler secret put TURNSTILE_SECRET_KEY --env dev ``` -### 6. Deploy +### 4. Deploy ```bash # Production diff --git a/workers/osa-worker/index.js b/workers/osa-worker/index.js index e067007..637b2df 100644 --- a/workers/osa-worker/index.js +++ b/workers/osa-worker/index.js @@ -66,35 +66,43 @@ async function verifyTurnstileToken(token, secretKey, ip) { } /** - * Check rate limit using KV storage + * Check rate limit using Cloudflare's built-in Rate Limiting API + * Free, in-memory, no KV writes required */ async function checkRateLimit(request, env, CONFIG) { if (!env.RATE_LIMITER) return { allowed: true }; const ip = request.headers.get('CF-Connecting-IP') || 'unknown'; - const now = Math.floor(Date.now() / 1000); - const minuteKey = `rl:min:${ip}:${Math.floor(now / 60)}`; - const hourKey = `rl:hour:${ip}:${Math.floor(now / 3600)}`; - - // Check per-minute limit - const minuteCount = parseInt(await env.RATE_LIMITER.get(minuteKey) || '0'); - if (minuteCount >= CONFIG.RATE_LIMIT_PER_MINUTE) { - return { allowed: false, reason: 'Too many requests per minute' }; - } - // Check per-hour limit - const hourCount = parseInt(await env.RATE_LIMITER.get(hourKey) || '0'); - if (hourCount >= CONFIG.RATE_LIMIT_PER_HOUR) { - return { allowed: false, reason: 'Too many requests per hour' }; - } + try { + // Check per-minute limit + const minuteResult = await env.RATE_LIMITER.limit({ + key: `min:${ip}`, + limit: CONFIG.RATE_LIMIT_PER_MINUTE, + period: 60, // seconds + }); - // Increment counters - await Promise.all([ - env.RATE_LIMITER.put(minuteKey, (minuteCount + 1).toString(), { expirationTtl: 120 }), - env.RATE_LIMITER.put(hourKey, (hourCount + 1).toString(), { expirationTtl: 7200 }), - ]); + if (!minuteResult.success) { + return { allowed: false, reason: 'Too many requests per minute' }; + } - return { allowed: true }; + // Check per-hour limit + const hourResult = await env.RATE_LIMITER.limit({ + key: `hour:${ip}`, + limit: CONFIG.RATE_LIMIT_PER_HOUR, + period: 3600, // seconds + }); + + if (!hourResult.success) { + return { allowed: false, reason: 'Too many requests per hour' }; + } + + return { allowed: true }; + } catch (error) { + console.error('Rate limit check error:', error); + // Fail open - allow request if rate limiting fails + return { allowed: true }; + } } /** diff --git a/workers/osa-worker/wrangler.toml b/workers/osa-worker/wrangler.toml index 32afe2f..fa1dde8 100644 --- a/workers/osa-worker/wrangler.toml +++ b/workers/osa-worker/wrangler.toml @@ -10,19 +10,17 @@ compatibility_date = "2024-01-01" BACKEND_URL = "https://api.osc.earth/osa" ENVIRONMENT = "production" -# KV namespace for rate limiting -[[kv_namespaces]] +# Built-in rate limiting (free, no KV writes) +[[rate_limit]] binding = "RATE_LIMITER" -id = "8f8506b1a7fb400680a00014312c124d" # Development environment [env.dev] name = "osa-worker-dev" vars = { BACKEND_URL = "https://api.osc.earth/osa-dev", ENVIRONMENT = "development" } -[[env.dev.kv_namespaces]] +[[env.dev.rate_limit]] binding = "RATE_LIMITER" -id = "6d46ef72877b4129b38ef2ca1e1cd5ea" # ============================================================================= # Setup Instructions @@ -34,23 +32,17 @@ id = "6d46ef72877b4129b38ef2ca1e1cd5ea" # 2. Login to Cloudflare: # wrangler login # -# 3. Create KV namespaces: -# wrangler kv:namespace create "RATE_LIMITER" -# wrangler kv:namespace create "RATE_LIMITER" --env dev -# -# 4. Update this file with the KV namespace IDs from step 3 -# -# 5. Set secrets: +# 3. Set secrets (rate limiting is automatic, no setup needed): # wrangler secret put BACKEND_API_KEY # wrangler secret put TURNSTILE_SECRET_KEY # wrangler secret put BACKEND_API_KEY --env dev # wrangler secret put TURNSTILE_SECRET_KEY --env dev # -# 6. Deploy: +# 4. Deploy: # wrangler deploy # Production # wrangler deploy --env dev # Development # -# 7. Verify: +# 5. Verify: # curl https://osa-worker..workers.dev/health # # ============================================================================= From 0a696527cc8cf9e325d079511e1a730bfad2636c Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Wed, 28 Jan 2026 23:15:28 -0800 Subject: [PATCH 2/5] Use hybrid rate limiting: built-in API + KV Pivot from full built-in API to hybrid approach based on PR review. Per-minute (bot protection): - Built-in Rate Limiting API - Fast (<1ms), in-memory - Configured in wrangler.toml with namespace_id Per-hour (human abuse prevention): - Workers KV for global consistency - Supports arbitrary time windows (3600s) - 1 write per request instead of 2 Benefits: - 50% reduction in KV writes (1 vs 2 per request) - Faster bot protection (<1ms vs ~10-50ms) - Global hourly limits across edge locations - Works on Pro Plan (unlimited KV writes) Changes: - wrangler.toml: Add [[ratelimits]] + keep [[kv_namespaces]] - index.js: Check built-in API first, then KV for hourly - README.md: Document hybrid approach and rationale Addresses review feedback on PR #130 --- workers/osa-worker/README.md | 29 ++++++++++---- workers/osa-worker/index.js | 65 ++++++++++++++++++-------------- workers/osa-worker/wrangler.toml | 37 ++++++++++++++---- 3 files changed, 87 insertions(+), 44 deletions(-) diff --git a/workers/osa-worker/README.md b/workers/osa-worker/README.md index 3d2dd25..37db048 100644 --- a/workers/osa-worker/README.md +++ b/workers/osa-worker/README.md @@ -2,7 +2,9 @@ Security proxy for the Open Science Assistant backend. Provides: - **Turnstile verification** (visible widget) for bot protection -- **Rate limiting** (IP-based, per-minute and per-hour, using Cloudflare's built-in API) +- **Hybrid rate limiting** (IP-based, per-minute and per-hour) + - Per-minute: Built-in API (fast bot protection, <1ms) + - Per-hour: KV (global human abuse prevention) - **CORS validation** for allowed origins - **API key injection** for backend authentication - **BYOK mode** for CLI/programmatic access @@ -39,7 +41,11 @@ npm install -g wrangler wrangler login ``` -### 2. Set up Turnstile +### 2. KV namespaces + +KV namespaces are already configured in `wrangler.toml` for per-hour rate limiting. No additional setup needed. + +### 3. Set up Turnstile 1. Go to Cloudflare Dashboard > Turnstile 2. Create a new widget with **Visible** mode @@ -51,7 +57,7 @@ wrangler login 4. Copy the Site Key (for frontend integration) 5. Copy the Secret Key (for this worker) -### 3. Set secrets +### 4. Set secrets ```bash # Backend API key (generate with: python -c "import secrets; print(secrets.token_urlsafe(32))") @@ -65,7 +71,7 @@ wrangler secret put BACKEND_API_KEY --env dev wrangler secret put TURNSTILE_SECRET_KEY --env dev ``` -### 4. Deploy +### 5. Deploy ```bash # Production @@ -88,10 +94,17 @@ wrangler deploy --env dev ## Rate Limits -| Environment | Per Minute | Per Hour | -|-------------|------------|----------| -| Production | 10 | 100 | -| Development | 60 | 600 | +Hybrid approach for optimal performance and protection: + +| Environment | Per Minute (Bot Protection) | Per Hour (Human Abuse) | +|-------------|----------------------------|----------------------| +| Production | 10 (built-in API) | 100 (KV, global) | +| Development | 60 (built-in API) | 600 (KV, global) | + +**Why hybrid?** +- **Per-minute**: Needs to be fast (<1ms), catches bots immediately → Built-in API +- **Per-hour**: Needs global consistency across edge locations → KV +- **Result**: 50% fewer KV writes (1 vs 2 per request), faster bot protection ## BYOK Mode diff --git a/workers/osa-worker/index.js b/workers/osa-worker/index.js index 637b2df..dbb9408 100644 --- a/workers/osa-worker/index.js +++ b/workers/osa-worker/index.js @@ -66,43 +66,52 @@ async function verifyTurnstileToken(token, secretKey, ip) { } /** - * Check rate limit using Cloudflare's built-in Rate Limiting API - * Free, in-memory, no KV writes required + * Hybrid rate limiting approach: + * - Per-minute (bot protection): Built-in API (fast, <1ms, in-memory) + * - Per-hour (human abuse): KV (global consistency, 1 write per request) + * + * Benefits: + * - 50% reduction in KV writes (1 vs 2 per request) + * - Faster bot protection (<1ms vs ~10-50ms for critical first check) + * - Global hourly limits across all edge locations */ async function checkRateLimit(request, env, CONFIG) { - if (!env.RATE_LIMITER) return { allowed: true }; - const ip = request.headers.get('CF-Connecting-IP') || 'unknown'; - try { - // Check per-minute limit - const minuteResult = await env.RATE_LIMITER.limit({ - key: `min:${ip}`, - limit: CONFIG.RATE_LIMIT_PER_MINUTE, - period: 60, // seconds - }); - - if (!minuteResult.success) { - return { allowed: false, reason: 'Too many requests per minute' }; + // Per-minute bot protection (built-in API, fast) + if (env.RATE_LIMITER_MINUTE) { + try { + const { success } = await env.RATE_LIMITER_MINUTE.limit({ key: ip }); + if (!success) { + return { allowed: false, reason: 'Too many requests per minute' }; + } + } catch (error) { + console.error('Per-minute rate limit check error:', error); + // Fail open for built-in API errors } + } - // Check per-hour limit - const hourResult = await env.RATE_LIMITER.limit({ - key: `hour:${ip}`, - limit: CONFIG.RATE_LIMIT_PER_HOUR, - period: 3600, // seconds - }); + // Per-hour human abuse protection (KV, global) + if (env.RATE_LIMITER_KV) { + try { + const now = Math.floor(Date.now() / 1000); + const hourKey = `rl:hour:${ip}:${Math.floor(now / 3600)}`; - if (!hourResult.success) { - return { allowed: false, reason: 'Too many requests per hour' }; - } + // Check current count + const hourCount = parseInt(await env.RATE_LIMITER_KV.get(hourKey) || '0'); + if (hourCount >= CONFIG.RATE_LIMIT_PER_HOUR) { + return { allowed: false, reason: 'Too many requests per hour' }; + } - return { allowed: true }; - } catch (error) { - console.error('Rate limit check error:', error); - // Fail open - allow request if rate limiting fails - return { allowed: true }; + // Increment counter (1 write per request instead of 2) + await env.RATE_LIMITER_KV.put(hourKey, (hourCount + 1).toString(), { expirationTtl: 7200 }); + } catch (error) { + console.error('Per-hour rate limit check error:', error); + // Fail open for KV errors + } } + + return { allowed: true }; } /** diff --git a/workers/osa-worker/wrangler.toml b/workers/osa-worker/wrangler.toml index fa1dde8..5cdcfa1 100644 --- a/workers/osa-worker/wrangler.toml +++ b/workers/osa-worker/wrangler.toml @@ -10,17 +10,36 @@ compatibility_date = "2024-01-01" BACKEND_URL = "https://api.osc.earth/osa" ENVIRONMENT = "production" -# Built-in rate limiting (free, no KV writes) -[[rate_limit]] -binding = "RATE_LIMITER" +# Hybrid rate limiting approach: +# - Per-minute (bot protection): Built-in API (fast, in-memory) +# - Per-hour (human abuse): KV (global consistency) + +# Per-minute rate limiter (built-in API, free, <1ms latency) +[[ratelimits]] +name = "RATE_LIMITER_MINUTE" +namespace_id = "1001" +simple = { limit = 10, period = 60 } + +# Per-hour rate limiter (KV, global, 1 write per request) +[[kv_namespaces]] +binding = "RATE_LIMITER_KV" +id = "8f8506b1a7fb400680a00014312c124d" # Development environment [env.dev] name = "osa-worker-dev" vars = { BACKEND_URL = "https://api.osc.earth/osa-dev", ENVIRONMENT = "development" } -[[env.dev.rate_limit]] -binding = "RATE_LIMITER" +# Per-minute rate limiter (dev has higher limit: 60/min) +[[env.dev.ratelimits]] +name = "RATE_LIMITER_MINUTE" +namespace_id = "1002" +simple = { limit = 60, period = 60 } + +# Per-hour rate limiter (KV) +[[env.dev.kv_namespaces]] +binding = "RATE_LIMITER_KV" +id = "6d46ef72877b4129b38ef2ca1e1cd5ea" # ============================================================================= # Setup Instructions @@ -32,17 +51,19 @@ binding = "RATE_LIMITER" # 2. Login to Cloudflare: # wrangler login # -# 3. Set secrets (rate limiting is automatic, no setup needed): +# 3. KV namespaces already exist (IDs in this file) +# +# 4. Set secrets: # wrangler secret put BACKEND_API_KEY # wrangler secret put TURNSTILE_SECRET_KEY # wrangler secret put BACKEND_API_KEY --env dev # wrangler secret put TURNSTILE_SECRET_KEY --env dev # -# 4. Deploy: +# 5. Deploy: # wrangler deploy # Production # wrangler deploy --env dev # Development # -# 5. Verify: +# 6. Verify: # curl https://osa-worker..workers.dev/health # # ============================================================================= From 06aba873c544576eef32bddc5586a218702580e1 Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Wed, 28 Jan 2026 23:17:59 -0800 Subject: [PATCH 3/5] Lower hourly rate limit from 100 to 20 Per IP address limits: - Production: 20/hour (~1 question every 3 minutes) - Development: 100/hour (for testing) Rationale: - Prevents sustained abuse - Allows reasonable research sessions - 20/hour is sufficient for legitimate use --- workers/osa-worker/README.md | 9 +++++++-- workers/osa-worker/index.js | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/workers/osa-worker/README.md b/workers/osa-worker/README.md index 37db048..9b6b7f7 100644 --- a/workers/osa-worker/README.md +++ b/workers/osa-worker/README.md @@ -98,14 +98,19 @@ Hybrid approach for optimal performance and protection: | Environment | Per Minute (Bot Protection) | Per Hour (Human Abuse) | |-------------|----------------------------|----------------------| -| Production | 10 (built-in API) | 100 (KV, global) | -| Development | 60 (built-in API) | 600 (KV, global) | +| Production | 10 (built-in API) | 20 (KV, global) | +| Development | 60 (built-in API) | 100 (KV, global) | **Why hybrid?** - **Per-minute**: Needs to be fast (<1ms), catches bots immediately → Built-in API - **Per-hour**: Needs global consistency across edge locations → KV - **Result**: 50% fewer KV writes (1 vs 2 per request), faster bot protection +**Rate limit scope:** +- Limits are **per IP address**, not per session +- 20/hour in production = ~1 question every 3 minutes (reasonable for research) +- Prevents abuse while allowing legitimate use + ## BYOK Mode CLI and programmatic clients can bypass Turnstile by providing their own OpenRouter API key: diff --git a/workers/osa-worker/index.js b/workers/osa-worker/index.js index dbb9408..47bf254 100644 --- a/workers/osa-worker/index.js +++ b/workers/osa-worker/index.js @@ -14,7 +14,7 @@ function getConfig(env) { const isDev = env.ENVIRONMENT === 'development'; return { RATE_LIMIT_PER_MINUTE: isDev ? 60 : 10, - RATE_LIMIT_PER_HOUR: isDev ? 600 : 100, + RATE_LIMIT_PER_HOUR: isDev ? 100 : 20, REQUEST_TIMEOUT: 120000, // 2 minutes for LLM responses IS_DEV: isDev, }; From 2561d76d2cf21990116d3edb0c6a1a063c45fc1c Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Wed, 28 Jan 2026 23:24:40 -0800 Subject: [PATCH 4/5] Auto-deploy worker on any worker file changes Update CI/CD workflow to trigger on worker file changes, not just CORS. Changes: - Add workers/osa-worker/** to trigger paths - Check for worker file changes in push event - Deploy if CORS sync changed files OR worker files were pushed - Only commit CORS changes (not worker changes, already committed) - Update PR comment to reflect auto-deployment Now merging to develop will automatically deploy worker changes. --- .github/workflows/sync-worker-cors.yml | 53 +++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/.github/workflows/sync-worker-cors.yml b/.github/workflows/sync-worker-cors.yml index 3d364aa..c764c13 100644 --- a/.github/workflows/sync-worker-cors.yml +++ b/.github/workflows/sync-worker-cors.yml @@ -7,10 +7,12 @@ on: - 'src/assistants/*/config.yaml' - 'scripts/sync_worker_cors.py' - '.github/workflows/sync-worker-cors.yml' + - 'workers/osa-worker/**' pull_request: branches: [main, develop] paths: - 'src/assistants/*/config.yaml' + - 'workers/osa-worker/**' permissions: contents: write @@ -40,16 +42,50 @@ jobs: - name: Check for changes id: check_changes run: | + # Check if CORS sync made changes if git diff --quiet workers/osa-worker/index.js; then - echo "changed=false" >> $GITHUB_OUTPUT echo "No CORS changes detected" + CORS_CHANGED=false else - echo "changed=true" >> $GITHUB_OUTPUT echo "CORS changes detected" + CORS_CHANGED=true + fi + + # Check if worker files were modified in the push that triggered this workflow + # Use the range from the push event (before -> after) + if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then + # Normal push (not first commit) + if git diff --name-only ${{ github.event.before }} ${{ github.sha }} | grep -q "^workers/osa-worker/"; then + echo "Worker files changed in push" + WORKER_CHANGED=true + else + echo "No worker files in push" + WORKER_CHANGED=false + fi + else + # First commit to branch, check current files + if git ls-files workers/osa-worker/ | grep -q .; then + echo "Worker files exist (first commit)" + WORKER_CHANGED=true + else + WORKER_CHANGED=false + fi + fi + + # Set outputs + echo "cors_changed=$CORS_CHANGED" >> $GITHUB_OUTPUT + + # Deploy if either CORS sync changed files OR worker files were pushed + if [ "$CORS_CHANGED" = true ] || [ "$WORKER_CHANGED" = true ]; then + echo "changed=true" >> $GITHUB_OUTPUT + echo "✅ Deployment needed" + else + echo "changed=false" >> $GITHUB_OUTPUT + echo "No deployment needed" fi - - name: Commit changes (main/develop only) - if: steps.check_changes.outputs.changed == 'true' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') && github.event_name == 'push' + - name: Commit CORS changes (main/develop only) + if: steps.check_changes.outputs.cors_changed == 'true' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') && github.event_name == 'push' run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -82,9 +118,16 @@ jobs: uses: actions/github-script@v7 with: script: | + let message = '⚠️ **Worker Deployment Required**\n\n'; + if ('${{ steps.check_changes.outputs.cors_changed }}' === 'true') { + message += 'This PR modifies community CORS origins. '; + } + message += 'Worker changes detected. After merging to `main` or `develop`, the workflow will automatically deploy the worker.\n\n'; + message += '**Manual deployment (if needed):**\n```bash\ncd workers/osa-worker\nwrangler deploy --env dev # for develop branch\nwrangler deploy # for main branch\n```'; + github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: '⚠️ **Worker CORS Update Required**\n\nThis PR modifies community CORS origins. After merging, run:\n\n```bash\npython scripts/sync_worker_cors.py\ngit add workers/osa-worker/index.js\ngit commit -m "chore: sync worker CORS"\ncd workers/osa-worker && wrangler deploy\n```\n\nOr merge to main and the sync workflow will auto-commit the changes.' + body: message }) From ae274f8063b0bb4b051cd6a31c558ad5286d6b7e Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Wed, 28 Jan 2026 23:28:16 -0800 Subject: [PATCH 5/5] Fix rate limiting check order and improve robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review findings: Critical fixes: - Reorder checks: hourly (KV read) → per-minute (token) → hourly (KV write) Prevents wasting per-minute tokens on hourly-rejected requests - Add radix parameter to parseInt() calls for safety Important fixes: - Use 'wrangler deploy' instead of 'wrangler deploy --env=""' for prod - Document KV race condition as known limitation This ensures per-minute tokens are only consumed by requests that pass the hourly check, preventing token waste and clearer error messages. --- .github/workflows/sync-worker-cors.yml | 2 +- workers/osa-worker/index.js | 41 ++++++++++++++++++-------- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/.github/workflows/sync-worker-cors.yml b/.github/workflows/sync-worker-cors.yml index c764c13..d9758f0 100644 --- a/.github/workflows/sync-worker-cors.yml +++ b/.github/workflows/sync-worker-cors.yml @@ -100,7 +100,7 @@ jobs: run: | npm install -g wrangler cd workers/osa-worker - wrangler deploy --env="" + wrangler deploy echo "✅ Deployed to production worker" - name: Deploy to Cloudflare Workers (dev) diff --git a/workers/osa-worker/index.js b/workers/osa-worker/index.js index 47bf254..dbe3119 100644 --- a/workers/osa-worker/index.js +++ b/workers/osa-worker/index.js @@ -74,11 +74,34 @@ async function verifyTurnstileToken(token, secretKey, ip) { * - 50% reduction in KV writes (1 vs 2 per request) * - Faster bot protection (<1ms vs ~10-50ms for critical first check) * - Global hourly limits across all edge locations + * + * Known limitation: + * - KV read-then-write is not atomic; concurrent requests from same IP + * may slightly exceed hourly limit. Per-minute guard constrains this. */ async function checkRateLimit(request, env, CONFIG) { const ip = request.headers.get('CF-Connecting-IP') || 'unknown'; - // Per-minute bot protection (built-in API, fast) + // Check hourly limit first (KV, read-only, no token consumed) + // This prevents wasting per-minute tokens on already-rejected requests + if (env.RATE_LIMITER_KV) { + try { + const now = Math.floor(Date.now() / 1000); + const hourKey = `rl:hour:${ip}:${Math.floor(now / 3600)}`; + + // Check current count + const hourCount = parseInt(await env.RATE_LIMITER_KV.get(hourKey) || '0', 10); + if (hourCount >= CONFIG.RATE_LIMIT_PER_HOUR) { + return { allowed: false, reason: 'Too many requests per hour' }; + } + } catch (error) { + console.error('Per-hour rate limit check error:', error); + // Fail open for KV errors + } + } + + // Check per-minute limit (built-in API, fast, consumes token) + // Only check this AFTER hourly passes to avoid wasting tokens if (env.RATE_LIMITER_MINUTE) { try { const { success } = await env.RATE_LIMITER_MINUTE.limit({ key: ip }); @@ -91,23 +114,17 @@ async function checkRateLimit(request, env, CONFIG) { } } - // Per-hour human abuse protection (KV, global) + // Increment hourly counter (1 write per request instead of 2) + // Done last, after both checks pass if (env.RATE_LIMITER_KV) { try { const now = Math.floor(Date.now() / 1000); const hourKey = `rl:hour:${ip}:${Math.floor(now / 3600)}`; - - // Check current count - const hourCount = parseInt(await env.RATE_LIMITER_KV.get(hourKey) || '0'); - if (hourCount >= CONFIG.RATE_LIMIT_PER_HOUR) { - return { allowed: false, reason: 'Too many requests per hour' }; - } - - // Increment counter (1 write per request instead of 2) + const hourCount = parseInt(await env.RATE_LIMITER_KV.get(hourKey) || '0', 10); await env.RATE_LIMITER_KV.put(hourKey, (hourCount + 1).toString(), { expirationTtl: 7200 }); } catch (error) { - console.error('Per-hour rate limit check error:', error); - // Fail open for KV errors + console.error('Per-hour rate limit increment error:', error); + // Already allowed, so don't fail the request } }