Skip to content

Commit f14c446

Browse files
author
Illyria Agent
committed
fix(security): trust-proxy IP for WS rate limit + require auth on public-keys/all
1 parent ee0812e commit f14c446

2 files changed

Lines changed: 101 additions & 5 deletions

File tree

‎server.js‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,26 @@ const WS_MAX_CONNECTIONS_PER_IP = 10;
3030
const WS_RATE_WINDOW_MS = 60 * 1000; // 1 minute
3131

3232
function getClientIp(request) {
33-
return request.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
34-
request.socket?.remoteAddress || 'unknown';
33+
// SECURITY: never trust the leftmost X-Forwarded-For entry — it is client
34+
// supplied and lets an attacker forge a new "IP" per connection to bypass the
35+
// per-IP WebSocket rate limit below. Mirror the trusted-proxy logic in
36+
// src/lib/server/rate-limiter.js: only the rightmost `TRUSTED_PROXY_COUNT`
37+
// hops are appended by infrastructure we control and cannot be spoofed.
38+
const trustedProxyCount = parseInt(process.env.TRUSTED_PROXY_COUNT ?? '0', 10);
39+
40+
if (trustedProxyCount > 0) {
41+
const xff = request.headers['x-forwarded-for'];
42+
if (xff) {
43+
const parts = xff.split(',').map((s) => s.trim()).filter(Boolean);
44+
if (parts.length >= trustedProxyCount) {
45+
return parts[parts.length - trustedProxyCount];
46+
}
47+
}
48+
}
49+
50+
// Default / direct-internet deployment: rely on X-Real-IP (set by a trusted
51+
// proxy) then the raw TCP peer address, which cannot be spoofed via headers.
52+
return request.headers['x-real-ip'] || request.socket?.remoteAddress || 'unknown';
3553
}
3654

3755
// Clean up stale entries every 5 minutes

‎src/app/api/crypto/public-keys/all/route.js‎

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,86 @@
11
import { NextResponse } from 'next/server';
22
import { createClient } from '@supabase/supabase-js';
3-
export async function GET(request, { params } = {}) {
3+
4+
// Regular (anon) client used ONLY to validate the caller's JWT. Never use the
5+
// service-role key for auth checks.
6+
const supabaseClient = createClient(
7+
process.env.NEXT_PUBLIC_SUPABASE_URL,
8+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
9+
);
10+
11+
/**
12+
* Authenticate user from request cookies. Mirrors the check in ../route.js so
13+
* this admin-scoped endpoint cannot be called anonymously.
14+
* @param {Request} request
15+
* @returns {Promise<{user?: any, error?: string}>}
16+
*/
17+
async function authenticateUser(request) {
18+
try {
19+
const cookieHeader = request.headers.get('cookie');
20+
if (!cookieHeader) {
21+
return { error: 'No cookies found' };
22+
}
23+
24+
const cookies = Object.fromEntries(
25+
cookieHeader.split('; ').map((cookie) => {
26+
const [name, value] = cookie.split('=');
27+
return [name, decodeURIComponent(value)];
28+
})
29+
);
30+
31+
let accessToken = null;
32+
33+
// Supabase-specific auth cookie first
34+
if (cookies['sb-xydzwxwsbgmznthiiscl-auth-token']) {
35+
try {
36+
let tokenData = cookies['sb-xydzwxwsbgmznthiiscl-auth-token'];
37+
if (tokenData.startsWith('base64-')) {
38+
tokenData = Buffer.from(tokenData.substring(7), 'base64').toString('utf-8');
39+
}
40+
const parsed = JSON.parse(tokenData);
41+
accessToken = parsed.access_token;
42+
} catch (error) {
43+
console.log('Failed to parse Supabase auth cookie:', error.message);
44+
}
45+
}
46+
47+
// Fallback to a session cookie that looks like a JWT
48+
if (!accessToken && cookies.session) {
49+
const sessionToken = cookies.session;
50+
if (sessionToken.split('.').length === 3) {
51+
accessToken = sessionToken;
52+
}
53+
}
54+
55+
if (!accessToken) {
56+
return { error: 'No access token found' };
57+
}
58+
59+
const { data: { user }, error } = await supabaseClient.auth.getUser(accessToken);
60+
if (error || !user) {
61+
return { error: `Invalid token: ${error?.message}` };
62+
}
63+
64+
return { user };
65+
} catch (error) {
66+
return { error: `Authentication error: ${error.message}` };
67+
}
68+
}
69+
70+
export async function GET(request) {
471
try {
5-
// Create service role client for admin access to all user data
72+
// SECURITY: require an authenticated session. This endpoint uses the
73+
// service-role key (which bypasses RLS) to read every user's public key,
74+
// so without this gate any anonymous caller could enumerate the entire
75+
// user base (all user_ids). Public keys are non-secret, but the full
76+
// membership list is. The legitimate caller (key-sync-service) is always
77+
// an authenticated client.
78+
const { user, error: authError } = await authenticateUser(request);
79+
if (authError || !user) {
80+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
81+
}
82+
83+
// Service role client for admin access to all user data
684
const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY, {
785
auth: {
886
autoRefreshToken: false,
@@ -34,4 +112,4 @@ export async function GET(request, { params } = {}) {
34112
console.error('Error in public-keys/all endpoint:', error);
35113
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
36114
}
37-
}
115+
}

0 commit comments

Comments
 (0)