97 lines
3.4 KiB
TypeScript
97 lines
3.4 KiB
TypeScript
import { NextRequest } from 'next/server';
|
|
|
|
const MAX_HEADER_LENGTH = 1024;
|
|
|
|
// Simple regex for basic IPv4 and IPv6 format checking
|
|
const IPV4_REGEX = /^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/;
|
|
const IPV6_REGEX = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,7}:$|^(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}$|^(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}$|^(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}$|^[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}$|^:(?::[0-9a-fA-F]{1,4}){1,7}$|^::$|^::1$/;
|
|
|
|
/**
|
|
* Normalizes an IP string (handling IPv6 brackets, ports, and ::ffff: mapped IPv4).
|
|
*/
|
|
export function normalizeIp(rawIp: string): string {
|
|
let ip = rawIp.trim().toLowerCase();
|
|
|
|
// Strip brackets from IPv6 (e.g. [::1]:8080 or [::1])
|
|
if (ip.startsWith('[') && ip.includes(']')) {
|
|
ip = ip.substring(1, ip.indexOf(']'));
|
|
} else if (ip.startsWith('::ffff:')) {
|
|
// IPv4-mapped IPv6 (e.g., ::ffff:192.168.1.1)
|
|
const ipv4Part = ip.substring(7);
|
|
if (IPV4_REGEX.test(ipv4Part)) {
|
|
return ipv4Part;
|
|
}
|
|
} else if (ip.includes(':') && ip.includes('.')) {
|
|
// IPv4 with port (e.g. 192.168.1.1:3000)
|
|
const [host] = ip.split(':');
|
|
if (IPV4_REGEX.test(host)) {
|
|
return host;
|
|
}
|
|
}
|
|
|
|
// IPv6 loopback normalization
|
|
if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') {
|
|
return '127.0.0.1';
|
|
}
|
|
|
|
return ip;
|
|
}
|
|
|
|
let hasWarnedMissingProxy = false;
|
|
|
|
/**
|
|
* Resolves the client identity IP address.
|
|
* Strictly ignores untrusted X-Forwarded-For headers unless TRUST_PROXY=true is configured.
|
|
*/
|
|
export function resolveClientIp(req: NextRequest): string {
|
|
const isTrustProxy = process.env.TRUST_PROXY === 'true';
|
|
|
|
if (!isTrustProxy) {
|
|
if (req.ip) {
|
|
return normalizeIp(req.ip);
|
|
}
|
|
|
|
if (process.env.NODE_ENV === 'production' && !hasWarnedMissingProxy) {
|
|
hasWarnedMissingProxy = true;
|
|
console.warn(
|
|
'[SECURITY CONFIGURATION WARNING] TRUST_PROXY is not set to "true" and direct req.ip is unavailable. ' +
|
|
'In reverse-proxy environments (Nginx, Caddy, Cloudflare, AWS ALB), configure TRUST_PROXY=true to resolve client IPs correctly.'
|
|
);
|
|
}
|
|
|
|
// When proxy is not trusted, ignore spoofable headers from the client
|
|
return 'direct-client';
|
|
}
|
|
|
|
// Proxy is trusted: extract and validate header
|
|
const xForwardedFor = req.headers.get('x-forwarded-for');
|
|
const xRealIp = req.headers.get('x-real-ip');
|
|
const cfConnectingIp = req.headers.get('cf-connecting-ip');
|
|
|
|
const rawHeader = xForwardedFor || xRealIp || cfConnectingIp || req.ip;
|
|
|
|
if (!rawHeader) {
|
|
return 'unknown-client';
|
|
}
|
|
|
|
if (rawHeader.length > MAX_HEADER_LENGTH) {
|
|
// Oversized header attack guard
|
|
return 'malformed-oversized-ip';
|
|
}
|
|
|
|
// Handle multi-value proxy chains: "client, proxy1, proxy2"
|
|
// The first (leftmost) entry is the client-reported IP
|
|
const parts = rawHeader.split(',').map(s => s.trim()).filter(Boolean);
|
|
if (parts.length === 0) {
|
|
return 'unknown-client';
|
|
}
|
|
|
|
const clientCandidate = normalizeIp(parts[0]);
|
|
|
|
// Validate that the parsed string is a legitimate IPv4 or IPv6 address
|
|
if (IPV4_REGEX.test(clientCandidate) || IPV6_REGEX.test(clientCandidate)) {
|
|
return clientCandidate;
|
|
}
|
|
|
|
return 'malformed-client-ip';
|
|
}
|