|
1 | 1 | import { Context } from 'hono'; |
2 | 2 | import { CONTENT_TYPES, POWERED_BY, VALID_PROVIDERS } from '../../globals'; |
3 | 3 | import { configSchema } from './schema/config'; |
| 4 | +import { Environment } from '../../utils/env'; |
| 5 | + |
| 6 | +// Regex patterns for validation (defined once for reusability) |
| 7 | +const VALIDATION_PATTERNS = { |
| 8 | + CONTROL_CHARS: /[\x00-\x1F\x7F]/, |
| 9 | + SUSPICIOUS_CHARS: /[\s<>{}|\\^`]/, |
| 10 | + DIGITS_1_3: /^\d{1,3}$/, |
| 11 | + DIGITS_1_10: /^\d{1,10}$/, |
| 12 | + DIGITS_ONLY: /^\d+$/, |
| 13 | + HEX_IP: /^0x[0-9a-f]{1,8}$/i, |
| 14 | + ALTERNATIVE_IP_PART: /^0[0-9a-fx]/i, // Starts with 0 followed by digits or x (octal or hex) |
| 15 | + IPV6_MAPPED_IPV4: /::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i, |
| 16 | + IPV6_EMBEDDED_IPV4: /::(\d{1,3}(?:\.\d{1,3}){3})$/i, |
| 17 | + HOMOGRAPH_ATTACK: /^[a-z0-9.-]+$/, |
| 18 | +}; |
| 19 | + |
| 20 | +// Disallowed URL schemes |
| 21 | +const DISALLOWED_SCHEMES = ['file://', 'data:', 'gopher:', 'ftp://', 'ftps://']; |
| 22 | + |
| 23 | +// Blocked hosts (cloud metadata endpoints and internal IPs) |
| 24 | +const BLOCKED_HOSTS = [ |
| 25 | + '0.0.0.0', |
| 26 | + '169.254.169.254', // AWS, Azure, GCP metadata (IPv4) |
| 27 | + 'metadata.google.internal', // GCP metadata |
| 28 | + 'metadata', // Kubernetes metadata |
| 29 | + 'metadata.azure.com', // Azure instance metadata |
| 30 | + 'instance-data', // AWS instance metadata alt |
| 31 | +]; |
| 32 | + |
| 33 | +// Blocked TLDs for SSRF protection |
| 34 | +const BLOCKED_TLDS = [ |
| 35 | + '.local', |
| 36 | + '.localdomain', |
| 37 | + '.internal', |
| 38 | + '.intranet', |
| 39 | + '.lan', |
| 40 | + '.home', |
| 41 | + '.corp', |
| 42 | + '.test', |
| 43 | + '.invalid', |
| 44 | + '.onion', |
| 45 | + '.localhost', |
| 46 | +]; |
| 47 | + |
| 48 | +// Parse allowed custom hosts from environment variable |
| 49 | +// Format: comma-separated list of domains/IPs (e.g., "localhost,127.0.0.1,example.com") |
| 50 | +const TRUSTED_CUSTOM_HOSTS = (c: Context) => { |
| 51 | + const envVar = Environment(c)?.TRUSTED_CUSTOM_HOSTS; |
| 52 | + if (!envVar) { |
| 53 | + // Default allowed hosts for local development |
| 54 | + return new Set(['localhost', '127.0.0.1', '::1', 'host.docker.internal']); |
| 55 | + } |
| 56 | + return new Set( |
| 57 | + envVar |
| 58 | + .split(',') |
| 59 | + .map((h: string) => h.trim().toLowerCase()) |
| 60 | + .filter((h: string) => h.length > 0) |
| 61 | + ); |
| 62 | +}; |
| 63 | + |
| 64 | +// Pre-computed IPv4 range boundaries for performance optimization |
| 65 | +const IPV4_RANGES = { |
| 66 | + PRIVATE: [ |
| 67 | + { start: ipv4ToInt('10.0.0.0'), end: ipv4ToInt('10.255.255.255') }, // 10/8 |
| 68 | + { start: ipv4ToInt('172.16.0.0'), end: ipv4ToInt('172.31.255.255') }, // 172.16/12 |
| 69 | + { start: ipv4ToInt('192.168.0.0'), end: ipv4ToInt('192.168.255.255') }, // 192.168/16 |
| 70 | + ], |
| 71 | + RESERVED: [ |
| 72 | + { start: ipv4ToInt('127.0.0.0'), end: ipv4ToInt('127.255.255.255') }, // loopback |
| 73 | + { start: ipv4ToInt('169.254.0.0'), end: ipv4ToInt('169.254.255.255') }, // link-local |
| 74 | + { start: ipv4ToInt('100.64.0.0'), end: ipv4ToInt('100.127.255.255') }, // CGNAT |
| 75 | + { start: ipv4ToInt('0.0.0.0'), end: ipv4ToInt('0.255.255.255') }, // "this" network |
| 76 | + { start: ipv4ToInt('224.0.0.0'), end: ipv4ToInt('255.255.255.255') }, // multicast/reserved/broadcast |
| 77 | + ], |
| 78 | +}; |
4 | 79 |
|
5 | 80 | export const requestValidator = (c: Context, next: any) => { |
6 | 81 | const requestHeaders = Object.fromEntries(c.req.raw.headers); |
@@ -66,7 +141,7 @@ export const requestValidator = (c: Context, next: any) => { |
66 | 141 | } |
67 | 142 |
|
68 | 143 | const customHostHeader = requestHeaders[`x-${POWERED_BY}-custom-host`]; |
69 | | - if (customHostHeader && customHostHeader.indexOf('api.portkey') > -1) { |
| 144 | + if (customHostHeader && !isValidCustomHost(customHostHeader, c)) { |
70 | 145 | return new Response( |
71 | 146 | JSON.stringify({ |
72 | 147 | status: 'failure', |
@@ -153,3 +228,223 @@ export const requestValidator = (c: Context, next: any) => { |
153 | 228 | } |
154 | 229 | return next(); |
155 | 230 | }; |
| 231 | + |
| 232 | +export function isValidCustomHost(customHost: string, c?: Context) { |
| 233 | + try { |
| 234 | + const value = customHost.trim().toLowerCase(); |
| 235 | + |
| 236 | + // Block empty or whitespace-only hosts |
| 237 | + if (!value) return false; |
| 238 | + |
| 239 | + // Block URLs with control characters or excessive whitespace |
| 240 | + if (VALIDATION_PATTERNS.CONTROL_CHARS.test(customHost)) return false; |
| 241 | + |
| 242 | + // Project-specific and obvious disallowed schemes/hosts |
| 243 | + if (value.indexOf('api.portkey') > -1) return false; |
| 244 | + if (DISALLOWED_SCHEMES.some((scheme) => value.startsWith(scheme))) |
| 245 | + return false; |
| 246 | + |
| 247 | + const url = new URL(customHost); |
| 248 | + const protocol = url.protocol; |
| 249 | + |
| 250 | + // Allow only HTTP(S) |
| 251 | + if (protocol !== 'http:' && protocol !== 'https:') return false; |
| 252 | + |
| 253 | + // Disallow credentials and obfuscation |
| 254 | + if (url.username || url.password) return false; |
| 255 | + if (customHost.includes('@')) return false; |
| 256 | + |
| 257 | + const host = url.hostname; |
| 258 | + |
| 259 | + // Block empty hostname |
| 260 | + if (!host) return false; |
| 261 | + |
| 262 | + // Block URLs with encoded characters in hostname (potential bypass attempt) |
| 263 | + if (host.includes('%')) return false; |
| 264 | + |
| 265 | + // Block suspicious characters that might indicate injection attempts |
| 266 | + if (VALIDATION_PATTERNS.SUSPICIOUS_CHARS.test(host)) return false; |
| 267 | + |
| 268 | + // Block non-ASCII characters in hostname (homograph attack protection) |
| 269 | + // Prevents Unicode lookalike characters from spoofing legitimate domains |
| 270 | + if (!VALIDATION_PATTERNS.HOMOGRAPH_ATTACK.test(host)) return false; |
| 271 | + |
| 272 | + // Block trailing dots in hostname (can cause DNS rebinding issues) |
| 273 | + if (host.endsWith('.')) return false; |
| 274 | + |
| 275 | + // Split hostname once for reuse in multiple checks |
| 276 | + const hostParts = host.split('.'); |
| 277 | + |
| 278 | + // Block excessive subdomain depth (potential DNS rebinding attack) |
| 279 | + // Limits the number of labels to prevent abuse |
| 280 | + if (hostParts.length > 10) return false; |
| 281 | + |
| 282 | + const trustedHosts = TRUSTED_CUSTOM_HOSTS(c); |
| 283 | + // Check against configurable allowed hosts (for local development or trusted domains) |
| 284 | + const isTrustedHost = |
| 285 | + trustedHosts.has(host) || |
| 286 | + // Allow subdomains of .localhost |
| 287 | + (trustedHosts.has('localhost') && host.endsWith('.localhost')); |
| 288 | + |
| 289 | + if (isTrustedHost) { |
| 290 | + // Still validate port range if provided |
| 291 | + if (url.port && !isValidPort(url.port)) return false; |
| 292 | + return true; |
| 293 | + } |
| 294 | + |
| 295 | + // Block obvious internal/unsafe hosts and cloud metadata endpoints |
| 296 | + if (BLOCKED_HOSTS.includes(host as any)) return false; |
| 297 | + |
| 298 | + // Block AWS IMDSv2 endpoint variations |
| 299 | + if (host.startsWith('169.254.169.') || host.startsWith('fd00:ec2::')) { |
| 300 | + return false; |
| 301 | + } |
| 302 | + |
| 303 | + // Block internal/special-use TLDs often used in SSRF attempts |
| 304 | + if ( |
| 305 | + BLOCKED_TLDS.some((tld) => host.endsWith(tld) && host !== 'localhost') |
| 306 | + ) { |
| 307 | + return false; |
| 308 | + } |
| 309 | + |
| 310 | + // Block private/reserved IPs (IPv4) |
| 311 | + if (isIPv4(hostParts) && (isPrivateIPv4(host) || isReservedIPv4(host))) { |
| 312 | + return false; |
| 313 | + } |
| 314 | + |
| 315 | + // Check for alternative IP representations (decimal, hex, octal) |
| 316 | + if (isAlternativeIPRepresentation(host, hostParts)) return false; |
| 317 | + |
| 318 | + // Block private/reserved IPv6 and IPv4-mapped IPv6 |
| 319 | + if (host.includes(':')) { |
| 320 | + if (isLocalOrPrivateIPv6(host)) return false; |
| 321 | + |
| 322 | + // Check both IPv6-mapped and embedded IPv4 patterns |
| 323 | + const ipv4Match = |
| 324 | + host.match(VALIDATION_PATTERNS.IPV6_MAPPED_IPV4) || |
| 325 | + host.match(VALIDATION_PATTERNS.IPV6_EMBEDDED_IPV4); |
| 326 | + |
| 327 | + if (ipv4Match) { |
| 328 | + const ip4 = ipv4Match[1]; |
| 329 | + if (isPrivateIPv4(ip4) || isReservedIPv4(ip4)) return false; |
| 330 | + } |
| 331 | + } |
| 332 | + |
| 333 | + // Validate port if present |
| 334 | + if (url.port && !isValidPort(url.port)) return false; |
| 335 | + |
| 336 | + return true; |
| 337 | + } catch { |
| 338 | + return false; |
| 339 | + } |
| 340 | +} |
| 341 | + |
| 342 | +// Helper function to convert integer to IPv4 dotted decimal notation |
| 343 | +function intToIPv4(num: number): string { |
| 344 | + const a = (num >>> 24) & 0xff; |
| 345 | + const b = (num >>> 16) & 0xff; |
| 346 | + const c = (num >>> 8) & 0xff; |
| 347 | + const d = num & 0xff; |
| 348 | + return `${a}.${b}.${c}.${d}`; |
| 349 | +} |
| 350 | + |
| 351 | +// Helper function to convert IPv4 dotted decimal to integer |
| 352 | +function ipv4ToInt(ip: string): number { |
| 353 | + const [a, b, c, d] = ip.split('.').map((n) => Number(n)); |
| 354 | + return ((a << 24) >>> 0) + (b << 16) + (c << 8) + d; |
| 355 | +} |
| 356 | + |
| 357 | +// Helper function to validate port numbers |
| 358 | +function isValidPort(port: string): boolean { |
| 359 | + const p = parseInt(port, 10); |
| 360 | + return p > 0 && p <= 65535; |
| 361 | +} |
| 362 | + |
| 363 | +function isIPv4(parts: string[]): boolean { |
| 364 | + if (parts.length !== 4) return false; |
| 365 | + return parts.every((part) => { |
| 366 | + // Must be 1-3 digits |
| 367 | + if (!VALIDATION_PATTERNS.DIGITS_1_3.test(part)) return false; |
| 368 | + |
| 369 | + const num = Number(part); |
| 370 | + |
| 371 | + // Must be in range 0-255 |
| 372 | + if (num < 0 || num > 255) return false; |
| 373 | + |
| 374 | + // Reject leading zeros (except for "0" itself) |
| 375 | + // This prevents octal interpretation ambiguity |
| 376 | + if (part.length > 1 && part.startsWith('0')) return false; |
| 377 | + |
| 378 | + return true; |
| 379 | + }); |
| 380 | +} |
| 381 | + |
| 382 | +function isPrivateIPv4(ip: string): boolean { |
| 383 | + const ipInt = ipv4ToInt(ip); |
| 384 | + return IPV4_RANGES.PRIVATE.some( |
| 385 | + (range) => ipInt >= range.start && ipInt <= range.end |
| 386 | + ); |
| 387 | +} |
| 388 | + |
| 389 | +function isReservedIPv4(ip: string): boolean { |
| 390 | + const ipInt = ipv4ToInt(ip); |
| 391 | + return IPV4_RANGES.RESERVED.some( |
| 392 | + (range) => ipInt >= range.start && ipInt <= range.end |
| 393 | + ); |
| 394 | +} |
| 395 | + |
| 396 | +function isLocalOrPrivateIPv6(host: string): boolean { |
| 397 | + const h = host.toLowerCase(); |
| 398 | + if (h === '::1' || h === '::') return true; // loopback/unspecified |
| 399 | + if (h.startsWith('fc') || h.startsWith('fd')) return true; // fc00::/7 (ULA) |
| 400 | + if (h.startsWith('fe80')) return true; // fe80::/10 (link-local) |
| 401 | + if (h.startsWith('fec0')) return true; // fec0::/10 (site-local, deprecated) |
| 402 | + return false; |
| 403 | +} |
| 404 | + |
| 405 | +function isAlternativeIPRepresentation(host: string, parts: string[]): boolean { |
| 406 | + // Check for decimal IP (e.g., 2130706433 for 127.0.0.1) |
| 407 | + // Valid range: 0 to 4294967295 (2^32 - 1) |
| 408 | + if (VALIDATION_PATTERNS.DIGITS_1_10.test(host)) { |
| 409 | + const num = parseInt(host, 10); |
| 410 | + if (num >= 0 && num <= 0xffffffff) { |
| 411 | + // Convert to dotted decimal and check if it's private/reserved |
| 412 | + const ip = intToIPv4(num); |
| 413 | + // Block if it resolves to a private or reserved IP |
| 414 | + if (isPrivateIPv4(ip) || isReservedIPv4(ip)) return true; |
| 415 | + // Also block public IPs in decimal format to prevent confusion |
| 416 | + return true; |
| 417 | + } |
| 418 | + } |
| 419 | + |
| 420 | + // Check for hex IP (e.g., 0x7f000001 for 127.0.0.1) |
| 421 | + if (VALIDATION_PATTERNS.HEX_IP.test(host)) { |
| 422 | + const num = parseInt(host, 16); |
| 423 | + if (num >= 0 && num <= 0xffffffff) { |
| 424 | + return true; // Block all hex IPs (no need to convert) |
| 425 | + } |
| 426 | + } |
| 427 | + |
| 428 | + // Check for octal or hex notation in any part (e.g., 0177.0.0.1 or 0x7f.0.0.1) |
| 429 | + if ( |
| 430 | + parts.length === 4 && |
| 431 | + parts.some((p) => VALIDATION_PATTERNS.ALTERNATIVE_IP_PART.test(p)) |
| 432 | + ) { |
| 433 | + // Has octal or hex notation - block it |
| 434 | + return true; |
| 435 | + } |
| 436 | + |
| 437 | + // Check for shortened IP formats (e.g., 127.1 -> 127.0.0.1) |
| 438 | + if (parts.length >= 2 && parts.length < 4) { |
| 439 | + if ( |
| 440 | + parts.every( |
| 441 | + (p) => VALIDATION_PATTERNS.DIGITS_ONLY.test(p) && Number(p) <= 255 |
| 442 | + ) |
| 443 | + ) { |
| 444 | + // Looks like a shortened IP format - block it |
| 445 | + return true; |
| 446 | + } |
| 447 | + } |
| 448 | + |
| 449 | + return false; |
| 450 | +} |
0 commit comments