From 98026bc6dddb562a8a468cbcd24ec0ce6f9f12bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 02:02:19 +0000 Subject: [PATCH 1/5] fix(security): harden OAuth, auth, and API route security - 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 --- web/app/api/auth/espn/credentials/route.ts | 14 +++-- web/app/api/connect/sleeper/discover/route.ts | 12 +++- .../api/connect/sleeper/leagues/[id]/route.ts | 9 ++- web/app/api/connect/yahoo/authorize/route.ts | 17 +++++- web/app/api/connect/yahoo/discover/route.ts | 7 ++- .../api/connect/yahoo/leagues/[id]/route.ts | 9 ++- web/app/api/debug/test-mcp/route.ts | 27 +++++++-- web/app/api/espn/auto-pull/route.ts | 20 +------ .../api/espn/leagues/[leagueId]/team/route.ts | 16 +++-- web/app/api/espn/leagues/default/route.ts | 35 +++++++++-- web/app/api/oauth/code/route.ts | 7 ++- workers/auth-worker/src/index-hono.ts | 32 +++++++--- workers/auth-worker/src/oauth-storage.ts | 60 ++++++++++++++++--- workers/espn-client/src/shared/espn-api.ts | 3 +- .../sleeper-client/src/shared/sleeper-api.ts | 3 +- workers/yahoo-client/src/shared/yahoo-api.ts | 3 +- 16 files changed, 204 insertions(+), 70 deletions(-) diff --git a/web/app/api/auth/espn/credentials/route.ts b/web/app/api/auth/espn/credentials/route.ts index 0a495c09..e60f6cd8 100644 --- a/web/app/api/auth/espn/credentials/route.ts +++ b/web/app/api/auth/espn/credentials/route.ts @@ -27,10 +27,13 @@ export async function GET(request: NextRequest) { const forEdit = request.nextUrl.searchParams.get('forEdit') === 'true'; const queryString = forEdit ? '?forEdit=true' : ''; - const bearer = (await getToken?.()) || undefined; + const bearer = await getToken?.(); + if (!bearer) { + return NextResponse.json({ error: 'Authentication token unavailable' }, { status: 401 }); + } const workerRes = await fetch(`${authWorkerUrl}/credentials/espn${queryString}`, { headers: { - ...(bearer ? { 'Authorization': `Bearer ${bearer}` } : {}) + 'Authorization': `Bearer ${bearer}`, }, }); @@ -101,12 +104,15 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'NEXT_PUBLIC_AUTH_WORKER_URL is not configured' }, { status: 500 }); } - const bearer = (await getToken?.()) || undefined; + const bearer = await getToken?.(); + if (!bearer) { + return NextResponse.json({ error: 'Authentication token unavailable' }, { status: 401 }); + } const workerRes = await fetch(`${authWorkerUrl}/credentials/espn`, { method: 'POST', headers: { 'Content-Type': 'application/json', - ...(bearer ? { 'Authorization': `Bearer ${bearer}` } : {}) + 'Authorization': `Bearer ${bearer}`, }, body: JSON.stringify({ swid: body.swid, diff --git a/web/app/api/connect/sleeper/discover/route.ts b/web/app/api/connect/sleeper/discover/route.ts index 5b83676b..7cdd91b8 100644 --- a/web/app/api/connect/sleeper/discover/route.ts +++ b/web/app/api/connect/sleeper/discover/route.ts @@ -17,13 +17,19 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'AUTH_WORKER_URL not configured' }, { status: 500 }); } - const body = await req.json().catch(() => ({})); - const bearer = (await getToken?.()) || undefined; + const body = await req.json().catch(() => null); + if (!body || typeof body !== 'object') { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + const bearer = await getToken?.(); + if (!bearer) { + return NextResponse.json({ error: 'Authentication token unavailable' }, { status: 401 }); + } const workerRes = await fetch(`${authWorkerUrl}/connect/sleeper/discover`, { method: 'POST', headers: { 'Content-Type': 'application/json', - ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), + Authorization: `Bearer ${bearer}`, }, body: JSON.stringify(body), }); diff --git a/web/app/api/connect/sleeper/leagues/[id]/route.ts b/web/app/api/connect/sleeper/leagues/[id]/route.ts index 01d462a0..b3fb7747 100644 --- a/web/app/api/connect/sleeper/leagues/[id]/route.ts +++ b/web/app/api/connect/sleeper/leagues/[id]/route.ts @@ -25,11 +25,14 @@ export async function DELETE( return NextResponse.json({ error: 'AUTH_WORKER_URL not configured' }, { status: 500 }); } - const bearer = (await getToken?.()) || undefined; - const workerRes = await fetch(`${authWorkerUrl}/leagues/sleeper/${leagueId}`, { + const bearer = await getToken?.(); + if (!bearer) { + return NextResponse.json({ error: 'Authentication token unavailable' }, { status: 401 }); + } + const workerRes = await fetch(`${authWorkerUrl}/leagues/sleeper/${encodeURIComponent(leagueId)}`, { method: 'DELETE', headers: { - ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), + Authorization: `Bearer ${bearer}`, }, }); diff --git a/web/app/api/connect/yahoo/authorize/route.ts b/web/app/api/connect/yahoo/authorize/route.ts index 5fdca8cc..fca1add0 100644 --- a/web/app/api/connect/yahoo/authorize/route.ts +++ b/web/app/api/connect/yahoo/authorize/route.ts @@ -18,17 +18,30 @@ export async function GET() { return NextResponse.json({ error: 'AUTH_WORKER_URL not configured' }, { status: 500 }); } - const bearer = (await getToken?.()) || undefined; + const bearer = await getToken?.(); + if (!bearer) { + return NextResponse.json({ error: 'Authentication token unavailable' }, { status: 401 }); + } const workerRes = await fetch(`${authWorkerUrl}/connect/yahoo/authorize`, { redirect: 'manual', headers: { - ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), + Authorization: `Bearer ${bearer}`, }, }); // auth-worker returns 302 with Location header const location = workerRes.headers.get('Location'); if (workerRes.status === 302 && location) { + // Validate redirect target to prevent open redirect attacks + try { + const redirectUrl = new URL(location); + 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 }); + } + } catch { + return NextResponse.json({ error: 'Invalid redirect URL' }, { status: 502 }); + } return NextResponse.redirect(location); } diff --git a/web/app/api/connect/yahoo/discover/route.ts b/web/app/api/connect/yahoo/discover/route.ts index 18e8cc02..8f0601df 100644 --- a/web/app/api/connect/yahoo/discover/route.ts +++ b/web/app/api/connect/yahoo/discover/route.ts @@ -17,12 +17,15 @@ export async function POST() { return NextResponse.json({ error: 'AUTH_WORKER_URL not configured' }, { status: 500 }); } - const bearer = (await getToken?.()) || undefined; + const bearer = await getToken?.(); + if (!bearer) { + return NextResponse.json({ error: 'Authentication token unavailable' }, { status: 401 }); + } const workerRes = await fetch(`${authWorkerUrl}/connect/yahoo/discover`, { method: 'POST', headers: { 'Content-Type': 'application/json', - ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), + Authorization: `Bearer ${bearer}`, }, }); diff --git a/web/app/api/connect/yahoo/leagues/[id]/route.ts b/web/app/api/connect/yahoo/leagues/[id]/route.ts index 89e4f211..80b69d12 100644 --- a/web/app/api/connect/yahoo/leagues/[id]/route.ts +++ b/web/app/api/connect/yahoo/leagues/[id]/route.ts @@ -25,11 +25,14 @@ export async function DELETE( return NextResponse.json({ error: 'AUTH_WORKER_URL not configured' }, { status: 500 }); } - const bearer = (await getToken?.()) || undefined; - const workerRes = await fetch(`${authWorkerUrl}/leagues/yahoo/${leagueId}`, { + const bearer = await getToken?.(); + if (!bearer) { + return NextResponse.json({ error: 'Authentication token unavailable' }, { status: 401 }); + } + const workerRes = await fetch(`${authWorkerUrl}/leagues/yahoo/${encodeURIComponent(leagueId)}`, { method: 'DELETE', headers: { - ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), + Authorization: `Bearer ${bearer}`, }, }); diff --git a/web/app/api/debug/test-mcp/route.ts b/web/app/api/debug/test-mcp/route.ts index f347d8b1..a9b286fa 100644 --- a/web/app/api/debug/test-mcp/route.ts +++ b/web/app/api/debug/test-mcp/route.ts @@ -8,13 +8,22 @@ import { auth } from '@clerk/nextjs/server'; const ALLOWED_MCP_HOST_PATTERNS = [ // Flaim production domains 'flaim.app', - // Cloudflare Workers (any account - for contributors and preview) - 'workers.dev', // Localhost for development 'localhost', '127.0.0.1', ]; +/** + * Flaim-specific worker name prefixes allowed on workers.dev. + * Prevents probing arbitrary Cloudflare Workers. + */ +const ALLOWED_WORKER_PREFIXES = [ + 'fantasy-mcp', + 'fantasy-mcp-preview', + 'auth-worker', + 'auth-worker-preview', +]; + /** * Validate that a URL is safe to fetch (SSRF protection) */ @@ -28,10 +37,20 @@ function isAllowedUrl(urlString: string): boolean { return false; } - // Check against allowlist patterns - return ALLOWED_MCP_HOST_PATTERNS.some(pattern => + // Check against static allowlist patterns + const matchesStatic = ALLOWED_MCP_HOST_PATTERNS.some(pattern => url.hostname === pattern || url.hostname.endsWith(`.${pattern}`) ); + if (matchesStatic) return true; + + // Check workers.dev — only allow known Flaim worker prefixes + if (url.hostname.endsWith('.workers.dev')) { + return ALLOWED_WORKER_PREFIXES.some(prefix => + url.hostname.startsWith(`${prefix}.`) + ); + } + + return false; } catch { return false; } diff --git a/web/app/api/espn/auto-pull/route.ts b/web/app/api/espn/auto-pull/route.ts index 239aae25..8b5a2408 100644 --- a/web/app/api/espn/auto-pull/route.ts +++ b/web/app/api/espn/auto-pull/route.ts @@ -39,25 +39,9 @@ export async function POST(request: NextRequest) { console.log(`[auto-pull] Using season year: ${seasonYear} (requested: ${requestedSeasonYear || 'none'})`); - const bearer = (await getToken?.()) || undefined; - - // Debug: Log token availability and details (helps diagnose auth issues) - console.log(`[auto-pull] User: ${userId}, bearer token available: ${!!bearer}`); - if (bearer) { - // Log JWT structure without exposing full token - const parts = bearer.split('.'); - console.log(`[auto-pull] JWT structure: ${parts.length} parts, length: ${bearer.length} chars`); - try { - const payload = JSON.parse(atob(parts[1])); - console.log(`[auto-pull] JWT payload - sub: ${payload.sub}, iss: ${payload.iss}, exp: ${new Date(payload.exp * 1000).toISOString()}`); - } catch (parseError) { - console.log('[auto-pull] Could not parse JWT payload', parseError); - } - } - - // If no bearer token, auth-worker and sport workers will reject the request + const bearer = await getToken?.(); if (!bearer) { - console.error('[auto-pull] getToken() returned undefined - cannot authenticate with workers'); + console.error('[auto-pull] getToken() returned undefined'); return NextResponse.json({ error: 'Authentication token unavailable. Please try signing out and back in.', code: 'TOKEN_UNAVAILABLE' diff --git a/web/app/api/espn/leagues/[leagueId]/team/route.ts b/web/app/api/espn/leagues/[leagueId]/team/route.ts index 1b076fb3..c52d83fa 100644 --- a/web/app/api/espn/leagues/[leagueId]/team/route.ts +++ b/web/app/api/espn/leagues/[leagueId]/team/route.ts @@ -37,12 +37,15 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< return NextResponse.json({ error: 'NEXT_PUBLIC_AUTH_WORKER_URL is not configured' }, { status: 500 }); } - const bearer = (await getToken?.()) || undefined; - const response = await fetch(`${authWorkerUrl}/leagues/${leagueId}/team`, { + const bearer = await getToken?.(); + if (!bearer) { + return NextResponse.json({ error: 'Authentication token unavailable' }, { status: 401 }); + } + const response = await fetch(`${authWorkerUrl}/leagues/${encodeURIComponent(leagueId)}/team`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', - ...(bearer ? { 'Authorization': `Bearer ${bearer}` } : {}) + 'Authorization': `Bearer ${bearer}`, }, body: JSON.stringify({ teamId: body.teamId, @@ -98,12 +101,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'NEXT_PUBLIC_AUTH_WORKER_URL is not configured' }, { status: 500 }); } - const bearer = (await getToken?.()) || undefined; + const bearer = await getToken?.(); + if (!bearer) { + return NextResponse.json({ error: 'Authentication token unavailable' }, { status: 401 }); + } const response = await fetch(`${authWorkerUrl}/leagues`, { method: 'GET', headers: { 'Content-Type': 'application/json', - ...(bearer ? { 'Authorization': `Bearer ${bearer}` } : {}) + 'Authorization': `Bearer ${bearer}`, } }); diff --git a/web/app/api/espn/leagues/default/route.ts b/web/app/api/espn/leagues/default/route.ts index 1a90eb0e..514fcf92 100644 --- a/web/app/api/espn/leagues/default/route.ts +++ b/web/app/api/espn/leagues/default/route.ts @@ -22,23 +22,41 @@ export async function POST(request: NextRequest) { seasonYear?: number; } = await request.json(); + const VALID_PLATFORMS = ['espn', 'yahoo', 'sleeper'] as const; + const VALID_SPORTS = ['football', 'baseball', 'basketball', 'hockey'] as const; + if (!body.platform || !body.leagueId || !body.sport || body.seasonYear === undefined) { return NextResponse.json({ error: 'platform, leagueId, sport, and seasonYear are required in request body' }, { status: 400 }); } + if (!VALID_PLATFORMS.includes(body.platform as typeof VALID_PLATFORMS[number])) { + return NextResponse.json({ error: 'Invalid platform' }, { status: 400 }); + } + + if (!VALID_SPORTS.includes(body.sport as typeof VALID_SPORTS[number])) { + return NextResponse.json({ error: 'Invalid sport' }, { status: 400 }); + } + + if (!Number.isInteger(body.seasonYear) || body.seasonYear < 2000 || body.seasonYear > 2100) { + return NextResponse.json({ error: 'Invalid seasonYear' }, { status: 400 }); + } + const authWorkerUrl = process.env.NEXT_PUBLIC_AUTH_WORKER_URL; if (!authWorkerUrl) { return NextResponse.json({ error: 'NEXT_PUBLIC_AUTH_WORKER_URL is not configured' }, { status: 500 }); } - const bearer = (await getToken?.()) || undefined; + const bearer = await getToken?.(); + if (!bearer) { + return NextResponse.json({ error: 'Authentication token unavailable' }, { status: 401 }); + } const workerResponse = await fetch(`${authWorkerUrl}/leagues/default`, { method: 'POST', headers: { 'Content-Type': 'application/json', - ...(bearer ? { 'Authorization': `Bearer ${bearer}` } : {}) + 'Authorization': `Bearer ${bearer}`, }, body: JSON.stringify(body) }); @@ -72,19 +90,26 @@ export async function DELETE(request: NextRequest) { const { searchParams } = new URL(request.url); const sport = searchParams.get('sport'); + const VALID_DELETE_SPORTS = ['football', 'baseball', 'basketball', 'hockey']; if (!sport) { return NextResponse.json({ error: 'sport query param is required' }, { status: 400 }); } + if (!VALID_DELETE_SPORTS.includes(sport)) { + return NextResponse.json({ error: 'Invalid sport' }, { status: 400 }); + } const authWorkerUrl = process.env.NEXT_PUBLIC_AUTH_WORKER_URL; if (!authWorkerUrl) { return NextResponse.json({ error: 'NEXT_PUBLIC_AUTH_WORKER_URL is not configured' }, { status: 500 }); } - const bearer = (await getToken?.()) || undefined; - const workerResponse = await fetch(`${authWorkerUrl}/leagues/default/${sport}`, { + const bearer = await getToken?.(); + if (!bearer) { + return NextResponse.json({ error: 'Authentication token unavailable' }, { status: 401 }); + } + const workerResponse = await fetch(`${authWorkerUrl}/leagues/default/${encodeURIComponent(sport)}`, { method: 'DELETE', - headers: bearer ? { 'Authorization': `Bearer ${bearer}` } : {} + headers: { 'Authorization': `Bearer ${bearer}` } }); const workerData = await workerResponse.json() as any; diff --git a/web/app/api/oauth/code/route.ts b/web/app/api/oauth/code/route.ts index 9939f166..dc2ec529 100644 --- a/web/app/api/oauth/code/route.ts +++ b/web/app/api/oauth/code/route.ts @@ -39,13 +39,16 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'AUTH_WORKER_URL is not configured' }, { status: 500 }); } - const bearer = (await getToken?.()) || undefined; + const bearer = await getToken?.(); + if (!bearer) { + return NextResponse.json({ error: 'Authentication token unavailable' }, { status: 401 }); + } const workerRes = await fetch(`${authWorkerUrl}/oauth/code`, { method: 'POST', headers: { 'Content-Type': 'application/json', - ...(bearer ? { 'Authorization': `Bearer ${bearer}` } : {}) + 'Authorization': `Bearer ${bearer}`, }, body: JSON.stringify({ redirect_uri: body.redirect_uri, diff --git a/workers/auth-worker/src/index-hono.ts b/workers/auth-worker/src/index-hono.ts index 23af4f3b..b66c8987 100644 --- a/workers/auth-worker/src/index-hono.ts +++ b/workers/auth-worker/src/index-hono.ts @@ -249,11 +249,18 @@ async function verifyJwtAndGetUserId(authorization: string | null, env: Env): Pr throw new Error(`JWT issuer "${payload.iss}" not in allowlist`); } } else { - // Dev/preview: also allow Clerk dev issuers - const isClerkIssuer = allowedIssuers.includes(payload.iss) || - payload.iss.endsWith('.clerk.accounts.dev'); - if (!isClerkIssuer) { - throw new Error(`JWT issuer "${payload.iss}" not recognized`); + // Dev/preview: prefer explicit CLERK_ISSUER; fall back to wildcard only if unset + if (env.CLERK_ISSUER) { + if (!allowedIssuers.includes(payload.iss)) { + throw new Error(`JWT issuer "${payload.iss}" not in allowlist`); + } + } else { + console.warn('[auth-worker] CLERK_ISSUER not set in non-prod — falling back to wildcard .clerk.accounts.dev matching'); + const isClerkIssuer = allowedIssuers.includes(payload.iss) || + payload.iss.endsWith('.clerk.accounts.dev'); + if (!isClerkIssuer) { + throw new Error(`JWT issuer "${payload.iss}" not recognized`); + } } } @@ -577,8 +584,19 @@ api.get('/authorize', (c) => { return handleAuthorize(c.req.raw, c.env as OAuthEnv); }); -// Token endpoint - exchange code for access token -api.post('/token', (c) => { +// Token endpoint - exchange code for access token (rate-limited per IP) +api.post('/token', async (c) => { + // Rate limit token exchange per IP to prevent brute-force PKCE attacks + const clientIp = c.req.header('CF-Connecting-IP') || c.req.header('X-Forwarded-For') || 'unknown'; + const storage = OAuthStorage.fromEnvironment(c.env); + const tokenRateLimit = await storage.checkRateLimit(`token:${clientIp}`, 30); // 30 token attempts/day per IP + if (!tokenRateLimit.allowed) { + return c.json({ + error: 'rate_limit_exceeded', + error_description: 'Too many token requests. Please try again later.', + }, 429); + } + await storage.incrementRateLimit(`token:${clientIp}`); return handleToken(c.req.raw, c.env as OAuthEnv, getCorsHeaders(c.req.raw)); }); diff --git a/workers/auth-worker/src/oauth-storage.ts b/workers/auth-worker/src/oauth-storage.ts index 50624f52..77fef734 100644 --- a/workers/auth-worker/src/oauth-storage.ts +++ b/workers/auth-worker/src/oauth-storage.ts @@ -111,21 +111,38 @@ function generateSecureToken(length: number = 32): string { /** * Verify PKCE code verifier against stored challenge + * RFC 7636 requires code_verifier to be 43-128 characters, unreserved charset. */ async function verifyPkceChallenge( codeVerifier: string, codeChallenge: string, method: 'S256' ): Promise { + // RFC 7636 §4.1: code_verifier must be 43-128 characters + if (codeVerifier.length < 43 || codeVerifier.length > 128) { + console.log(`[oauth-storage] PKCE code_verifier length out of range: ${codeVerifier.length}`); + return false; + } + // S256: SHA-256 hash of verifier, base64url encoded const encoder = new TextEncoder(); const data = encoder.encode(codeVerifier); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashArray = new Uint8Array(hashBuffer); const base64 = btoa(String.fromCharCode(...hashArray)); - const base64url = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + const computed = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); - return base64url === codeChallenge; + // Constant-time comparison to prevent timing attacks + const computedBytes = encoder.encode(computed); + const expectedBytes = encoder.encode(codeChallenge); + if (computedBytes.length !== expectedBytes.length) { + return false; + } + let result = 0; + for (let i = 0; i < computedBytes.length; i++) { + result |= computedBytes[i] ^ expectedBytes[i]; + } + return result === 0; } /** @@ -352,22 +369,50 @@ export class OAuthStorage { /** * Exchange authorization code for access token * Validates PKCE if present + * + * Uses atomic claim (UPDATE ... WHERE used_at IS NULL) to prevent + * race conditions where two concurrent requests exchange the same code. */ async exchangeCodeForToken( code: string, redirectUri: string, codeVerifier?: string ): Promise { - // Get and validate the code - const authCode = await this.getAuthorizationCode(code); - if (!authCode) { + // Atomically claim the code — only succeeds if not already used + const { data, error } = await this.supabase + .from('oauth_codes') + .update({ used_at: new Date().toISOString() }) + .eq('code', code) + .is('used_at', null) + .select('*') + .single(); + + if (error || !data) { + console.log(`[oauth-storage] Auth code not found, expired, or already used: ${code.substring(0, 8)}...`); return null; } + // Check if expired + if (new Date(data.expires_at) < new Date()) { + console.log(`[oauth-storage] Auth code expired: ${code.substring(0, 8)}...`); + return null; + } + + const authCode: OAuthCode = { + code: data.code, + userId: data.user_id, + redirectUri: data.redirect_uri, + codeChallenge: data.code_challenge || undefined, + codeChallengeMethod: data.code_challenge_method || undefined, + scope: data.scope, + resource: data.resource || undefined, + expiresAt: new Date(data.expires_at), + }; + // Validate redirect URI matches if (!redirectUrisMatch(authCode.redirectUri, redirectUri)) { console.log(`[oauth-storage] Redirect URI mismatch: expected ${authCode.redirectUri}, got ${redirectUri}`); - return null; + return null; // Code is already burned — correct per RFC 6749 §4.1.2 } // Validate PKCE if challenge was provided @@ -385,9 +430,6 @@ export class OAuthStorage { } } - // Mark code as used - await this.markCodeAsUsed(code); - // Create and return access token const token = await this.createAccessToken({ userId: authCode.userId, diff --git a/workers/espn-client/src/shared/espn-api.ts b/workers/espn-client/src/shared/espn-api.ts index f64c2dda..1562aec4 100644 --- a/workers/espn-client/src/shared/espn-api.ts +++ b/workers/espn-client/src/shared/espn-api.ts @@ -66,7 +66,8 @@ export function handleEspnError(response: Response): never { case 429: throw new Error('ESPN_RATE_LIMIT: Too many requests to ESPN. Please wait and try again.'); default: - throw new Error(`ESPN_API_ERROR: ESPN returned ${response.status}`); + console.error(`[espn-api] Unexpected ESPN status: ${response.status}`); + throw new Error('ESPN_API_ERROR: An unexpected error occurred with ESPN. Please try again.'); } } diff --git a/workers/sleeper-client/src/shared/sleeper-api.ts b/workers/sleeper-client/src/shared/sleeper-api.ts index 21060cba..e3f38412 100644 --- a/workers/sleeper-client/src/shared/sleeper-api.ts +++ b/workers/sleeper-client/src/shared/sleeper-api.ts @@ -49,7 +49,8 @@ export function handleSleeperError(response: Response): never { case 400: throw new Error('SLEEPER_BAD_REQUEST: Invalid request'); default: - throw new Error(`SLEEPER_API_ERROR: Sleeper returned ${response.status}`); + console.error(`[sleeper-api] Unexpected Sleeper status: ${response.status}`); + throw new Error('SLEEPER_API_ERROR: An unexpected error occurred with Sleeper. Please try again.'); } } diff --git a/workers/yahoo-client/src/shared/yahoo-api.ts b/workers/yahoo-client/src/shared/yahoo-api.ts index d38ce15e..e102cd61 100644 --- a/workers/yahoo-client/src/shared/yahoo-api.ts +++ b/workers/yahoo-client/src/shared/yahoo-api.ts @@ -61,7 +61,8 @@ export function handleYahooError(response: Response): never { case 429: throw new Error('YAHOO_RATE_LIMITED: Too many requests. Please wait.'); default: - throw new Error(`YAHOO_API_ERROR: Yahoo returned ${response.status}`); + console.error(`[yahoo-api] Unexpected Yahoo status: ${response.status}`); + throw new Error('YAHOO_API_ERROR: An unexpected error occurred with Yahoo. Please try again.'); } } From 452aa5457e048d3f6ca78bc064b9132dad8ffeca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 02:02:56 +0000 Subject: [PATCH 2/5] chore: update package-lock.json after dependency install https://claude.ai/code/session_01YGWmBeoSA3FT4xrQvYitx3 --- package-lock.json | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index c9e1ca3f..1e0987f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1224,7 +1224,6 @@ "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.1", @@ -1240,7 +1239,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -3016,7 +3014,6 @@ "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.57.0" }, @@ -4826,7 +4823,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4907,7 +4903,6 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -5456,7 +5451,6 @@ "integrity": "sha512-NX9oUXgF9HPfJSwl8tUZCMP1oGx2+Sf+ru6d05QjzQz4OwWg0psEzwY6VexP2tTHWdOkhKHUIZH+fS6nA7jfOw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "3.0.9", "pathe": "^2.0.3" @@ -5471,7 +5465,6 @@ "integrity": "sha512-AiLUiuZ0FuA+/8i19mTYd+re5jqjEc2jZbgJ2up0VY0Ddyyxg/uUtBDpIFAy4uzKaQxOW8gMgBdAJJ2ydhu39A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/pretty-format": "3.0.9", "magic-string": "^0.30.17", @@ -5565,7 +5558,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6970,7 +6962,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -7041,7 +7032,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7215,7 +7205,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8327,7 +8316,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -10297,7 +10285,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "15.5.9", "@swc/helpers": "0.5.15", @@ -11044,7 +11031,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11054,7 +11040,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -12352,7 +12337,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12649,7 +12633,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12706,7 +12689,6 @@ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -12960,7 +12942,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -13561,7 +13542,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13575,7 +13555,6 @@ "integrity": "sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "3.0.9", "@vitest/mocker": "3.0.9", @@ -13803,7 +13782,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -13882,7 +13860,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -13985,7 +13962,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 2e6f8ce5660a80f1ab4f5d7fa5de7905aca6675b Mon Sep 17 00:00:00 2001 From: Gerry Date: Wed, 11 Mar 2026 11:26:40 -0400 Subject: [PATCH 3/5] fix(auth-worker): replace Supabase rate limiting with CF Workers native 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 --- .../src/__tests__/eval-api-key.test.ts | 14 ++-- workers/auth-worker/src/index-hono.ts | 57 ++++--------- workers/auth-worker/src/oauth-storage.ts | 84 ++----------------- workers/auth-worker/wrangler.jsonc | 14 ++++ 4 files changed, 43 insertions(+), 126 deletions(-) diff --git a/workers/auth-worker/src/__tests__/eval-api-key.test.ts b/workers/auth-worker/src/__tests__/eval-api-key.test.ts index 16ea4732..48aa9139 100644 --- a/workers/auth-worker/src/__tests__/eval-api-key.test.ts +++ b/workers/auth-worker/src/__tests__/eval-api-key.test.ts @@ -7,6 +7,8 @@ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no network in test') const EVAL_API_KEY = 'flaim_eval_abc123testkey'; const EVAL_USER_ID = 'user_eval_test_12345'; +const mockRateLimiter = { limit: async () => ({ success: true }) }; + const baseEnv = { SUPABASE_URL: 'https://example.supabase.co', SUPABASE_SERVICE_KEY: 'test-key', @@ -14,6 +16,8 @@ const baseEnv = { ENVIRONMENT: 'test', EVAL_API_KEY, EVAL_USER_ID, + TOKEN_RATE_LIMITER: mockRateLimiter, + CREDENTIALS_RATE_LIMITER: mockRateLimiter, }; function makeRequest(path: string, options?: RequestInit): Request { @@ -55,17 +59,9 @@ vi.mock('../supabase-storage', () => { }); vi.mock('../oauth-storage', () => { - const mockOAuthStorage = { - checkRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - limit: 200, - resetAt: new Date(Date.now() + 86400000), - }), - incrementRateLimit: vi.fn().mockResolvedValue(1), - }; return { OAuthStorage: { - fromEnvironment: vi.fn().mockReturnValue(mockOAuthStorage), + fromEnvironment: vi.fn().mockReturnValue({}), }, }; }); diff --git a/workers/auth-worker/src/index-hono.ts b/workers/auth-worker/src/index-hono.ts index b66c8987..ad34ffe5 100644 --- a/workers/auth-worker/src/index-hono.ts +++ b/workers/auth-worker/src/index-hono.ts @@ -37,7 +37,6 @@ import { validateOAuthToken, OAuthEnv, } from './oauth-handlers'; -import { OAuthStorage } from './oauth-storage'; import { handleSyncCredentials, handleGetExtensionStatus, @@ -73,6 +72,10 @@ import { logEvalEvent } from './logging'; // TYPES // ============================================================================= +interface RateLimit { + limit(options: { key: string }): Promise<{ success: boolean }>; +} + export interface Env { SUPABASE_URL: string; SUPABASE_SERVICE_KEY: string; @@ -86,6 +89,9 @@ export interface Env { // Eval/CI API key auth (Cloudflare secrets) EVAL_API_KEY?: string; // Static API key for eval/CI EVAL_USER_ID?: string; // Clerk user ID that the API key resolves to + // Rate limiting (Cloudflare Workers native) + TOKEN_RATE_LIMITER: RateLimit; + CREDENTIALS_RATE_LIMITER: RateLimit; } type Jwk = { @@ -104,7 +110,6 @@ type JwtPayload = { sub?: string; iss?: string; exp?: number; [k: string]: unkno // CONSTANTS // ============================================================================= -const RATE_LIMIT_PER_DAY = 200; const EVAL_RUN_HEADER = 'X-Flaim-Eval-Run'; const EVAL_TRACE_HEADER = 'X-Flaim-Eval-Trace'; @@ -586,17 +591,14 @@ api.get('/authorize', (c) => { // Token endpoint - exchange code for access token (rate-limited per IP) api.post('/token', async (c) => { - // Rate limit token exchange per IP to prevent brute-force PKCE attacks - const clientIp = c.req.header('CF-Connecting-IP') || c.req.header('X-Forwarded-For') || 'unknown'; - const storage = OAuthStorage.fromEnvironment(c.env); - const tokenRateLimit = await storage.checkRateLimit(`token:${clientIp}`, 30); // 30 token attempts/day per IP - if (!tokenRateLimit.allowed) { + const clientIp = c.req.header('CF-Connecting-IP') || 'unknown'; + const { success } = await c.env.TOKEN_RATE_LIMITER.limit({ key: clientIp }); + if (!success) { return c.json({ error: 'rate_limit_exceeded', error_description: 'Too many token requests. Please try again later.', }, 429); } - await storage.incrementRateLimit(`token:${clientIp}`); return handleToken(c.req.raw, c.env as OAuthEnv, getCorsHeaders(c.req.raw)); }); @@ -1096,33 +1098,18 @@ async function handleCredentialsEspn(c: Context<{ Bindings: Env }>, method: stri if (getRawCredentials) { console.log(`🔍 [auth-worker] GET raw credentials for user: ${maskUserId(clerkUserId)}`); - // Check rate limit before returning credentials - const oauthStorage = OAuthStorage.fromEnvironment(env); - const rateLimit = await oauthStorage.checkRateLimit(clerkUserId, RATE_LIMIT_PER_DAY); - - if (!rateLimit.allowed) { + const { success } = await env.CREDENTIALS_RATE_LIMITER.limit({ key: clerkUserId }); + if (!success) { console.log(`⚠️ [auth-worker] Rate limit exceeded for user: ${maskUserId(clerkUserId)}`); return new Response(JSON.stringify({ error: 'Rate limit exceeded', - message: `Daily limit of ${rateLimit.limit} calls reached. Limit resets at ${rateLimit.resetAt.toISOString()}.`, - resetAt: rateLimit.resetAt.toISOString(), + message: 'Too many requests. Please try again later.', }), { status: 429, - headers: { - 'Content-Type': 'application/json', - 'X-RateLimit-Limit': String(rateLimit.limit), - 'X-RateLimit-Remaining': '0', - 'X-RateLimit-Reset': String(Math.floor(rateLimit.resetAt.getTime() / 1000)), - 'Retry-After': String(Math.ceil((rateLimit.resetAt.getTime() - Date.now()) / 1000)), - ...corsHeaders - } + headers: { 'Content-Type': 'application/json', ...corsHeaders } }); } - // Increment rate limit counter - const currentUsage = await oauthStorage.incrementRateLimit(clerkUserId); - const remaining = Math.max(0, RATE_LIMIT_PER_DAY - currentUsage); - const credentials = await storage.getCredentials(clerkUserId); if (!credentials) { @@ -1131,13 +1118,7 @@ async function handleCredentialsEspn(c: Context<{ Bindings: Env }>, method: stri message: 'No ESPN credentials found for user. Add your ESPN credentials at /settings/espn' }), { status: 404, - headers: { - 'Content-Type': 'application/json', - 'X-RateLimit-Limit': String(RATE_LIMIT_PER_DAY), - 'X-RateLimit-Remaining': String(remaining), - 'X-RateLimit-Reset': String(Math.floor(rateLimit.resetAt.getTime() / 1000)), - ...corsHeaders - } + headers: { 'Content-Type': 'application/json', ...corsHeaders } }); } @@ -1146,13 +1127,7 @@ async function handleCredentialsEspn(c: Context<{ Bindings: Env }>, method: stri platform: 'espn', credentials }), { - headers: { - 'Content-Type': 'application/json', - 'X-RateLimit-Limit': String(RATE_LIMIT_PER_DAY), - 'X-RateLimit-Remaining': String(remaining), - 'X-RateLimit-Reset': String(Math.floor(rateLimit.resetAt.getTime() / 1000)), - ...corsHeaders - } + headers: { 'Content-Type': 'application/json', ...corsHeaders } }); } diff --git a/workers/auth-worker/src/oauth-storage.ts b/workers/auth-worker/src/oauth-storage.ts index 77fef734..5f0859b4 100644 --- a/workers/auth-worker/src/oauth-storage.ts +++ b/workers/auth-worker/src/oauth-storage.ts @@ -86,13 +86,6 @@ export interface TokenValidationResult { error?: string; } -export interface RateLimitResult { - allowed: boolean; - remaining: number; - limit: number; - resetAt: Date; -} - // ============================================================================= // UTILITIES // ============================================================================= @@ -118,6 +111,12 @@ async function verifyPkceChallenge( codeChallenge: string, method: 'S256' ): Promise { + // RFC 7636 §4.1: code_verifier must use unreserved characters only + if (!/^[A-Za-z0-9\-._~]+$/.test(codeVerifier)) { + console.log('[oauth-storage] PKCE code_verifier contains invalid characters'); + return false; + } + // RFC 7636 §4.1: code_verifier must be 43-128 characters if (codeVerifier.length < 43 || codeVerifier.length > 128) { console.log(`[oauth-storage] PKCE code_verifier length out of range: ${codeVerifier.length}`); @@ -378,12 +377,13 @@ export class OAuthStorage { redirectUri: string, codeVerifier?: string ): Promise { - // Atomically claim the code — only succeeds if not already used + // Atomically claim the code — only succeeds if not already used and not expired const { data, error } = await this.supabase .from('oauth_codes') .update({ used_at: new Date().toISOString() }) .eq('code', code) .is('used_at', null) + .gt('expires_at', new Date().toISOString()) .select('*') .single(); @@ -392,12 +392,6 @@ export class OAuthStorage { return null; } - // Check if expired - if (new Date(data.expires_at) < new Date()) { - console.log(`[oauth-storage] Auth code expired: ${code.substring(0, 8)}...`); - return null; - } - const authCode: OAuthCode = { code: data.code, userId: data.user_id, @@ -677,68 +671,6 @@ export class OAuthStorage { return tokens.length > 0; } - // --------------------------------------------------------------------------- - // RATE LIMITING - // --------------------------------------------------------------------------- - - /** - * Check rate limit for a user (does not increment) - * Default limit: 200 calls/day - */ - async checkRateLimit(userId: string, limit: number = 200): Promise { - const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD - - const { data, error } = await this.supabase - .from('rate_limits') - .select('request_count, window_date') - .eq('user_id', userId) - .eq('window_date', today) - .single(); - - // Calculate reset time (next midnight UTC) - const now = new Date(); - const resetAt = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1, 0, 0, 0)); - - if (error || !data) { - // No record yet - user hasn't made any calls today - return { - allowed: true, - remaining: limit, - limit, - resetAt, - }; - } - - const count = data.request_count || 0; - const remaining = Math.max(0, limit - count); - - return { - allowed: count < limit, - remaining, - limit, - resetAt, - }; - } - - /** - * Increment rate limit counter for a user (upsert) - * Returns the updated count - */ - async incrementRateLimit(userId: string): Promise { - // Use the RPC function which handles the upsert/increment logic atomically in SQL - const { data: updated, error: updateError } = await this.supabase.rpc('increment_rate_limit', { - p_user_id: userId, - }); - - if (updateError) { - console.error('[oauth-storage] Failed to increment rate limit:', updateError); - // Don't fail the request on rate limit errors - log and continue - return 0; - } - - return updated?.[0]?.request_count || 0; - } - // --------------------------------------------------------------------------- // FACTORY METHODS // --------------------------------------------------------------------------- diff --git a/workers/auth-worker/wrangler.jsonc b/workers/auth-worker/wrangler.jsonc index 6a309716..d85bf9e9 100644 --- a/workers/auth-worker/wrangler.jsonc +++ b/workers/auth-worker/wrangler.jsonc @@ -24,6 +24,20 @@ // - YAHOO_CLIENT_ID (Yahoo Developer App) // - YAHOO_CLIENT_SECRET (Yahoo Developer App) + // Rate limiting (Cloudflare Workers native — zero-latency, no DB calls) + "rate_limits": [ + { + "binding": "TOKEN_RATE_LIMITER", + "namespace_id": "1001", + "simple": { "limit": 10, "period": 60 } + }, + { + "binding": "CREDENTIALS_RATE_LIMITER", + "namespace_id": "1002", + "simple": { "limit": 15, "period": 60 } + } + ], + // Environment configurations "env": { // Production environment From 98132e49e63a42db3015d25c3754a5501c08de50 Mon Sep 17 00:00:00 2001 From: Gerry Date: Wed, 11 Mar 2026 11:43:00 -0400 Subject: [PATCH 4/5] fix(security): harden API routes and fix sleeper test assertion - 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 --- web/app/api/connect/yahoo/authorize/route.ts | 3 ++- web/app/api/debug/test-mcp/route.ts | 9 ++++++--- web/app/api/espn/leagues/default/route.ts | 9 ++++----- .../src/shared/__tests__/sleeper-api.test.ts | 2 +- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/web/app/api/connect/yahoo/authorize/route.ts b/web/app/api/connect/yahoo/authorize/route.ts index fca1add0..c813693e 100644 --- a/web/app/api/connect/yahoo/authorize/route.ts +++ b/web/app/api/connect/yahoo/authorize/route.ts @@ -39,7 +39,8 @@ export async function GET() { console.error('Yahoo authorize: unexpected redirect target:', redirectUrl.hostname); return NextResponse.json({ error: 'Invalid redirect target' }, { status: 502 }); } - } catch { + } catch (e) { + console.error('Yahoo authorize: failed to parse redirect URL', e); return NextResponse.json({ error: 'Invalid redirect URL' }, { status: 502 }); } return NextResponse.redirect(location); diff --git a/web/app/api/debug/test-mcp/route.ts b/web/app/api/debug/test-mcp/route.ts index a9b286fa..4ae0d512 100644 --- a/web/app/api/debug/test-mcp/route.ts +++ b/web/app/api/debug/test-mcp/route.ts @@ -44,10 +44,13 @@ function isAllowedUrl(urlString: string): boolean { if (matchesStatic) return true; // Check workers.dev — only allow known Flaim worker prefixes + // Validate exact first segment to prevent bypass via fantasy-mcp.evil.workers.dev if (url.hostname.endsWith('.workers.dev')) { - return ALLOWED_WORKER_PREFIXES.some(prefix => - url.hostname.startsWith(`${prefix}.`) - ); + 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); } return false; diff --git a/web/app/api/espn/leagues/default/route.ts b/web/app/api/espn/leagues/default/route.ts index 514fcf92..88abc3d6 100644 --- a/web/app/api/espn/leagues/default/route.ts +++ b/web/app/api/espn/leagues/default/route.ts @@ -7,6 +7,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@clerk/nextjs/server'; +const VALID_PLATFORMS = ['espn', 'yahoo', 'sleeper'] as const; +const VALID_SPORTS = ['football', 'baseball', 'basketball', 'hockey'] as const; + export async function POST(request: NextRequest) { try { const { userId, getToken } = await auth(); @@ -22,9 +25,6 @@ export async function POST(request: NextRequest) { seasonYear?: number; } = await request.json(); - const VALID_PLATFORMS = ['espn', 'yahoo', 'sleeper'] as const; - const VALID_SPORTS = ['football', 'baseball', 'basketball', 'hockey'] as const; - if (!body.platform || !body.leagueId || !body.sport || body.seasonYear === undefined) { return NextResponse.json({ error: 'platform, leagueId, sport, and seasonYear are required in request body' @@ -90,11 +90,10 @@ export async function DELETE(request: NextRequest) { const { searchParams } = new URL(request.url); const sport = searchParams.get('sport'); - const VALID_DELETE_SPORTS = ['football', 'baseball', 'basketball', 'hockey']; if (!sport) { return NextResponse.json({ error: 'sport query param is required' }, { status: 400 }); } - if (!VALID_DELETE_SPORTS.includes(sport)) { + if (!VALID_SPORTS.includes(sport as typeof VALID_SPORTS[number])) { return NextResponse.json({ error: 'Invalid sport' }, { status: 400 }); } diff --git a/workers/sleeper-client/src/shared/__tests__/sleeper-api.test.ts b/workers/sleeper-client/src/shared/__tests__/sleeper-api.test.ts index 11572ae0..4509c6d1 100644 --- a/workers/sleeper-client/src/shared/__tests__/sleeper-api.test.ts +++ b/workers/sleeper-client/src/shared/__tests__/sleeper-api.test.ts @@ -48,7 +48,7 @@ describe('sleeper-api helpers', () => { 'SLEEPER_BAD_REQUEST: Invalid request', ); expect(() => handleSleeperError(new Response(null, { status: 503 }))).toThrow( - 'SLEEPER_API_ERROR: Sleeper returned 503', + 'SLEEPER_API_ERROR: An unexpected error occurred with Sleeper. Please try again.', ); }); From 79654984b51b641c181810f45c573b9fe93cae78 Mon Sep 17 00:00:00 2001 From: Gerry Date: Wed, 11 Mar 2026 12:10:06 -0400 Subject: [PATCH 5/5] fix(security): pin CF account subdomain and gate localhost in SSRF allowlist 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 --- web/app/api/debug/test-mcp/route.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/web/app/api/debug/test-mcp/route.ts b/web/app/api/debug/test-mcp/route.ts index 4ae0d512..cb3b7559 100644 --- a/web/app/api/debug/test-mcp/route.ts +++ b/web/app/api/debug/test-mcp/route.ts @@ -8,11 +8,15 @@ import { auth } from '@clerk/nextjs/server'; const ALLOWED_MCP_HOST_PATTERNS = [ // Flaim production domains 'flaim.app', - // Localhost for development - 'localhost', - '127.0.0.1', + // Localhost for development (excluded in production) + ...(process.env.NODE_ENV === 'development' ? ['localhost', '127.0.0.1'] : []), ]; +/** + * Flaim CF account subdomain. Workers are .gerrygugger.workers.dev. + */ +const CF_ACCOUNT_SUBDOMAIN = 'gerrygugger'; + /** * Flaim-specific worker name prefixes allowed on workers.dev. * Prevents probing arbitrary Cloudflare Workers. @@ -43,13 +47,12 @@ function isAllowedUrl(urlString: string): boolean { ); if (matchesStatic) return true; - // Check workers.dev — only allow known Flaim worker prefixes - // Validate exact first segment to prevent bypass via fantasy-mcp.evil.workers.dev + // Check workers.dev — only allow known Flaim worker prefixes on our account + // Format: ..workers.dev (exactly 4 segments) 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' + return parts.length === 4 + && parts[1] === CF_ACCOUNT_SUBDOMAIN && ALLOWED_WORKER_PREFIXES.some(prefix => parts[0] === prefix); }