Skip to content

Commit 6a71a81

Browse files
committed
fix(security): improve IP detection and distributed replay prevention
1. X-Forwarded-For spoofing protection: - New centralized getClientIp() utility - Checks platform-specific headers (CF-Connecting-IP, X-Vercel-Forwarded-For) - Validates IP format to reject obviously spoofed values - Documents proxy configuration requirements 2. Distributed replay prevention: - Move seenSignatures to Supabase (seen_signatures table) - Works across multiple server instances - Falls back to in-memory if Supabase unavailable - Add checkAndRecordSignatureAsync for critical paths Migration: supabase/migrations/20260207102400_create_seen_signatures.sql
1 parent 3c9b3df commit 6a71a81

File tree

5 files changed

+192
-28
lines changed

5 files changed

+192
-28
lines changed

src/app/api/auth/login/route.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
22
import { createClient } from '@supabase/supabase-js';
33
import { login } from '@/lib/auth/service';
44
import { checkRateLimitAsync } from '@/lib/web-wallet/rate-limit';
5+
import { getClientIp } from '@/lib/web-wallet/client-ip';
56
import { z } from 'zod';
67

78
/**
@@ -12,17 +13,6 @@ const loginSchema = z.object({
1213
password: z.string().min(1),
1314
});
1415

15-
/**
16-
* Get client IP from request headers
17-
*/
18-
function getClientIp(request: NextRequest): string {
19-
return (
20-
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
21-
request.headers.get('x-real-ip') ||
22-
'unknown'
23-
);
24-
}
25-
2616
/**
2717
* POST /api/auth/login
2818
* Authenticate a merchant

src/app/api/auth/register/route.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
22
import { createClient } from '@supabase/supabase-js';
33
import { register } from '@/lib/auth/service';
44
import { checkRateLimitAsync } from '@/lib/web-wallet/rate-limit';
5+
import { getClientIp } from '@/lib/web-wallet/client-ip';
56
import { z } from 'zod';
67

78
/**
@@ -13,17 +14,6 @@ const registerSchema = z.object({
1314
name: z.string().optional(),
1415
});
1516

16-
/**
17-
* Get client IP from request headers
18-
*/
19-
function getClientIp(request: NextRequest): string {
20-
return (
21-
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
22-
request.headers.get('x-real-ip') ||
23-
'unknown'
24-
);
25-
}
26-
2717
/**
2818
* POST /api/auth/register
2919
* Register a new merchant account

src/lib/web-wallet/client-ip.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Client IP Detection Utility
3+
*
4+
* Safely extracts client IP from request headers with proxy awareness.
5+
*
6+
* SECURITY NOTES:
7+
* - X-Forwarded-For can be spoofed if not behind a trusted proxy
8+
* - Railway/Vercel/Cloudflare overwrite this header with the real client IP
9+
* - For self-hosted deployments, ensure your reverse proxy (nginx, etc.)
10+
* is configured to set X-Forwarded-For correctly and strip any
11+
* client-provided values
12+
*
13+
* Header priority:
14+
* 1. CF-Connecting-IP (Cloudflare - most trusted)
15+
* 2. X-Real-IP (nginx default)
16+
* 3. X-Forwarded-For (first IP, set by proxy)
17+
* 4. 'unknown' fallback
18+
*/
19+
20+
import { NextRequest } from 'next/server';
21+
22+
/**
23+
* Extract client IP from request headers.
24+
* Uses platform-specific headers when available for better accuracy.
25+
*/
26+
export function getClientIp(request: NextRequest): string {
27+
// Cloudflare sets this reliably
28+
const cfIp = request.headers.get('cf-connecting-ip');
29+
if (cfIp && isValidIp(cfIp)) {
30+
return cfIp.trim();
31+
}
32+
33+
// Vercel sets this
34+
const vercelIp = request.headers.get('x-vercel-forwarded-for');
35+
if (vercelIp) {
36+
const ip = vercelIp.split(',')[0]?.trim();
37+
if (ip && isValidIp(ip)) {
38+
return ip;
39+
}
40+
}
41+
42+
// Railway/nginx typically use X-Real-IP
43+
const realIp = request.headers.get('x-real-ip');
44+
if (realIp && isValidIp(realIp)) {
45+
return realIp.trim();
46+
}
47+
48+
// Standard X-Forwarded-For (first IP is client when proxy configured correctly)
49+
const xff = request.headers.get('x-forwarded-for');
50+
if (xff) {
51+
const ip = xff.split(',')[0]?.trim();
52+
if (ip && isValidIp(ip)) {
53+
return ip;
54+
}
55+
}
56+
57+
return 'unknown';
58+
}
59+
60+
/**
61+
* Basic IP format validation to reject obviously spoofed values.
62+
* Accepts IPv4 and IPv6 formats.
63+
*/
64+
function isValidIp(ip: string): boolean {
65+
const trimmed = ip.trim();
66+
67+
// Reject empty or obviously invalid
68+
if (!trimmed || trimmed.length > 45) return false;
69+
70+
// IPv4: basic pattern check
71+
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
72+
if (ipv4Pattern.test(trimmed)) {
73+
// Validate each octet is 0-255
74+
const octets = trimmed.split('.').map(Number);
75+
return octets.every(o => o >= 0 && o <= 255);
76+
}
77+
78+
// IPv6: basic pattern check (simplified, allows common formats)
79+
const ipv6Pattern = /^[a-fA-F0-9:]+$/;
80+
if (ipv6Pattern.test(trimmed) && trimmed.includes(':')) {
81+
return true;
82+
}
83+
84+
// IPv4-mapped IPv6
85+
if (trimmed.startsWith('::ffff:')) {
86+
return isValidIp(trimmed.slice(7));
87+
}
88+
89+
return false;
90+
}
91+
92+
/**
93+
* Get a rate limit key that includes IP but is harder to spoof.
94+
* Combines IP with other request characteristics.
95+
*/
96+
export function getRateLimitKey(request: NextRequest, prefix: string): string {
97+
const ip = getClientIp(request);
98+
// Could add user-agent hash for additional entropy, but might cause
99+
// issues with legitimate users changing browsers
100+
return `${prefix}:${ip}`;
101+
}

src/lib/web-wallet/rate-limit.ts

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -282,12 +282,8 @@ export function resetRateLimits(): void {
282282
// ──────────────────────────────────────────────
283283

284284
/**
285-
* In-memory store of recently seen signature hashes.
286-
* Prevents the same signed request from being replayed.
287-
* Entries expire after the timestamp window (5 minutes).
288-
*
289-
* Note: For multi-instance deployments, consider moving to Supabase
290-
* similar to rate limiting above.
285+
* Distributed replay prevention using Supabase.
286+
* Falls back to in-memory for development or when Supabase is unavailable.
291287
*/
292288
const seenSignatures = new Map<string, number>();
293289

@@ -308,6 +304,34 @@ function ensureSigCleanupRunning() {
308304
if (sigCleanupInterval.unref) sigCleanupInterval.unref();
309305
}
310306

307+
/**
308+
* Check and record signature using Supabase (distributed)
309+
*/
310+
async function checkSignatureSupabase(signatureHash: string): Promise<boolean | null> {
311+
const sb = getSupabase();
312+
if (!sb) return null;
313+
314+
try {
315+
// Try to insert - will fail if exists (primary key constraint)
316+
const { error } = await sb
317+
.from('seen_signatures')
318+
.insert({ hash: signatureHash, seen_at: new Date().toISOString() });
319+
320+
if (error) {
321+
// Check if it's a duplicate key error
322+
if (error.code === '23505') {
323+
return false; // Replay detected
324+
}
325+
throw error;
326+
}
327+
328+
return true; // Fresh signature
329+
} catch (error) {
330+
console.error('[ReplayPrevention] Supabase error:', error);
331+
return null; // Fall back to in-memory
332+
}
333+
}
334+
311335
/**
312336
* Check if a signature has been seen before (replay prevention).
313337
* Returns true if the signature is fresh (not a replay).
@@ -318,6 +342,39 @@ function ensureSigCleanupRunning() {
318342
export function checkAndRecordSignature(signatureHash: string): boolean {
319343
ensureSigCleanupRunning();
320344

345+
// Check in-memory first (fast path)
346+
if (seenSignatures.has(signatureHash)) {
347+
return false;
348+
}
349+
350+
// Record in memory
351+
seenSignatures.set(signatureHash, Date.now());
352+
353+
// Fire-and-forget Supabase sync for distributed tracking
354+
if (useSupabase) {
355+
checkSignatureSupabase(signatureHash).catch(() => {});
356+
}
357+
358+
return true;
359+
}
360+
361+
/**
362+
* Async version that waits for Supabase (use for critical paths)
363+
*/
364+
export async function checkAndRecordSignatureAsync(signatureHash: string): Promise<boolean> {
365+
// Try Supabase first
366+
const sbResult = await checkSignatureSupabase(signatureHash);
367+
if (sbResult !== null) {
368+
// Also update in-memory cache
369+
if (sbResult) {
370+
seenSignatures.set(signatureHash, Date.now());
371+
}
372+
return sbResult;
373+
}
374+
375+
// Fallback to in-memory
376+
ensureSigCleanupRunning();
377+
321378
if (seenSignatures.has(signatureHash)) {
322379
return false;
323380
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
-- Signature replay prevention for distributed deployments
2+
-- Stores recently used signature hashes to prevent replay attacks
3+
4+
CREATE TABLE IF NOT EXISTS seen_signatures (
5+
hash TEXT PRIMARY KEY,
6+
seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
7+
);
8+
9+
-- Index for cleanup queries
10+
CREATE INDEX IF NOT EXISTS idx_seen_signatures_seen_at ON seen_signatures(seen_at);
11+
12+
-- RLS: Only service role can access (server-side only)
13+
ALTER TABLE seen_signatures ENABLE ROW LEVEL SECURITY;
14+
15+
CREATE POLICY "Service role only" ON seen_signatures
16+
FOR ALL USING (auth.role() = 'service_role');
17+
18+
-- Auto-cleanup function (remove signatures older than 5 minutes)
19+
CREATE OR REPLACE FUNCTION cleanup_seen_signatures()
20+
RETURNS void AS $$
21+
BEGIN
22+
DELETE FROM seen_signatures WHERE seen_at < NOW() - INTERVAL '5 minutes';
23+
END;
24+
$$ LANGUAGE plpgsql;
25+
26+
COMMENT ON TABLE seen_signatures IS 'Replay prevention - tracks used signatures within 5-minute window';

0 commit comments

Comments
 (0)