Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 0 additions & 24 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 10 additions & 4 deletions web/app/api/auth/espn/credentials/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
},
});

Expand Down Expand Up @@ -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,
Expand Down
12 changes: 9 additions & 3 deletions web/app/api/connect/sleeper/discover/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
Expand Down
9 changes: 6 additions & 3 deletions web/app/api/connect/sleeper/leagues/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
},
});

Expand Down
17 changes: 15 additions & 2 deletions web/app/api/connect/yahoo/authorize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
7 changes: 5 additions & 2 deletions web/app/api/connect/yahoo/discover/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
},
});

Expand Down
9 changes: 6 additions & 3 deletions web/app/api/connect/yahoo/leagues/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
},
});

Expand Down
27 changes: 23 additions & 4 deletions web/app/api/debug/test-mcp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand All @@ -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}.`)
);
}
Comment on lines +47 to +51

Choose a reason for hiding this comment

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

critical

The current SSRF protection for *.workers.dev domains is insufficient. The check url.hostname.startsWith(${prefix}.) can be bypassed. For example, a hostname like fantasy-mcp.evil.domain.workers.dev would be allowed. The validation should be stricter to ensure the hostname matches the expected [prefix].[account].workers.dev format.

Suggested change
if (url.hostname.endsWith('.workers.dev')) {
return ALLOWED_WORKER_PREFIXES.some(prefix =>
url.hostname.startsWith(`${prefix}.`)
);
}
return ALLOWED_WORKER_PREFIXES.some(prefix => {
// This ensures the hostname is in the format `[prefix].[account].workers.dev`
// and prevents bypasses like `[prefix].evil.domain.workers.dev`.
const parts = url.hostname.split('.');
return parts.length === 4 && parts[0] === prefix && parts[2] === 'workers' && parts[3] === 'dev';
});


return false;
} catch {
return false;
}
Expand Down
Loading
Loading