[object Object]

← 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

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 →