diff --git a/server.js b/server.js index 4cdaf0f8..ecd4ffeb 100644 --- a/server.js +++ b/server.js @@ -30,8 +30,26 @@ const WS_MAX_CONNECTIONS_PER_IP = 10; const WS_RATE_WINDOW_MS = 60 * 1000; // 1 minute function getClientIp(request) { - return request.headers['x-forwarded-for']?.split(',')[0]?.trim() || - request.socket?.remoteAddress || 'unknown'; + // SECURITY: never trust the leftmost X-Forwarded-For entry — it is client + // supplied and lets an attacker forge a new "IP" per connection to bypass the + // per-IP WebSocket rate limit below. Mirror the trusted-proxy logic in + // src/lib/server/rate-limiter.js: only the rightmost `TRUSTED_PROXY_COUNT` + // hops are appended by infrastructure we control and cannot be spoofed. + const trustedProxyCount = parseInt(process.env.TRUSTED_PROXY_COUNT ?? '0', 10); + + if (trustedProxyCount > 0) { + const xff = request.headers['x-forwarded-for']; + if (xff) { + const parts = xff.split(',').map((s) => s.trim()).filter(Boolean); + if (parts.length >= trustedProxyCount) { + return parts[parts.length - trustedProxyCount]; + } + } + } + + // Default / direct-internet deployment: rely on X-Real-IP (set by a trusted + // proxy) then the raw TCP peer address, which cannot be spoofed via headers. + return request.headers['x-real-ip'] || request.socket?.remoteAddress || 'unknown'; } // Clean up stale entries every 5 minutes diff --git a/src/app/api/crypto/public-keys/all/route.js b/src/app/api/crypto/public-keys/all/route.js index a9181709..4211b637 100644 --- a/src/app/api/crypto/public-keys/all/route.js +++ b/src/app/api/crypto/public-keys/all/route.js @@ -1,8 +1,86 @@ import { NextResponse } from 'next/server'; import { createClient } from '@supabase/supabase-js'; -export async function GET(request, { params } = {}) { + +// Regular (anon) client used ONLY to validate the caller's JWT. Never use the +// service-role key for auth checks. +const supabaseClient = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY +); + +/** + * Authenticate user from request cookies. Mirrors the check in ../route.js so + * this admin-scoped endpoint cannot be called anonymously. + * @param {Request} request + * @returns {Promise<{user?: any, error?: string}>} + */ +async function authenticateUser(request) { + try { + const cookieHeader = request.headers.get('cookie'); + if (!cookieHeader) { + return { error: 'No cookies found' }; + } + + const cookies = Object.fromEntries( + cookieHeader.split('; ').map((cookie) => { + const [name, value] = cookie.split('='); + return [name, decodeURIComponent(value)]; + }) + ); + + let accessToken = null; + + // Supabase-specific auth cookie first + if (cookies['sb-xydzwxwsbgmznthiiscl-auth-token']) { + try { + let tokenData = cookies['sb-xydzwxwsbgmznthiiscl-auth-token']; + if (tokenData.startsWith('base64-')) { + tokenData = Buffer.from(tokenData.substring(7), 'base64').toString('utf-8'); + } + const parsed = JSON.parse(tokenData); + accessToken = parsed.access_token; + } catch (error) { + console.log('Failed to parse Supabase auth cookie:', error.message); + } + } + + // Fallback to a session cookie that looks like a JWT + if (!accessToken && cookies.session) { + const sessionToken = cookies.session; + if (sessionToken.split('.').length === 3) { + accessToken = sessionToken; + } + } + + if (!accessToken) { + return { error: 'No access token found' }; + } + + const { data: { user }, error } = await supabaseClient.auth.getUser(accessToken); + if (error || !user) { + return { error: `Invalid token: ${error?.message}` }; + } + + return { user }; + } catch (error) { + return { error: `Authentication error: ${error.message}` }; + } +} + +export async function GET(request) { try { - // Create service role client for admin access to all user data + // SECURITY: require an authenticated session. This endpoint uses the + // service-role key (which bypasses RLS) to read every user's public key, + // so without this gate any anonymous caller could enumerate the entire + // user base (all user_ids). Public keys are non-secret, but the full + // membership list is. The legitimate caller (key-sync-service) is always + // an authenticated client. + const { user, error: authError } = await authenticateUser(request); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Service role client for admin access to all user data const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY, { auth: { autoRefreshToken: false, @@ -34,4 +112,4 @@ export async function GET(request, { params } = {}) { console.error('Error in public-keys/all endpoint:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } -} \ No newline at end of file +}