Skip to content
Merged
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
22 changes: 20 additions & 2 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 81 additions & 3 deletions src/app/api/crypto/public-keys/all/route.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 });
}
}
}
Loading