my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server

security: add SSRF protection for client metadata and domain verification fetches

dunkirk.sh 6c0dfd21 4ffe78b8

verified
+383 -124
+263
src/lib/ssrf-safe-fetch.ts
··· 1 + /** 2 + * SSRF-safe fetch implementation. 3 + * 4 + * Prevents Server-Side Request Forgery attacks by: 5 + * 1. Blocking private/internal IP addresses in URLs 6 + * 2. Blocking local hostnames (.local, .localhost, .internal, etc.) 7 + * 3. Validating redirect targets 8 + * 9 + * @see https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/ 10 + */ 11 + 12 + export type SafeFetchResult<T> = 13 + | { success: true; data: T } 14 + | { success: false; error: string }; 15 + 16 + /** 17 + * Check if an IP address is in a private/reserved range. 18 + * Covers all ranges that should not be accessed from the internet. 19 + */ 20 + function isPrivateIP(ip: string): boolean { 21 + // IPv4 private/reserved ranges 22 + const ipv4Patterns = [ 23 + /^0\./, // 0.0.0.0/8 - Current network 24 + /^10\./, // 10.0.0.0/8 - Private 25 + /^127\./, // 127.0.0.0/8 - Loopback 26 + /^169\.254\./, // 169.254.0.0/16 - Link-local (including AWS metadata 169.254.169.254) 27 + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12 - Private 28 + /^192\.0\.0\./, // 192.0.0.0/24 - IETF Protocol Assignments 29 + /^192\.0\.2\./, // 192.0.2.0/24 - TEST-NET-1 30 + /^192\.88\.99\./, // 192.88.99.0/24 - 6to4 Relay Anycast 31 + /^192\.168\./, // 192.168.0.0/16 - Private 32 + /^198\.1[8-9]\./, // 198.18.0.0/15 - Benchmarking 33 + /^198\.51\.100\./, // 198.51.100.0/24 - TEST-NET-2 34 + /^203\.0\.113\./, // 203.0.113.0/24 - TEST-NET-3 35 + /^22[4-9]\./, // 224.0.0.0/4 - Multicast 36 + /^23[0-9]\./, // 224.0.0.0/4 - Multicast 37 + /^24[0-9]\./, // 240.0.0.0/4 - Reserved 38 + /^25[0-5]\./, // 240.0.0.0/4 - Reserved (including broadcast 255.255.255.255) 39 + ]; 40 + 41 + for (const pattern of ipv4Patterns) { 42 + if (pattern.test(ip)) { 43 + return true; 44 + } 45 + } 46 + 47 + // IPv6 private/reserved - handle both bracketed [::1] and plain ::1 48 + const ipv6 = ip.replace(/^\[|\]$/g, "").toLowerCase(); 49 + 50 + // Loopback ::1 51 + if (ipv6 === "::1") return true; 52 + 53 + // Unspecified :: 54 + if (ipv6 === "::") return true; 55 + 56 + // Link-local fe80::/10 57 + if ( 58 + ipv6.startsWith("fe80:") || 59 + ipv6.startsWith("fe8") || 60 + ipv6.startsWith("fe9") || 61 + ipv6.startsWith("fea") || 62 + ipv6.startsWith("feb") 63 + ) { 64 + return true; 65 + } 66 + 67 + // Unique local fc00::/7 (includes fd00::/8) 68 + if (ipv6.startsWith("fc") || ipv6.startsWith("fd")) { 69 + return true; 70 + } 71 + 72 + // IPv4-mapped IPv6 addresses ::ffff:x.x.x.x 73 + const ipv4MappedMatch = ipv6.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i); 74 + if (ipv4MappedMatch?.[1]) { 75 + return isPrivateIP(ipv4MappedMatch[1]); 76 + } 77 + 78 + return false; 79 + } 80 + 81 + /** 82 + * Check if a hostname is a local/internal hostname that should not be fetched. 83 + */ 84 + function isLocalHostname(hostname: string): boolean { 85 + const lower = hostname.toLowerCase(); 86 + 87 + // Exact matches for localhost variants 88 + if (lower === "localhost" || lower === "localhost.localdomain") { 89 + return true; 90 + } 91 + 92 + // Check for local TLDs and suffixes 93 + const localSuffixes = [ 94 + ".local", 95 + ".localhost", 96 + ".localdomain", 97 + ".internal", 98 + ".home", 99 + ".lan", 100 + ".corp", 101 + ".test", 102 + ".invalid", 103 + ".example", 104 + // Cloud provider metadata hostnames 105 + ".metadata.google.internal", 106 + ".compute.internal", 107 + ]; 108 + 109 + for (const suffix of localSuffixes) { 110 + if (lower.endsWith(suffix)) { 111 + return true; 112 + } 113 + } 114 + 115 + // AWS/cloud metadata hostnames 116 + if ( 117 + lower === "metadata.google.internal" || 118 + lower === "instance-data" || 119 + lower === "metadata" 120 + ) { 121 + return true; 122 + } 123 + 124 + return false; 125 + } 126 + 127 + /** 128 + * Validate that a URL is safe to fetch (not pointing to internal resources). 129 + */ 130 + export function validateExternalURL(urlString: string): { 131 + safe: boolean; 132 + error?: string; 133 + } { 134 + let url: URL; 135 + try { 136 + url = new URL(urlString); 137 + } catch { 138 + return { safe: false, error: "Invalid URL" }; 139 + } 140 + 141 + // Must be HTTP or HTTPS 142 + if (url.protocol !== "http:" && url.protocol !== "https:") { 143 + return { safe: false, error: "URL must use http or https protocol" }; 144 + } 145 + 146 + // Check for credentials in URL (potential abuse vector) 147 + if (url.username || url.password) { 148 + return { safe: false, error: "URL must not contain credentials" }; 149 + } 150 + 151 + const hostname = url.hostname; 152 + 153 + // Check if hostname is a local hostname 154 + if (isLocalHostname(hostname)) { 155 + return { safe: false, error: "Cannot fetch from local/internal hostnames" }; 156 + } 157 + 158 + // Check if hostname is an IP address in private range 159 + // IPv4 check 160 + if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { 161 + if (isPrivateIP(hostname)) { 162 + return { 163 + safe: false, 164 + error: "Cannot fetch from private/reserved IP addresses", 165 + }; 166 + } 167 + } 168 + 169 + // IPv6 check (bracketed in URLs) 170 + if (hostname.startsWith("[") && hostname.endsWith("]")) { 171 + if (isPrivateIP(hostname)) { 172 + return { 173 + safe: false, 174 + error: "Cannot fetch from private/reserved IP addresses", 175 + }; 176 + } 177 + } 178 + 179 + // Check port - block common internal service ports 180 + const blockedPorts = [ 181 + "22", // SSH 182 + "23", // Telnet 183 + "25", // SMTP 184 + "53", // DNS 185 + "110", // POP3 186 + "143", // IMAP 187 + "445", // SMB 188 + "3306", // MySQL 189 + "5432", // PostgreSQL 190 + "6379", // Redis 191 + "11211", // Memcached 192 + "27017", // MongoDB 193 + ]; 194 + 195 + if (url.port && blockedPorts.includes(url.port)) { 196 + return { safe: false, error: `Port ${url.port} is not allowed` }; 197 + } 198 + 199 + return { safe: true }; 200 + } 201 + 202 + /** 203 + * Perform a fetch with SSRF protection. 204 + * 205 + * This validates the URL before fetching and adds additional protections. 206 + */ 207 + export async function safeFetch( 208 + url: string, 209 + options: { 210 + timeout?: number; 211 + headers?: Record<string, string>; 212 + } = {}, 213 + ): Promise<SafeFetchResult<Response>> { 214 + // Validate URL before fetching 215 + const validation = validateExternalURL(url); 216 + if (!validation.safe) { 217 + return { 218 + success: false, 219 + error: validation.error || "URL validation failed", 220 + }; 221 + } 222 + 223 + const { timeout = 5000, headers = {} } = options; 224 + 225 + try { 226 + const controller = new AbortController(); 227 + const timeoutId = setTimeout(() => controller.abort(), timeout); 228 + 229 + const response = await fetch(url, { 230 + method: "GET", 231 + headers: { 232 + Accept: "application/json, text/html", 233 + "User-Agent": "Indiko/1.0 (OAuth Client Metadata Fetcher)", 234 + ...headers, 235 + }, 236 + signal: controller.signal, 237 + redirect: "follow", 238 + }); 239 + 240 + clearTimeout(timeoutId); 241 + 242 + // After redirect, validate the final URL 243 + if (response.url && response.url !== url) { 244 + const finalValidation = validateExternalURL(response.url); 245 + if (!finalValidation.safe) { 246 + return { 247 + success: false, 248 + error: `Redirect to unsafe URL: ${finalValidation.error}`, 249 + }; 250 + } 251 + } 252 + 253 + return { success: true, data: response }; 254 + } catch (error) { 255 + if (error instanceof Error) { 256 + if (error.name === "AbortError") { 257 + return { success: false, error: "Request timed out" }; 258 + } 259 + return { success: false, error: `Fetch failed: ${error.message}` }; 260 + } 261 + return { success: false, error: "Fetch failed: Unknown error" }; 262 + } 263 + }
+120 -124
src/routes/indieauth.ts
··· 1 1 import crypto from "crypto"; 2 2 import { db } from "../db"; 3 3 import { signIDToken } from "../oidc"; 4 + import { safeFetch, validateExternalURL } from "../lib/ssrf-safe-fetch"; 4 5 5 6 interface SessionUser { 6 7 username: string; ··· 277 278 } 278 279 } 279 280 280 - // Fetch client metadata from client_id URL 281 + // Fetch client metadata from client_id URL (with SSRF protection) 281 282 async function fetchClientMetadata(clientId: string): Promise<{ 282 283 success: boolean; 283 284 metadata?: { ··· 289 290 }; 290 291 error?: string; 291 292 }> { 292 - // MUST NOT fetch loopback addresses (security requirement) 293 + // Validate URL is safe to fetch (prevents SSRF attacks) 294 + const urlValidation = validateExternalURL(clientId); 295 + if (!urlValidation.safe) { 296 + return { 297 + success: false, 298 + error: urlValidation.error || "Invalid client_id URL", 299 + }; 300 + } 301 + 302 + // Additional check: MUST NOT fetch loopback addresses (IndieAuth spec requirement) 293 303 if (isLoopbackURL(clientId)) { 294 304 return { 295 305 success: false, ··· 297 307 }; 298 308 } 299 309 300 - try { 301 - // Set timeout for fetch to prevent hanging 302 - const controller = new AbortController(); 303 - const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout 310 + // Use SSRF-safe fetch with timeout and redirect validation 311 + const fetchResult = await safeFetch(clientId, { timeout: 5000 }); 304 312 305 - const response = await fetch(clientId, { 306 - method: "GET", 307 - headers: { 308 - Accept: "application/json, text/html", 309 - }, 310 - signal: controller.signal, 311 - }); 313 + if (!fetchResult.success) { 314 + return { 315 + success: false, 316 + error: `Failed to fetch client metadata: ${fetchResult.error}`, 317 + }; 318 + } 312 319 313 - clearTimeout(timeoutId); 320 + const response = fetchResult.data; 314 321 315 - if (!response.ok) { 316 - return { 317 - success: false, 318 - error: `Failed to fetch client metadata: HTTP ${response.status}`, 319 - }; 320 - } 322 + if (!response.ok) { 323 + return { 324 + success: false, 325 + error: `Failed to fetch client metadata: HTTP ${response.status}`, 326 + }; 327 + } 321 328 322 - const contentType = response.headers.get("content-type") || ""; 329 + const contentType = response.headers.get("content-type") || ""; 323 330 324 - // Try to parse as JSON first 325 - if (contentType.includes("application/json")) { 331 + // Try to parse as JSON first 332 + if (contentType.includes("application/json")) { 333 + try { 326 334 const metadata = await response.json(); 327 335 328 336 // Verify client_id matches ··· 333 341 }; 334 342 } 335 343 344 + // Validate any logo_uri or client_uri in metadata (prevent SSRF via metadata fields) 345 + if (metadata.logo_uri) { 346 + const logoValidation = validateExternalURL(metadata.logo_uri); 347 + if (!logoValidation.safe) { 348 + delete metadata.logo_uri; 349 + } 350 + } 351 + 352 + if (metadata.client_uri) { 353 + const clientUriValidation = validateExternalURL(metadata.client_uri); 354 + if (!clientUriValidation.safe) { 355 + delete metadata.client_uri; 356 + } 357 + } 358 + 336 359 return { success: true, metadata }; 360 + } catch { 361 + return { success: false, error: "Invalid JSON in client metadata" }; 337 362 } 363 + } 338 364 339 - // If HTML, look for <link rel="redirect_uri"> tags 340 - if (contentType.includes("text/html")) { 341 - const html = await response.text(); 365 + // If HTML, look for <link rel="redirect_uri"> tags 366 + if (contentType.includes("text/html")) { 367 + const html = await response.text(); 368 + 369 + // Extract redirect URIs from link tags 370 + const redirectUriRegex = 371 + /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; 372 + const redirectUris: string[] = []; 373 + let match: RegExpExecArray | null; 342 374 343 - // Extract redirect URIs from link tags 344 - const redirectUriRegex = 345 - /<link\s+[^>]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; 346 - const redirectUris: string[] = []; 347 - let match: RegExpExecArray | null; 375 + while ((match = redirectUriRegex.exec(html)) !== null) { 376 + redirectUris.push(match[1]); 377 + } 348 378 349 - while ((match = redirectUriRegex.exec(html)) !== null) { 379 + // Also try reverse order (href before rel) 380 + const redirectUriRegex2 = 381 + /<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi; 382 + while ((match = redirectUriRegex2.exec(html)) !== null) { 383 + if (!redirectUris.includes(match[1])) { 350 384 redirectUris.push(match[1]); 351 385 } 352 - 353 - // Also try reverse order (href before rel) 354 - const redirectUriRegex2 = 355 - /<link\s+[^>]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi; 356 - while ((match = redirectUriRegex2.exec(html)) !== null) { 357 - if (!redirectUris.includes(match[1])) { 358 - redirectUris.push(match[1]); 359 - } 360 - } 361 - 362 - if (redirectUris.length > 0) { 363 - return { 364 - success: true, 365 - metadata: { 366 - client_id: clientId, 367 - redirect_uris: redirectUris, 368 - }, 369 - }; 370 - } 386 + } 371 387 388 + if (redirectUris.length > 0) { 372 389 return { 373 - success: false, 374 - error: "No client metadata or redirect_uri links found in HTML", 390 + success: true, 391 + metadata: { 392 + client_id: clientId, 393 + redirect_uris: redirectUris, 394 + }, 375 395 }; 376 396 } 377 397 378 - return { success: false, error: "Unsupported content type" }; 379 - } catch (error) { 380 - if (error instanceof Error) { 381 - if (error.name === "AbortError") { 382 - return { success: false, error: "Timeout fetching client metadata" }; 383 - } 384 - return { 385 - success: false, 386 - error: `Failed to fetch client metadata: ${error.message}`, 387 - }; 388 - } 389 - return { success: false, error: "Failed to fetch client metadata" }; 398 + return { 399 + success: false, 400 + error: "No client metadata or redirect_uri links found in HTML", 401 + }; 390 402 } 403 + 404 + return { success: false, error: "Unsupported content type" }; 391 405 } 392 406 393 - // Verify domain has rel="me" link back to user profile 407 + // Verify domain has rel="me" link back to user profile (with SSRF protection) 394 408 export async function verifyDomain( 395 409 domainUrl: string, 396 410 indikoProfileUrl: string, ··· 398 412 success: boolean; 399 413 error?: string; 400 414 }> { 401 - try { 402 - // Set timeout for fetch 403 - const controller = new AbortController(); 404 - const timeoutId = setTimeout(() => controller.abort(), 5000); 415 + // Validate URL is safe to fetch (prevents SSRF attacks) 416 + const urlValidation = validateExternalURL(domainUrl); 417 + if (!urlValidation.safe) { 418 + return { success: false, error: urlValidation.error || "Invalid domain URL" }; 419 + } 420 + 421 + // Use SSRF-safe fetch 422 + const fetchResult = await safeFetch(domainUrl, { 423 + timeout: 5000, 424 + headers: { 425 + Accept: "text/html", 426 + "User-Agent": "indiko/1.0 (+https://indiko.dunkirk.sh/)", 427 + }, 428 + }); 405 429 406 - const response = await fetch(domainUrl, { 407 - method: "GET", 408 - headers: { 409 - Accept: "text/html", 410 - "User-Agent": "indiko/1.0 (+https://indiko.dunkirk.sh/)", 411 - }, 412 - signal: controller.signal, 413 - }); 430 + if (!fetchResult.success) { 431 + console.error(`[verifyDomain] Failed to fetch ${domainUrl}: ${fetchResult.error}`); 432 + return { success: false, error: `Failed to fetch domain: ${fetchResult.error}` }; 433 + } 414 434 415 - clearTimeout(timeoutId); 435 + const response = fetchResult.data; 416 436 417 - if (!response.ok) { 418 - const errorBody = await response.text(); 419 - console.error( 420 - `[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`, 421 - { 422 - status: response.status, 423 - contentType: response.headers.get("content-type"), 424 - bodyPreview: errorBody.substring(0, 200), 425 - }, 426 - ); 427 - return { 428 - success: false, 429 - error: `Failed to fetch domain: HTTP ${response.status}`, 430 - }; 431 - } 437 + if (!response.ok) { 438 + const errorBody = await response.text(); 439 + console.error( 440 + `[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`, 441 + { 442 + status: response.status, 443 + contentType: response.headers.get("content-type"), 444 + bodyPreview: errorBody.substring(0, 200), 445 + }, 446 + ); 447 + return { 448 + success: false, 449 + error: `Failed to fetch domain: HTTP ${response.status}`, 450 + }; 451 + } 432 452 433 453 const html = await response.text(); 434 454 ··· 493 513 normalizedTarget: normalizedIndikoUrl, 494 514 }, 495 515 ); 496 - return { 497 - success: false, 498 - error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`, 499 - }; 500 - } 501 - 502 - return { success: true }; 503 - } catch (error) { 504 - if (error instanceof Error) { 505 - if (error.name === "AbortError") { 506 - console.error(`[verifyDomain] Timeout verifying ${domainUrl}`); 507 - return { success: false, error: "Timeout verifying domain" }; 508 - } 509 - console.error( 510 - `[verifyDomain] Error verifying ${domainUrl}: ${error.message}`, 511 - { 512 - name: error.name, 513 - stack: error.stack, 514 - }, 515 - ); 516 - return { 517 - success: false, 518 - error: `Failed to verify domain: ${error.message}`, 519 - }; 520 - } 521 - console.error( 522 - `[verifyDomain] Unknown error verifying ${domainUrl}:`, 523 - error, 524 - ); 525 - return { success: false, error: "Failed to verify domain" }; 516 + return { 517 + success: false, 518 + error: `Domain must have <link rel="me" href="${indikoProfileUrl}" /> or <a rel="me" href="${indikoProfileUrl}">...</a> to verify ownership`, 519 + }; 526 520 } 521 + 522 + return { success: true }; 527 523 } 528 524 529 525 // Validate and register app with client information discovery