Skip to content

Commit b5a7825

Browse files
authored
Merge pull request #1372 from Portkey-AI/fix/ssrf_custom_host_validator
fix: custom host validator to protect from ssrf attack
2 parents 093cf32 + c3e1171 commit b5a7825

File tree

3 files changed

+302
-2
lines changed

3 files changed

+302
-2
lines changed

src/middlewares/requestValidator/index.ts

Lines changed: 296 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,81 @@
11
import { Context } from 'hono';
22
import { CONTENT_TYPES, POWERED_BY, VALID_PROVIDERS } from '../../globals';
33
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+
};
479

580
export const requestValidator = (c: Context, next: any) => {
681
const requestHeaders = Object.fromEntries(c.req.raw.headers);
@@ -66,7 +141,7 @@ export const requestValidator = (c: Context, next: any) => {
66141
}
67142

68143
const customHostHeader = requestHeaders[`x-${POWERED_BY}-custom-host`];
69-
if (customHostHeader && customHostHeader.indexOf('api.portkey') > -1) {
144+
if (customHostHeader && !isValidCustomHost(customHostHeader, c)) {
70145
return new Response(
71146
JSON.stringify({
72147
status: 'failure',
@@ -153,3 +228,223 @@ export const requestValidator = (c: Context, next: any) => {
153228
}
154229
return next();
155230
};
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+
}

src/middlewares/requestValidator/schema/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
TRITON,
77
AZURE_OPEN_AI,
88
} from '../../../globals';
9+
import { isValidCustomHost } from '..';
910

1011
export const configSchema: any = z
1112
.object({
@@ -154,7 +155,7 @@ export const configSchema: any = z
154155
.refine(
155156
(value) => {
156157
const customHost = value.custom_host;
157-
if (customHost && customHost.indexOf('api.portkey') > -1) {
158+
if (customHost && !isValidCustomHost(customHost)) {
158159
return false;
159160
}
160161
return true;

src/utils/env.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ const nodeEnv = {
129129
HTTPS_PROXY: getValueOrFileContents(process.env.HTTPS_PROXY),
130130

131131
APM_LOGGER: getValueOrFileContents(process.env.APM_LOGGER),
132+
133+
TRUSTED_CUSTOM_HOSTS: getValueOrFileContents(
134+
process.env.TRUSTED_CUSTOM_HOSTS
135+
),
132136
};
133137

134138
export const Environment = (c?: Context) => {

0 commit comments

Comments
 (0)