fix: harden proxy auth and SSRF defenses

This commit is contained in:
Linus Rath
2026-03-31 17:47:09 +02:00
parent b3d4c9241c
commit aa40c8be26
17 changed files with 462 additions and 175 deletions
+54 -12
View File
@@ -1,9 +1,39 @@
import { lookup } from 'node:dns/promises';
import { BlockList, isIP } from 'node:net';
import { NextRequest, NextResponse } from 'next/server';
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
const FETCH_TIMEOUT_MS = 15000;
function isValidExternalUrl(urlString: string): boolean {
const blockedAddressRanges = new BlockList();
blockedAddressRanges.addAddress('0.0.0.0');
blockedAddressRanges.addAddress('127.0.0.1');
blockedAddressRanges.addSubnet('10.0.0.0', 8);
blockedAddressRanges.addSubnet('172.16.0.0', 12);
blockedAddressRanges.addSubnet('192.168.0.0', 16);
blockedAddressRanges.addSubnet('169.254.0.0', 16);
blockedAddressRanges.addAddress('::', 'ipv6');
blockedAddressRanges.addAddress('::1', 'ipv6');
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
function normalizeHostname(hostname: string): string {
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
}
function isBlockedIpAddress(hostname: string): boolean {
const normalized = normalizeHostname(hostname);
const family = isIP(normalized);
if (family === 4) {
return blockedAddressRanges.check(normalized, 'ipv4');
}
if (family === 6) {
return blockedAddressRanges.check(normalized, 'ipv6');
}
return false;
}
async function isValidExternalUrl(urlString: string): Promise<boolean> {
let url: URL;
try {
url = new URL(urlString);
@@ -15,21 +45,16 @@ function isValidExternalUrl(urlString: string): boolean {
return false;
}
const hostname = url.hostname.toLowerCase();
const hostname = normalizeHostname(url.hostname);
// Block private/internal hostnames
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.endsWith('.localhost') ||
hostname.endsWith('.local') ||
hostname.endsWith('.internal') ||
hostname.endsWith('.arpa') ||
hostname.startsWith('10.') ||
hostname.startsWith('192.168.') ||
hostname.startsWith('169.254.') ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
hostname.endsWith('.localdomain')
) {
return false;
}
@@ -39,7 +64,24 @@ function isValidExternalUrl(urlString: string): boolean {
return false;
}
return true;
if (isBlockedIpAddress(hostname)) {
return false;
}
if (isIP(hostname)) {
return true;
}
try {
const records = await lookup(hostname, { all: true, verbatim: true });
if (records.length === 0) {
return false;
}
return records.every((record) => !isBlockedIpAddress(record.address));
} catch {
return false;
}
}
export async function POST(request: NextRequest) {
@@ -56,7 +98,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
if (!isValidExternalUrl(url)) {
if (!(await isValidExternalUrl(url))) {
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
}
@@ -69,7 +111,7 @@ export async function POST(request: NextRequest) {
let response: Response | undefined;
for (let i = 0; i <= MAX_REDIRECTS; i++) {
if (!isValidExternalUrl(currentUrl)) {
if (!(await isValidExternalUrl(currentUrl))) {
clearTimeout(timeout);
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
}