Skip to content

fix(security): harden OAuth, auth, and API route security#30

Merged
jdguggs10 merged 5 commits intomainfrom
fix/security-hardening-v2
Mar 11, 2026
Merged

fix(security): harden OAuth, auth, and API route security#30
jdguggs10 merged 5 commits intomainfrom
fix/security-hardening-v2

Conversation

@jdguggs10
Copy link
Owner

Summary

  • Fix OAuth authorization code race condition with atomic UPDATE...WHERE used_at IS NULL
  • Add PKCE code_verifier length/charset validation (RFC 7636) and constant-time comparison
  • Replace Supabase-based rate limiting with CF Workers native rate_limits (zero-latency, no DB round-trips)
  • Tighten SSRF allowlist, JWT issuer handling, and error message sanitization
  • Add platform/sport/seasonYear enum validation on default league route
  • Deduplicate VALID_SPORTS constants, fix sleeper test assertion

Test plan

  • auth-worker type-check and all 154 tests pass
  • sleeper-client 49 tests pass
  • espn-client 61 tests pass
  • yahoo-client 50 tests pass
  • fantasy-mcp 8 tests pass

Replaces #29 (stale review bot state).

🤖 Generated with Claude Code

claude and others added 4 commits March 11, 2026 11:42
- Fix OAuth authorization code race condition with atomic UPDATE...WHERE
  used_at IS NULL to prevent double-exchange attacks
- Add PKCE code_verifier length validation (43-128 chars per RFC 7636)
  and constant-time comparison for challenge verification
- Add per-IP rate limiting on /token endpoint to prevent brute-force
- Tighten non-prod JWT issuer allowlist (prefer explicit CLERK_ISSUER)
- Validate Yahoo OAuth redirect Location header against *.yahoo.com
- Add encodeURIComponent() on dynamic route params to prevent injection
- Fail early with 401 when bearer token unavailable (defense-in-depth)
- Tighten SSRF allowlist to Flaim-specific worker names only
- Sanitize error messages in platform clients (log details server-side)
- Remove JWT payload claim logging from auto-pull route
- Add platform/sport/seasonYear enum validation on default league route

https://claude.ai/code/session_01YGWmBeoSA3FT4xrQvYitx3
Removes DB round-trips on /token and ?raw=true requests by using
Cloudflare Workers native rate_limits bindings. Fixes TOCTOU race,
X-Forwarded-For spoofability, and grant_type scoping issues by
deleting the Supabase-based rate limiting code entirely.

- TOKEN_RATE_LIMITER: 10 req/60s per IP (brute-force PKCE protection)
- CREDENTIALS_RATE_LIMITER: 15 req/60s per user (runaway loop protection)
- Removed checkRateLimit, incrementRateLimit, RateLimitResult from OAuthStorage
- Removed RATE_LIMIT_PER_DAY constant and X-RateLimit-* response headers
- Updated test mocks with mock rate limiter bindings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add Yahoo redirect catch block logging
- Fix SSRF hostname bypass in debug route
- Deduplicate VALID_SPORTS constants in default league route
- Update sleeper test assertion for error message change

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel
Copy link

vercel bot commented Mar 11, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openai Ready Ready Preview, Comment Mar 11, 2026 4:11pm

@claude
Copy link

claude bot commented Mar 11, 2026

Code Review: fix(security): harden OAuth, auth, and API route security

Overall this is a well-structured security hardening PR. The fixes address real vulnerabilities. A few items worth discussing before merge.

Bugs / Correctness Issues

1. Placeholder namespace_id values in wrangler.jsonc — likely a deploy-stopper

The rate_limits bindings use namespace_id "1001" and "1002". Cloudflare Workers rate_limits bindings require a real namespace ID provisioned via wrangler rate-limit create or the CF dashboard. If these are placeholders, the worker will fail to deploy. They should be replaced with actual IDs, or documented as TODOs with provisioning steps.

2. Yahoo redirect validation missing HTTPS protocol check

File: web/app/api/connect/yahoo/authorize/route.ts

The hostname is validated but the protocol is not. A redirect to http://api.login.yahoo.com (plain HTTP) would pass the current guard. Since Yahoo OAuth only uses HTTPS, add redirectUrl.protocol !== "https:" to the condition.

3. Misleading comment in SSRF logic

File: web/app/api/debug/test-mcp/route.ts

The comment says "prevent bypass via fantasy-mcp.evil.workers.dev", but that hostname would actually pass the check because parts[0] is still "fantasy-mcp". The code correctly blocks the suffix bypass evil.fantasy-mcp.workers.dev. Update the comment to match. Practical risk is low since Cloudflare controls workers.dev, but the mismatch will confuse future reviewers.


Security Observations (positive)

  • Atomic code exchange (oauth-storage.ts): UPDATE ... WHERE used_at IS NULL cleanly closes the TOCTOU race. The RFC 6749 sec 4.1.2 comment about "burn on claim" is accurate and helpful.
  • Constant-time PKCE comparison: XOR loop is correct. Early-exit on length mismatch is fine since SHA-256 base64url output is always a fixed 43 characters.
  • CF Workers native rate limiting: Zero-latency with no DB round-trips. Keying TOKEN_RATE_LIMITER on IP and CREDENTIALS_RATE_LIMITER on clerkUserId is the right semantic separation.
  • encodeURIComponent on dynamic route params: Prevents path injection in leagues/sleeper/[id], leagues/yahoo/[id], and leagues/default/[sport].
  • Removed JWT payload debug logging: Logging decoded JWT claims in a production log stream is an information leak; removing it is the right call.
  • PKCE RFC 7636 validation: Length (43-128 chars) and unreserved-charset checks match spec requirements exactly.

Regression Concern

4. X-RateLimit- and Retry-After headers dropped from 429 responses*

The old Supabase-based code emitted X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, and Retry-After on 429 responses. These are gone because the CF native rate limiter does not expose that metadata. If any client relied on those headers for backoff, it will now receive a bare 429 with no guidance. Not a blocker if clients handle 429 generically, but worth confirming.


Minor Notes

  • getToken optional chaining: Since auth() from Clerk always provides getToken for an authenticated session (userId is already validated above each call), the ?. operator is technically unnecessary. Using getToken() directly would let TypeScript enforce non-nullability more strictly.
  • sleeper/discover empty body: The guard checks for non-objects, but also rejects a valid empty {}. Worth confirming whether the downstream worker expects a body or not.

Test Coverage

The eval-api-key.test.ts mock stubs both rate limiter bindings correctly. To better cover the new security paths, consider adding:

  • A test for double-exchange of the same auth code (the core TOCTOU fix)
  • A test where the rate limiter mock returns { success: false } to verify the 429 response path end-to-end

Summary: The core fixes (atomic code exchange, PKCE hardening, SSRF tightening, consistent early auth failures) are sound and well-implemented. The main item to resolve before merging is the placeholder namespace_id values in wrangler.jsonc — those will likely cause a production deploy failure. The HTTPS protocol check on Yahoo redirects is a quick one-liner worth adding.

@gemini-code-assist
Copy link

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the application's security posture and improves performance across several critical areas. Key updates include fortifying OAuth authorization flows against race conditions and timing attacks, migrating to more efficient native Cloudflare Workers rate limiting, and strengthening API route protections through stricter SSRF allowlists and JWT issuer validation. Additionally, it introduces comprehensive input validation for core league parameters and refines error messaging for external API interactions, contributing to a more robust and secure system.

Highlights

  • OAuth Security: Fixed an OAuth authorization code race condition by implementing an atomic UPDATE...WHERE used_at IS NULL operation, ensuring codes are used only once. Added PKCE code_verifier length and character set validation (RFC 7636) and constant-time comparison to prevent timing attacks.
  • Rate Limiting: Replaced the Supabase-based rate limiting mechanism with native Cloudflare Workers rate_limits, providing zero-latency and eliminating database round-trips for improved performance and reliability.
  • API Route Security: Tightened the Server-Side Request Forgery (SSRF) allowlist to restrict access to specific Flaim Cloudflare Worker prefixes. Enhanced JWT issuer handling for both production and development environments, and improved error message sanitization across various API clients.
  • Input Validation: Introduced robust validation for platform, sport, and seasonYear enums on the default league route to prevent invalid data from being processed.
  • Code Quality & Maintenance: Deduplicated VALID_SPORTS constants and corrected a test assertion in the Sleeper client, improving code consistency and test accuracy.
Changelog
  • package-lock.json
    • Removed peer: true from several dependency entries.
  • web/app/api/auth/espn/credentials/route.ts
    • Enforced authentication token presence for API calls and updated authorization header logic.
  • web/app/api/connect/sleeper/discover/route.ts
    • Added validation for the request body and ensured authentication token presence for API calls.
  • web/app/api/connect/sleeper/leagues/[id]/route.ts
    • Required authentication token for league deletion and URL-encoded the league ID.
  • web/app/api/connect/yahoo/authorize/route.ts
    • Validated the authentication token and added open redirect protection for Yahoo authorization redirects.
  • web/app/api/connect/yahoo/discover/route.ts
    • Ensured authentication token presence for API calls.
  • web/app/api/connect/yahoo/leagues/[id]/route.ts
    • Required authentication token for league deletion and URL-encoded the league ID.
  • web/app/api/debug/test-mcp/route.ts
    • Restricted the Server-Side Request Forgery (SSRF) allowlist to specific Flaim Cloudflare Worker prefixes.
  • web/app/api/espn/auto-pull/route.ts
    • Simplified authentication token handling and error reporting.
  • web/app/api/espn/leagues/[leagueId]/team/route.ts
    • Required authentication token for team updates and URL-encoded the league ID.
  • web/app/api/espn/leagues/default/route.ts
    • Added validation for platform, sport, and season year parameters, and enforced authentication token presence.
  • web/app/api/oauth/code/route.ts
    • Required authentication token for OAuth code exchange.
  • workers/auth-worker/src/tests/eval-api-key.test.ts
    • Updated test setup to mock new rate limiters and removed old OAuth storage mocks.
  • workers/auth-worker/src/index-hono.ts
    • Integrated Cloudflare Workers native rate limiting, refined JWT issuer validation, and removed Supabase-based rate limiting.
  • workers/auth-worker/src/oauth-storage.ts
    • Implemented atomic OAuth code exchange, added PKCE code verifier validation, and removed Supabase-based rate limiting logic.
  • workers/auth-worker/wrangler.jsonc
    • Configured Cloudflare Workers native rate limiters for token and credentials endpoints.
  • workers/espn-client/src/shared/espn-api.ts
    • Improved generic ESPN API error messages for better user experience.
  • workers/sleeper-client/src/shared/tests/sleeper-api.test.ts
    • Updated test assertion for generic Sleeper API error messages.
  • workers/sleeper-client/src/shared/sleeper-api.ts
    • Improved generic Sleeper API error messages for better user experience.
  • workers/yahoo-client/src/shared/yahoo-api.ts
    • Improved generic Yahoo API error messages for better user experience.
Activity
  • No human activity has been recorded on this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 98132e49e6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +50 to +53
return parts.length >= 3
&& parts[parts.length - 1] === 'dev'
&& parts[parts.length - 2] === 'workers'
&& ALLOWED_WORKER_PREFIXES.some(prefix => parts[0] === prefix);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject non-Flaim workers.dev hosts in SSRF allowlist

The new workers.dev check still allows arbitrary accounts as long as the first label matches an allowed prefix, so URLs like https://fantasy-mcp.evil.workers.dev/... pass validation. That means this endpoint can still be used to fetch attacker-controlled workers despite the hardening goal, because the account segment is never validated against a trusted value.

Useful? React with 👍 / 👎.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces significant security hardening measures across the authentication and API layers, including fixing an OAuth race condition with an atomic database update, strengthening PKCE validation, replacing database-based rate limiting with a more efficient native solution, tightening SSRF protection, and sanitizing error messages. While these enhancements are substantial and generally well-implemented, specific issues were identified in the debug route's SSRF protection logic, notably the inclusion of localhost in the allowlist and a bypassable check for Cloudflare Workers subdomains. Additionally, a minor suggestion was noted to improve readability in one of the new validation checks.

Comment on lines 11 to 13
// Localhost for development
'localhost',
'127.0.0.1',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The SSRF allowlist includes localhost and 127.0.0.1. While the comment suggests this is for development, these entries should be conditionally included based on the environment (e.g., process.env.NODE_ENV === 'development'). If this route is accessible in a production or staging environment, an attacker could use it to probe internal services running on the same host or within the local network.

Comment on lines +48 to +54
if (url.hostname.endsWith('.workers.dev')) {
const parts = url.hostname.split('.');
return parts.length >= 3
&& parts[parts.length - 1] === 'dev'
&& parts[parts.length - 2] === 'workers'
&& ALLOWED_WORKER_PREFIXES.some(prefix => parts[0] === prefix);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The validation logic for workers.dev subdomains only checks the first segment of the hostname against ALLOWED_WORKER_PREFIXES. Since Cloudflare Workers subdomains are structured as worker-name.subdomain.workers.dev, an attacker can create a worker with one of the allowed names (e.g., fantasy-mcp) on their own Cloudflare account (e.g., fantasy-mcp.attacker.workers.dev) and bypass this check. This allows the debug route to fetch from attacker-controlled workers, which could lead to SSRF or leakage of sensitive headers if they are forwarded in the fetch call.

Comment on lines +38 to +41
if (redirectUrl.hostname !== 'api.login.yahoo.com' && !redirectUrl.hostname.endsWith('.yahoo.com')) {
console.error('Yahoo authorize: unexpected redirect target:', redirectUrl.hostname);
return NextResponse.json({ error: 'Invalid redirect target' }, { status: 502 });
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For improved readability, you could simplify the domain validation logic by first checking for valid domains and then handling the invalid case. This avoids a complex negative condition and makes the intent clearer.

        const isYahooDomain = redirectUrl.hostname === 'api.login.yahoo.com' || redirectUrl.hostname.endsWith('.yahoo.com');
        if (!isYahooDomain) {
          console.error('Yahoo authorize: unexpected redirect target:', redirectUrl.hostname);
          return NextResponse.json({ error: 'Invalid redirect target' }, { status: 502 });
        }

…lowlist

Address PR review feedback:
- Pin workers.dev check to gerrygugger account subdomain and enforce
  exactly 4 hostname segments (blocks fantasy-mcp.evil.workers.dev)
- Gate localhost/127.0.0.1 behind NODE_ENV=development so they're
  excluded in production

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jdguggs10 jdguggs10 merged commit ba9d3dc into main Mar 11, 2026
1 of 2 checks passed
@claude
Copy link

claude bot commented Mar 11, 2026

Code Review — PR #30: fix(security): harden OAuth, auth, and API route security

This is a well-scoped security hardening PR. The core changes are sound and address real vulnerabilities. Below is detailed feedback organized by area.


✅ Strengths

  • Atomic code exchange (oauth-storage.ts): UPDATE ... WHERE used_at IS NULL correctly prevents the TOCTOU race where two concurrent requests could both read a code as unused before either marks it used. The RFC 6749 comment on the redirect-mismatch path is accurate.
  • Constant-time PKCE comparison: The XOR-based comparison is correct. The early return when lengths differ is safe in practice since SHA-256 base64url output is always 43 chars, making the branch non-exploitable.
  • SSRF tightening (test-mcp/route.ts): Removing the workers.dev wildcard and replacing it with explicit prefix + account subdomain checks is a meaningful improvement. The CF_ACCOUNT_SUBDOMAIN constant and comments make the intent clear.
  • Auth guard refactor: Replacing the (await getToken?.()) || undefined + conditional header spread pattern with an early 401 return is cleaner and ensures the Authorization header is never silently omitted.
  • encodeURIComponent on path segments: Good catch across sleeper/leagues/[id], yahoo/leagues/[id], espn/leagues/[leagueId]/team, and leagues/default DELETE.
  • Removed JWT debug logging in auto-pull/route.ts: The previous code logged JWT sub/iss/exp to server logs, which is a privacy risk in production.
  • CF Workers native rate limiting: Zero-latency, no DB round-trips — strictly better than the Supabase-based approach for this use case.

🟡 Issues Worth Addressing

1. namespace_id placeholder values in wrangler.jsonc

{ "binding": "TOKEN_RATE_LIMITER",      "namespace_id": "1001", ... },
{ "binding": "CREDENTIALS_RATE_LIMITER","namespace_id": "1002", ... }

CF Workers rate limiters require a real namespace_id UUID provisioned via the Cloudflare dashboard (or wrangler CLI). The values 1001/1002 look like placeholders. If these are deployed as-is, the bindings will fail to resolve. Please confirm these are real namespace IDs or document that they need provisioning before deploy.

2. Removed Retry-After / X-RateLimit-* headers on 429

The old implementation returned:

Retry-After: <seconds>
X-RateLimit-Limit: 200
X-RateLimit-Remaining: 0
X-RateLimit-Reset: <epoch>

The new 429 responses return no retry guidance:

headers: { 'Content-Type': 'application/json', ...corsHeaders }

RFC 6585 §4 recommends including Retry-After. CF's native rate limiter doesn't expose the reset time, but you could at least include a static Retry-After: 60 based on the configured period. Clients that currently inspect these headers for backoff will break silently.

3. CREDENTIALS_RATE_LIMITER is only applied to the raw-credentials GET path

if (getRawCredentials) {
  const { success } = await env.CREDENTIALS_RATE_LIMITER.limit({ key: clerkUserId });
  // ...
}

The limiter is not applied to POST (save credentials) or DELETE paths in handleCredentialsEspn. If the concern is credential exfiltration, limiting only the GET is correct. If the concern is general abuse, the other paths are unprotected. Worth an explicit comment on the intended threat model.

4. Open redirect allowlist could be more defensive

if (redirectUrl.hostname !== 'api.login.yahoo.com' && !redirectUrl.hostname.endsWith('.yahoo.com')) {

*.yahoo.com is broad. A compromised/subdomain-takeover on any *.yahoo.com subdomain would pass this check. Consider tightening to specific known-good hostnames: api.login.yahoo.com and login.yahoo.com. This is low risk given Yahoo controls their subdomains, but it's worth the extra specificity for defense-in-depth.

5. seasonYear validation range vs. VALID_PLATFORMS placement

if (!VALID_PLATFORMS.includes(body.platform as typeof VALID_PLATFORMS[number])) {
if (!VALID_SPORTS.includes(body.sport as typeof VALID_SPORTS[number])) {
if (!Number.isInteger(body.seasonYear) || body.seasonYear < 2000 || body.seasonYear > 2100) {

These validations run before checking NEXT_PUBLIC_AUTH_WORKER_URL. The order should be: parse/validate body → check config → auth. Currently auth (the userId check at the top of the handler) comes before these, but the env var check comes after. Minor, but ordering matters for predictable error responses.

Also: VALID_PLATFORMS in espn/leagues/default/route.ts includes 'yahoo' and 'sleeper'. If this endpoint is ESPN-specific, accepting non-ESPN platforms could confuse callers. If it's a generic "set default league" route, the name is misleading.

6. sleeper/discover body validation is more breaking than intended

const body = await req.json().catch(() => null);
if (!body || typeof body !== 'object') {
  return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}

The original catch(() => ({})) allowed callers with no body / Content-Type mismatch to still proceed. The new behavior returns 400 for any parse failure, including an empty body or missing Content-Type: application/json. If the Sleeper discover route actually requires a body with content, this is correct — but it would be worth a comment or a test confirming the expected request shape.


🔵 Minor / Nits

  • CF_ACCOUNT_SUBDOMAIN = 'gerrygugger' is fine for this repo but reduces portability for forks. A comment noting this would help contributors.
  • VALID_SPORTS is defined in espn/leagues/default/route.ts — the PR summary mentions "deduplicate VALID_SPORTS constants" but this diff only shows one definition site. If there are others in the codebase, confirm all references use this single source.
  • OAuthStorage import removed from index-hono.ts — confirm the remaining usages (non-rate-limit paths) are still importing from the right places. The diff shows the import removed at the top; a quick grep for OAuthStorage in that file would confirm nothing is broken.
  • authCode construction from raw data: the field mappings (data.code_challenge, data.code_challenge_method, data.resource) should match the actual Supabase column names exactly. If any column is named differently (e.g., snake_case vs camelCase mismatch), these will silently be null/undefined. A quick check against the Supabase schema would be reassuring.

Summary

Area Status
OAuth race condition fix ✅ Solid
PKCE RFC 7636 compliance ✅ Solid
CF native rate limiting ✅ Solid (pending namespace_id confirmation)
Auth guard hardening ✅ Solid
SSRF tightening ✅ Solid
Error message sanitization ✅ Solid
Retry-After on 429 🟡 Regression
Rate limiter namespace IDs 🟡 Needs verification
Open redirect allowlist breadth 🟡 Minor

The PR is in good shape overall. The namespace_id question and the missing Retry-After are the items most worth resolving before merging to production.

🤖 Generated with Claude Code

@jdguggs10 jdguggs10 deleted the fix/security-hardening-v2 branch March 11, 2026 18:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants