fix: harden auth, cron secrets, and provider flows (GNU-17) (#148)
- Replace === with crypto.timingSafeEqual in all 7 cron routes via shared lib/auth/cron.ts - Add in-memory rate limiting (60 req/min) and expires_at support to calendar feed - Add exponential backoff on MFA verify after 3 failed attempts - Add 60s cooldown on password reset requests - Validate bank callback auth code format before API call - Redact session IDs from bank sync and callback logs - Validate OAuth redirect_uris against allowlist (claude.ai, claude.com, localhost) - Remove excessive PII/debug console logging from login page Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
5c8db1ec60
commit
550cadcb06
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { POST } from '../route'
|
||||
|
||||
function createRequest(body: unknown) {
|
||||
return new Request('http://localhost/api/mcp-oauth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
describe('POST /api/mcp-oauth/register', () => {
|
||||
it('returns 400 for invalid JSON', async () => {
|
||||
const request = new Request('http://localhost/api/mcp-oauth/register', {
|
||||
method: 'POST',
|
||||
body: 'not json',
|
||||
})
|
||||
const response = await POST(request)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('accepts registration with valid claude.ai redirect_uris', async () => {
|
||||
const response = await POST(createRequest({
|
||||
client_name: 'Test Client',
|
||||
redirect_uris: ['https://claude.ai/api/oauth/callback'],
|
||||
}))
|
||||
expect(response.status).toBe(201)
|
||||
const body = await response.json()
|
||||
expect(body.client_id).toBeDefined()
|
||||
expect(body.redirect_uris).toEqual(['https://claude.ai/api/oauth/callback'])
|
||||
})
|
||||
|
||||
it('accepts registration with localhost redirect_uris', async () => {
|
||||
const response = await POST(createRequest({
|
||||
redirect_uris: ['http://localhost:3000/callback'],
|
||||
}))
|
||||
expect(response.status).toBe(201)
|
||||
})
|
||||
|
||||
it('accepts registration with 127.0.0.1 redirect_uris', async () => {
|
||||
const response = await POST(createRequest({
|
||||
redirect_uris: ['http://127.0.0.1:8080/callback'],
|
||||
}))
|
||||
expect(response.status).toBe(201)
|
||||
})
|
||||
|
||||
it('accepts registration with claude.com redirect_uris', async () => {
|
||||
const response = await POST(createRequest({
|
||||
redirect_uris: ['https://claude.com/api/oauth/callback'],
|
||||
}))
|
||||
expect(response.status).toBe(201)
|
||||
})
|
||||
|
||||
it('rejects registration with disallowed redirect_uris', async () => {
|
||||
const response = await POST(createRequest({
|
||||
redirect_uris: ['https://evil.com/callback'],
|
||||
}))
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json()
|
||||
expect(body.error).toBe('invalid_redirect_uri')
|
||||
})
|
||||
|
||||
it('rejects if any redirect_uri in array is invalid', async () => {
|
||||
const response = await POST(createRequest({
|
||||
redirect_uris: [
|
||||
'https://claude.ai/api/callback',
|
||||
'https://evil.com/steal',
|
||||
],
|
||||
}))
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('accepts registration with no redirect_uris', async () => {
|
||||
const response = await POST(createRequest({
|
||||
client_name: 'No URIs',
|
||||
}))
|
||||
expect(response.status).toBe(201)
|
||||
const body = await response.json()
|
||||
expect(body.redirect_uris).toEqual([])
|
||||
})
|
||||
|
||||
it('defaults client_name to MCP Client', async () => {
|
||||
const response = await POST(createRequest({}))
|
||||
expect(response.status).toBe(201)
|
||||
const body = await response.json()
|
||||
expect(body.client_name).toBe('MCP Client')
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,22 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import crypto from 'crypto'
|
||||
|
||||
// Allowed redirect URI patterns (must match CLAUDE.md allowlist)
|
||||
const ALLOWED_REDIRECT_PATTERNS = [
|
||||
/^https:\/\/claude\.ai\/api\//,
|
||||
/^https:\/\/claude\.com\/api\//,
|
||||
/^http:\/\/localhost(:\d+)?(\/|$)/,
|
||||
/^http:\/\/127\.0\.0\.1(:\d+)?(\/|$)/,
|
||||
]
|
||||
|
||||
function isRedirectUriAllowed(uri: string): boolean {
|
||||
return ALLOWED_REDIRECT_PATTERNS.some(pattern => pattern.test(uri))
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 7591 — Dynamic Client Registration.
|
||||
* Claude Desktop registers itself as an OAuth client before starting the auth flow.
|
||||
* We accept any registration and return a client_id.
|
||||
* Validates redirect_uris against the allowlist before accepting registration.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
let body: Record<string, unknown>
|
||||
@@ -14,12 +26,23 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'invalid_request' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate redirect_uris against allowlist
|
||||
const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : []
|
||||
for (const uri of redirectUris) {
|
||||
if (typeof uri !== 'string' || !isRedirectUriAllowed(uri)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'invalid_redirect_uri', error_description: `Redirect URI not allowed: ${uri}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const clientId = crypto.randomUUID()
|
||||
|
||||
return NextResponse.json({
|
||||
client_id: clientId,
|
||||
client_name: (body.client_name as string) || 'MCP Client',
|
||||
redirect_uris: body.redirect_uris || [],
|
||||
redirect_uris: redirectUris,
|
||||
grant_types: ['authorization_code'],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: 'none',
|
||||
|
||||
Reference in New Issue
Block a user