refactor(documents): improve SSRF protection and filename sanitization

Enhance SSRF mitigation in web-loader by expanding private IP detection for both
IPv4 and IPv6, including checks for multicast and broadcast addresses. Update
filename sanitization logic in UploadMenuDialog to collapse consecutive
disallowed characters and trim underscores, ensuring cleaner filenames. Remove
redundant userId check in import-url API route for streamlined authentication.
This commit is contained in:
Richard R 2026-06-10 12:59:14 -06:00
parent c2ff4320bf
commit 56305d5c1b
3 changed files with 218 additions and 124 deletions

View file

@ -10,9 +10,6 @@ export async function POST(req: NextRequest) {
// 1. Authenticate user request // 1. Authenticate user request
const ctxOrRes = await requireAuthContext(req); const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes; if (ctxOrRes instanceof Response) return ctxOrRes;
if (!ctxOrRes.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 2. Validate URL body parameter // 2. Validate URL body parameter
let body; let body;

View file

@ -121,15 +121,13 @@ export function UploadMenuDialog({
const scrapeResult = await importUrl(cleanUrl); const scrapeResult = await importUrl(cleanUrl);
setImportStep('converting'); setImportStep('converting');
// Simulated processing time for micro-interaction polish
await new Promise((resolve) => setTimeout(resolve, 800));
setImportStep('uploading'); setImportStep('uploading');
// Create virtual file from markdown content // Create virtual file from markdown content
const displayTitle = webTitle.trim() || scrapeResult.title || 'Imported Web Page'; const displayTitle = webTitle.trim() || scrapeResult.title || 'Imported Web Page';
const safeTitle = displayTitle const safeTitle = displayTitle
.replace(/[/\\?%*:|"<>\s]/g, '_') .replace(/[/\\?%*:|"<>\s]+/g, '_') // collapse runs of disallowed chars
.replace(/^_+|_+$/g, '') // trim leading/trailing underscores
.substring(0, 80); .substring(0, 80);
const filename = `${safeTitle}.md`; const filename = `${safeTitle}.md`;
const file = new File([scrapeResult.content], filename, { const file = new File([scrapeResult.content], filename, {

View file

@ -1,4 +1,6 @@
import dns from 'dns'; import dns from 'dns';
import http from 'http';
import https from 'https';
import { promisify } from 'util'; import { promisify } from 'util';
import { parseHTML } from 'linkedom'; import { parseHTML } from 'linkedom';
import { Readability } from '@mozilla/readability'; import { Readability } from '@mozilla/readability';
@ -6,46 +8,80 @@ import TurndownService from 'turndown';
const dnsLookup = promisify(dns.lookup); const dnsLookup = promisify(dns.lookup);
/** const MAX_BYTES = 2 * 1024 * 1024;
* Checks if an IP address is a private, loopback, or link-local address (SSRF mitigation). const TIMEOUT_MS = 6000;
*/ const MAX_REDIRECTS = 5;
function isPrivateIp(ip: string): boolean { const USER_AGENT =
// IPv4 checks 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
if (ip === '127.0.0.1' || ip === '0.0.0.0') return true;
const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); /**
if (ipv4Match) { * Checks if an IPv4 address falls in a private, loopback, link-local, multicast,
const [, o1, o2] = ipv4Match.map(Number); * or broadcast range. Anything that is not a clean dotted-quad is treated as
* unsafe (fail closed).
*/
function isPrivateIpv4(ip: string): boolean {
const m = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (!m) return true;
const octets = m.slice(1).map(Number);
if (octets.some((o) => o > 255)) return true; // malformed → unsafe
const [o1, o2] = octets;
if (o1 === 0) return true; // 0.0.0.0/8 ("this host")
if (o1 === 10) return true; // 10.0.0.0/8 if (o1 === 10) return true; // 10.0.0.0/8
if (o1 === 127) return true; // 127.0.0.0/8 loopback
if (o1 === 169 && o2 === 254) return true; // 169.254.0.0/16 link-local
if (o1 === 172 && o2 >= 16 && o2 <= 31) return true; // 172.16.0.0/12 if (o1 === 172 && o2 >= 16 && o2 <= 31) return true; // 172.16.0.0/12
if (o1 === 192 && o2 === 168) return true; // 192.168.0.0/16 if (o1 === 192 && o2 === 168) return true; // 192.168.0.0/16
if (o1 === 169 && o2 === 254) return true; // 169.254.0.0/16 if (o1 >= 224 && o1 <= 239) return true; // 224.0.0.0/4 multicast
} if (ip === '255.255.255.255') return true; // limited broadcast
// IPv6 checks
const ipLower = ip.toLowerCase();
if (
ipLower === '::1' ||
ipLower === '::' ||
ipLower === '0:0:0:0:0:0:0:1'
) {
return true;
}
if (
ipLower.startsWith('fe80:') ||
ipLower.startsWith('fc00:') ||
ipLower.startsWith('fd00:')
) {
return true;
}
return false; return false;
} }
/** /**
* Resolves the URL domain and validates that it does not point to a local/private network. * Checks if an IP address (v4 or v6) is a private, loopback, link-local,
* multicast, or otherwise non-routable address (SSRF mitigation).
*/ */
async function validateUrlForSsrf(urlString: string): Promise<string> { function isPrivateIp(ip: string): boolean {
let addr = ip.trim().toLowerCase();
// Strip IPv6 brackets and zone id (e.g. [fe80::1%eth0]).
if (addr.startsWith('[') && addr.endsWith(']')) addr = addr.slice(1, -1);
const zoneIdx = addr.indexOf('%');
if (zoneIdx !== -1) addr = addr.slice(0, zoneIdx);
// Plain IPv4.
if (!addr.includes(':')) return isPrivateIpv4(addr);
// IPv6 from here.
if (addr === '::1' || addr === '::') return true; // loopback / unspecified
// IPv4-mapped (::ffff:a.b.c.d) / -compatible (::a.b.c.d) IPv6: evaluate the
// embedded IPv4 against the v4 ranges. Any address in the mapped range we
// cannot cleanly read as a dotted-quad (e.g. the hex form ::ffff:7f00:1) is
// blocked outright — fail closed. (cf. CVE-2026-47684)
if (addr.startsWith('::ffff:') || /^::\d+\.\d+\.\d+\.\d+$/.test(addr)) {
const m = addr.match(/^::(?:ffff:)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
return m ? isPrivateIpv4(m[1]) : true;
}
if (/^fe[89ab]/.test(addr)) return true; // fe80::/10 link-local
if (/^fec/.test(addr)) return true; // fec0::/10 (deprecated site-local)
if (/^f[cd]/.test(addr)) return true; // fc00::/7 unique-local
if (/^ff/.test(addr)) return true; // ff00::/8 multicast
return false;
}
interface ValidatedTarget {
url: URL;
/** The DNS-resolved IP address the connection is pinned to. */
address: string;
}
/**
* Resolves the URL domain and validates that it does not point to a local/private
* network. Returns the parsed URL together with the resolved IP address so the
* caller can pin the connection to it, closing the DNS-rebinding window between
* validation and connect.
*/
async function validateUrlForSsrf(urlString: string): Promise<ValidatedTarget> {
let url: URL; let url: URL;
try { try {
url = new URL(urlString); url = new URL(urlString);
@ -63,100 +99,165 @@ async function validateUrlForSsrf(urlString: string): Promise<string> {
throw new Error('Access to localhost is forbidden'); throw new Error('Access to localhost is forbidden');
} }
let results: { address: string; family: number }[];
try { try {
const lookupResult = await dnsLookup(hostname); results = await dnsLookup(hostname, { all: true });
if (isPrivateIp(lookupResult.address)) { } catch {
throw new Error(`Access to private network address is forbidden`); // Fail closed: do not silently continue and let a later re-resolution decide.
} throw new Error('Could not resolve host address');
} catch (err) {
if (err instanceof Error && err.message.includes('forbidden')) {
throw err;
}
// If lookup fails entirely, let the fetch attempt fail or resolve naturally
} }
return urlString; if (results.length === 0) {
throw new Error('Could not resolve host address');
}
// Validate every resolved address; reject if any maps to a private network.
for (const r of results) {
if (isPrivateIp(r.address)) {
throw new Error('Access to private network address is forbidden');
}
}
return { url, address: results[0].address };
} }
/** /**
* Fetches a URL with a size limit (default 2MB) and connection timeout (default 6s). * Performs a single GET request pinned to the already-validated IP address.
* Resolves with either the response body or a redirect location to follow.
*/ */
async function fetchWithLimit( function requestPinned(
urlStr: string, target: ValidatedTarget,
maxBytes: number = 2 * 1024 * 1024, maxBytes: number,
timeoutMs: number = 6000 timeoutMs: number
): Promise<string> { ): Promise<{ body?: string; redirectLocation?: string }> {
const controller = new AbortController(); const { url, address } = target;
const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const isHttps = url.protocol === 'https:';
const lib = isHttps ? https : http;
const port = url.port ? Number(url.port) : isHttps ? 443 : 80;
try { const options: https.RequestOptions = {
const res = await fetch(urlStr, { host: address, // connect to the validated IP, not a freshly-resolved one
signal: controller.signal, port,
path: `${url.pathname}${url.search}`,
method: 'GET',
headers: { headers: {
'User-Agent': // Preserve virtual hosting (host:port) so the origin serves the right vhost.
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', Host: url.host,
'User-Agent': USER_AGENT,
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
}, },
}); timeout: timeoutMs,
};
if (!res.ok) { if (isHttps) {
throw new Error(`Failed to fetch webpage (status ${res.status})`); // Validate the TLS certificate against the original hostname, not the IP.
options.servername = url.hostname;
} }
const contentType = res.headers.get('content-type') || ''; return new Promise((resolve, reject) => {
let settled = false;
const ok = (v: { body?: string; redirectLocation?: string }) => {
if (settled) return;
settled = true;
clearTimeout(hardTimer);
resolve(v);
};
const fail = (e: Error) => {
if (settled) return;
settled = true;
clearTimeout(hardTimer);
reject(e);
};
const req = lib.request(options, (res) => {
const status = res.statusCode ?? 0;
if (status >= 300 && status < 400 && res.headers.location) {
res.resume(); // drain so the socket can be reused/closed
ok({ redirectLocation: res.headers.location });
return;
}
if (status < 200 || status >= 300) {
res.resume();
fail(new Error(`Failed to fetch webpage (status ${status})`));
return;
}
const contentType = (res.headers['content-type'] || '').toString();
const isHtml = const isHtml =
contentType.includes('text/html') || contentType.includes('text/html') ||
contentType.includes('application/xhtml+xml'); contentType.includes('application/xhtml+xml');
const isText = contentType.includes('text/plain'); const isText = contentType.includes('text/plain');
if (!isHtml && !isText) { if (!isHtml && !isText) {
throw new Error('Unsupported content type. Only web pages or text files are supported.'); res.resume();
fail(
new Error('Unsupported content type. Only web pages or text files are supported.')
);
return;
} }
const contentLength = res.headers.get('content-length'); const contentLength = res.headers['content-length'];
if (contentLength && parseInt(contentLength, 10) > maxBytes) { if (contentLength && parseInt(contentLength.toString(), 10) > maxBytes) {
throw new Error('Web page size exceeds the maximum allowed limit of 2MB.'); res.resume();
fail(new Error('Web page size exceeds the maximum allowed limit of 2MB.'));
return;
} }
// Read response body chunks to verify size limit dynamically in case Content-Length header is missing/spoofed // Stream and enforce the size limit dynamically (header may be absent/spoofed).
const reader = res.body?.getReader(); let received = 0;
if (!reader) { const chunks: Buffer[] = [];
// Fallback for environments lacking full stream reader support res.on('data', (chunk: Buffer) => {
const text = await res.text(); received += chunk.length;
if (Buffer.byteLength(text) > maxBytes) { if (received > maxBytes) {
throw new Error('Web page size exceeds the maximum allowed limit of 2MB.'); req.destroy();
fail(new Error('Web page size exceeds the maximum allowed limit of 2MB.'));
return;
} }
return text; chunks.push(chunk);
});
res.on('end', () => ok({ body: Buffer.concat(chunks).toString('utf-8') }));
res.on('error', fail);
});
// Hard deadline covering the whole request (connect + transfer), in addition
// to the socket idle timeout, to bound slow-read/slowloris responses.
const hardTimer = setTimeout(() => {
req.destroy(new Error('Request timed out'));
}, timeoutMs);
req.on('timeout', () => {
req.destroy(new Error('Request timed out'));
});
req.on('error', fail);
req.end();
});
} }
let bytesReceived = 0; /**
const chunks: Uint8Array[] = []; * Validates and fetches a URL with a size limit (default 2MB) and connection
* timeout (default 6s). Each redirect hop is independently re-validated and
* pinned to its resolved IP to prevent SSRF via redirects or DNS rebinding.
*/
async function fetchWithLimit(
urlStr: string,
maxBytes: number = MAX_BYTES,
timeoutMs: number = TIMEOUT_MS
): Promise<string> {
let currentUrl = urlStr;
while (true) { for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
const { done, value } = await reader.read(); const target = await validateUrlForSsrf(currentUrl);
if (done) break; const result = await requestPinned(target, maxBytes, timeoutMs);
if (value) { if (result.redirectLocation) {
bytesReceived += value.length; currentUrl = new URL(result.redirectLocation, target.url).href;
if (bytesReceived > maxBytes) { continue;
controller.abort();
throw new Error('Web page size exceeds the maximum allowed limit of 2MB.');
}
chunks.push(value);
}
} }
const merged = new Uint8Array(bytesReceived); return result.body ?? '';
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.length;
} }
return new TextDecoder().decode(merged); throw new Error('Too many redirects while fetching the webpage.');
} finally {
clearTimeout(timeoutId);
}
} }
/** /**
@ -165,10 +266,8 @@ async function fetchWithLimit(
export async function fetchAndParseUrl( export async function fetchAndParseUrl(
urlStr: string urlStr: string
): Promise<{ title: string; content: string }> { ): Promise<{ title: string; content: string }> {
// 1. SSRF prevention check // 1. Fetch HTML content with limit (SSRF validation + IP pinning happens
await validateUrlForSsrf(urlStr); // inside fetchWithLimit for the initial URL and every redirect hop).
// 2. Fetch HTML content with limit
const html = await fetchWithLimit(urlStr); const html = await fetchWithLimit(urlStr);
// 3. Parse HTML string to a virtual DOM document // 3. Parse HTML string to a virtual DOM document