← back to Norma
security(P1): add shared SSRF guard blocking private/reserved/loopback hosts on user-URL fetches
10f2f975cfab0e10e7230cdba90a9ae87ae43ea7 · 2026-07-12 11:27:39 -0700 · Steve Abrams
Wire lib/ssrf-guard.assertPublicUrl() into /api/nonprofit/enrich and
/api/email-analyzer/fetch-url before the outbound fetch. Blocks non-http(s)
protocols, localhost, RFC1918 (10/172.16-31/192.168), loopback (127/::1),
link-local + cloud-metadata (169.254.0.0/16 incl. 169.254.169.254), and other
reserved ranges; resolves DNS so hostnames pointing at private IPs are also
blocked. Verified: private/reserved/file:// -> 400, public URL -> 200.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M app/api/email-analyzer/fetch-url/route.tsM app/api/nonprofit/enrich/route.tsA lib/ssrf-guard.ts
Diff
commit 10f2f975cfab0e10e7230cdba90a9ae87ae43ea7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jul 12 11:27:39 2026 -0700
security(P1): add shared SSRF guard blocking private/reserved/loopback hosts on user-URL fetches
Wire lib/ssrf-guard.assertPublicUrl() into /api/nonprofit/enrich and
/api/email-analyzer/fetch-url before the outbound fetch. Blocks non-http(s)
protocols, localhost, RFC1918 (10/172.16-31/192.168), loopback (127/::1),
link-local + cloud-metadata (169.254.0.0/16 incl. 169.254.169.254), and other
reserved ranges; resolves DNS so hostnames pointing at private IPs are also
blocked. Verified: private/reserved/file:// -> 400, public URL -> 200.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
app/api/email-analyzer/fetch-url/route.ts | 19 ++--
app/api/nonprofit/enrich/route.ts | 24 ++---
lib/ssrf-guard.ts | 147 ++++++++++++++++++++++++++++++
3 files changed, 163 insertions(+), 27 deletions(-)
diff --git a/app/api/email-analyzer/fetch-url/route.ts b/app/api/email-analyzer/fetch-url/route.ts
index 8a347bb..495b98e 100644
--- a/app/api/email-analyzer/fetch-url/route.ts
+++ b/app/api/email-analyzer/fetch-url/route.ts
@@ -6,6 +6,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/require-role';
import { checkRateLimit } from '@/lib/rate-limit';
+import { assertPublicUrl } from '@/lib/ssrf-guard';
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
const GEMINI_URL =
@@ -199,20 +200,18 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'url is required' }, { status: 400 });
}
- // Validate URL format
- try {
- const parsed = new URL(url);
- if (!['http:', 'https:'].includes(parsed.protocol)) {
- return NextResponse.json({ error: 'Only HTTP/HTTPS URLs are supported' }, { status: 400 });
- }
- } catch {
- return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 });
+ // SSRF guard: validate protocol + block private/reserved/loopback hosts
+ // (also resolves DNS so a hostname pointing at a private IP is blocked).
+ const guard = await assertPublicUrl(url);
+ if (!guard.ok) {
+ return NextResponse.json({ error: guard.reason }, { status: 400 });
}
+ const safeUrl = guard.url!;
// Fetch the page with a 10-second timeout
let html: string;
try {
- const res = await fetch(url, {
+ const res = await fetch(safeUrl, {
headers: {
'User-Agent':
'Mozilla/5.0 (compatible; NormaBot/1.0; +https://norma.app)',
@@ -260,7 +259,7 @@ export async function POST(request: NextRequest) {
const publishedDate = extractPublishedDate(html);
const rawContent = extractArticleContent(html);
const content = rawContent.substring(0, 2000);
- const source = extractDomain(url);
+ const source = extractDomain(safeUrl);
// Extract key points via Gemini
const keyPoints = await extractKeyPoints(rawContent, title);
diff --git a/app/api/nonprofit/enrich/route.ts b/app/api/nonprofit/enrich/route.ts
index 4704c7a..36c62ff 100644
--- a/app/api/nonprofit/enrich/route.ts
+++ b/app/api/nonprofit/enrich/route.ts
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
+import { assertPublicUrl } from '@/lib/ssrf-guard';
// --------------------------------------------------------------------------
// Types
@@ -199,24 +200,13 @@ export async function POST(request: NextRequest) {
normalizedUrl = `https://${normalizedUrl}`;
}
- // Validate URL format
- let parsedUrl: URL;
- try {
- parsedUrl = new URL(normalizedUrl);
- } catch {
- return NextResponse.json(
- { error: 'Invalid URL format' },
- { status: 400 },
- );
- }
-
- // Reject non-http(s) protocols
- if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
- return NextResponse.json(
- { error: 'Only HTTP and HTTPS URLs are supported' },
- { status: 400 },
- );
+ // SSRF guard: validate protocol + block private/reserved/loopback hosts
+ // (also resolves DNS so a hostname pointing at a private IP is blocked).
+ const guard = await assertPublicUrl(normalizedUrl);
+ if (!guard.ok) {
+ return NextResponse.json({ error: guard.reason }, { status: 400 });
}
+ normalizedUrl = guard.url!;
// Fetch the page with a timeout
const controller = new AbortController();
diff --git a/lib/ssrf-guard.ts b/lib/ssrf-guard.ts
new file mode 100644
index 0000000..d401e26
--- /dev/null
+++ b/lib/ssrf-guard.ts
@@ -0,0 +1,147 @@
+/**
+ * Shared SSRF (Server-Side Request Forgery) guard.
+ *
+ * Any route that fetches a user-supplied URL server-side MUST run the URL
+ * through `assertPublicUrl()` (or `safeFetch()`) BEFORE issuing the fetch.
+ *
+ * It blocks:
+ * - non-http(s) protocols (file:, gopher:, data:, ftp:, ...)
+ * - localhost / loopback (127.0.0.0/8, ::1, "localhost")
+ * - RFC1918 private ranges (10/8, 172.16-31/12, 192.168/16)
+ * - link-local / metadata (169.254.0.0/16 incl. 169.254.169.254, fe80::/10)
+ * - other reserved/special ranges (0.0.0.0/8, 100.64/10 CGNAT, 192.0.0/24,
+ * 192.0.2/24, 198.18/15, 198.51.100/24, 203.0.113/24, 224/4 multicast,
+ * 240/4, ::/128, unique-local fc00::/7, IPv4-mapped IPv6)
+ * - cloud metadata hostnames (metadata.google.internal, etc.)
+ *
+ * DNS resolution is performed so a hostname that resolves to a private/reserved
+ * IP is also blocked (not just literal-IP URLs).
+ */
+import { lookup } from 'dns/promises';
+import net from 'net';
+
+export interface SsrfCheck {
+ ok: boolean;
+ /** Human-readable reason when ok === false */
+ reason?: string;
+ /** The normalized, validated URL when ok === true */
+ url?: string;
+}
+
+const BLOCKED_HOSTNAMES = new Set<string>([
+ 'localhost',
+ 'metadata.google.internal',
+ 'metadata',
+]);
+
+/** IPv4 dotted-quad → 32-bit unsigned int. */
+function ipv4ToLong(ip: string): number {
+ const parts = ip.split('.').map(Number);
+ return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
+}
+
+function inRange(ipLong: number, cidrBase: string, bits: number): boolean {
+ const baseLong = ipv4ToLong(cidrBase);
+ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
+ return (ipLong & mask) === (baseLong & mask);
+}
+
+/** True if the IPv4 address is private, loopback, link-local, or otherwise reserved. */
+function isPrivateIpv4(ip: string): boolean {
+ const long = ipv4ToLong(ip);
+ const blockedCidrs: [string, number][] = [
+ ['0.0.0.0', 8], // "this" network
+ ['10.0.0.0', 8], // RFC1918
+ ['100.64.0.0', 10], // CGNAT
+ ['127.0.0.0', 8], // loopback
+ ['169.254.0.0', 16], // link-local (incl. 169.254.169.254 metadata)
+ ['172.16.0.0', 12], // RFC1918
+ ['192.0.0.0', 24], // IETF protocol assignments
+ ['192.0.2.0', 24], // TEST-NET-1
+ ['192.168.0.0', 16], // RFC1918
+ ['198.18.0.0', 15], // benchmarking
+ ['198.51.100.0', 24], // TEST-NET-2
+ ['203.0.113.0', 24], // TEST-NET-3
+ ['224.0.0.0', 4], // multicast
+ ['240.0.0.0', 4], // reserved / broadcast
+ ];
+ return blockedCidrs.some(([base, bits]) => inRange(long, base, bits));
+}
+
+/** True if the IPv6 address is loopback, link-local, unique-local, or maps to a private IPv4. */
+function isPrivateIpv6(ip: string): boolean {
+ const addr = ip.toLowerCase().replace(/^\[|\]$/g, '');
+
+ if (addr === '::1' || addr === '::') return true; // loopback / unspecified
+ if (addr.startsWith('fe80') || addr.startsWith('fe9') ||
+ addr.startsWith('fea') || addr.startsWith('feb')) return true; // fe80::/10 link-local
+ if (/^f[cd]/.test(addr)) return true; // fc00::/7 unique-local
+
+ // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible — extract embedded v4
+ const v4Match = addr.match(/(?:::ffff:)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
+ if (v4Match) return isPrivateIpv4(v4Match[1]);
+
+ return false;
+}
+
+/** True if a resolved IP (v4 or v6) is in a blocked range. */
+export function isBlockedIp(ip: string): boolean {
+ if (net.isIPv4(ip)) return isPrivateIpv4(ip);
+ if (net.isIPv6(ip)) return isPrivateIpv6(ip);
+ // Unrecognized address form — block to fail closed.
+ return true;
+}
+
+/**
+ * Validate a user-supplied URL for SSRF safety.
+ * Resolves DNS and blocks any URL whose host is (or resolves to) a
+ * private/reserved/loopback/link-local address.
+ */
+export async function assertPublicUrl(rawUrl: string): Promise<SsrfCheck> {
+ let parsed: URL;
+ try {
+ parsed = new URL(rawUrl);
+ } catch {
+ return { ok: false, reason: 'Invalid URL format' };
+ }
+
+ // Only http(s)
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ return { ok: false, reason: 'Only HTTP and HTTPS URLs are supported' };
+ }
+
+ const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
+
+ // Explicit hostname denylist
+ if (BLOCKED_HOSTNAMES.has(hostname) || hostname.endsWith('.localhost')) {
+ return { ok: false, reason: 'Access to this host is not allowed' };
+ }
+
+ // If the host is already an IP literal, check it directly.
+ if (net.isIP(hostname)) {
+ if (isBlockedIp(hostname)) {
+ return { ok: false, reason: 'Access to private or reserved IP ranges is not allowed' };
+ }
+ return { ok: true, url: parsed.toString() };
+ }
+
+ // Otherwise resolve the hostname and check every returned address.
+ let addresses: { address: string }[];
+ try {
+ addresses = await lookup(hostname, { all: true });
+ } catch {
+ return { ok: false, reason: 'Could not resolve host' };
+ }
+
+ if (!addresses.length) {
+ return { ok: false, reason: 'Could not resolve host' };
+ }
+
+ for (const { address } of addresses) {
+ if (isBlockedIp(address)) {
+ return { ok: false, reason: 'Host resolves to a private or reserved IP range' };
+ }
+ }
+
+ return { ok: true, url: parsed.toString() };
+}
← dab8014 chore: version bump (session close — Norma platform + agent
·
back to Norma
·
docs(security): record P0#1-3 + P1 SSRF remediation as resol cb9d893 →