Skip to content

Commit fc253e6

Browse files
authored
Merge pull request #722 from Chigybillionz/fix/640-enforce-ssrf-safe-external-request-policies
PREWAVE-BE-045 — Enforce SSRF-Safe External Request Policies #640
2 parents 6791f63 + ba941b8 commit fc253e6

12 files changed

Lines changed: 1829 additions & 0 deletions

‎src/common/security/ssrf/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from './ssrf.constants';
2+
export * from './ip-parser.util';
3+
export * from './ssrf-url-validator';
4+
export * from './safe-http-client.service';
5+
export * from './ssrf.module';
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import * as net from 'net';
2+
import {
3+
FORBIDDEN_IPV4_CIDRS,
4+
FORBIDDEN_IPV6_CIDRS,
5+
CLOUD_METADATA_IPS,
6+
} from './ssrf.constants';
7+
8+
export interface IpValidationResult {
9+
isForbidden: boolean;
10+
reason?: string;
11+
normalizedIp?: string;
12+
}
13+
14+
/**
15+
* Converts a 32-bit unsigned integer to a dotted-decimal IPv4 string.
16+
*/
17+
export function intToIpv4(intVal: number): string {
18+
const p1 = (intVal >>> 24) & 255;
19+
const p2 = (intVal >>> 16) & 255;
20+
const p3 = (intVal >>> 8) & 255;
21+
const p4 = intVal & 255;
22+
return `${p1}.${p2}.${p3}.${p4}`;
23+
}
24+
25+
/**
26+
* Parses a single part of an IPv4 address (decimal, octal with 0 prefix, or hex with 0x prefix).
27+
* Returns null if invalid.
28+
*/
29+
function parseIpv4Part(part: string): number | null {
30+
const trimmed = part.trim();
31+
if (trimmed.length === 0) return null;
32+
33+
let num: number;
34+
if (/^0x[0-9a-fA-F]+$/i.test(trimmed)) {
35+
num = parseInt(trimmed, 16);
36+
} else if (/^0[0-7]+$/.test(trimmed)) {
37+
num = parseInt(trimmed, 8);
38+
} else if (/^\d+$/.test(trimmed)) {
39+
num = parseInt(trimmed, 10);
40+
} else {
41+
return null;
42+
}
43+
44+
if (isNaN(num) || num < 0 || num > 0xffffffff) {
45+
return null;
46+
}
47+
return num;
48+
}
49+
50+
/**
51+
* Attempts to parse any representation of an IPv4 address, including:
52+
* - Dotted-quad: "127.0.0.1", "0177.0.0.1", "0x7f.0.0.1"
53+
* - Dotted-triplet, doublet: "127.0.1", "127.1"
54+
* - Single integer (dword): "2130706433", "0x7f000001", "017700000001"
55+
* Returns normalized dotted-decimal string or null if not an IPv4 address.
56+
*/
57+
export function parseAndNormalizeIpv4(input: string): string | null {
58+
const trimmed = input.trim();
59+
if (!trimmed) return null;
60+
61+
// Check if it's a single integer representation (decimal, hex, octal)
62+
if (/^(?:0x[0-9a-fA-F]+|0[0-7]+|\d+)$/i.test(trimmed)) {
63+
let intVal: number;
64+
if (/^0x[0-9a-fA-F]+$/i.test(trimmed)) {
65+
intVal = parseInt(trimmed, 16);
66+
} else if (/^0[0-7]+$/.test(trimmed)) {
67+
intVal = parseInt(trimmed, 8);
68+
} else {
69+
intVal = parseInt(trimmed, 10);
70+
}
71+
72+
if (isNaN(intVal) || intVal < 0 || intVal > 0xffffffff) {
73+
return null;
74+
}
75+
return intToIpv4(intVal);
76+
}
77+
78+
// Dotted notation (1 to 4 parts)
79+
const parts = trimmed.split('.');
80+
if (parts.length > 4 || parts.length < 1) return null;
81+
82+
const parsedParts: number[] = [];
83+
for (const part of parts) {
84+
const val = parseIpv4Part(part);
85+
if (val === null) return null;
86+
parsedParts.push(val);
87+
}
88+
89+
// Follow standard inet_aton semantics for classful/partial IP strings
90+
let finalInt = 0;
91+
if (parsedParts.length === 4) {
92+
for (let i = 0; i < 4; i++) {
93+
if (parsedParts[i] > 255) return null;
94+
}
95+
finalInt =
96+
((parsedParts[0] << 24) |
97+
(parsedParts[1] << 16) |
98+
(parsedParts[2] << 8) |
99+
parsedParts[3]) >>>
100+
0;
101+
} else if (parsedParts.length === 3) {
102+
if (parsedParts[0] > 255 || parsedParts[1] > 255 || parsedParts[2] > 0xffff)
103+
return null;
104+
finalInt =
105+
((parsedParts[0] << 24) | (parsedParts[1] << 16) | parsedParts[2]) >>> 0;
106+
} else if (parsedParts.length === 2) {
107+
if (parsedParts[0] > 255 || parsedParts[1] > 0xffffff) return null;
108+
finalInt = ((parsedParts[0] << 24) | parsedParts[1]) >>> 0;
109+
} else if (parsedParts.length === 1) {
110+
finalInt = parsedParts[0] >>> 0;
111+
}
112+
113+
return intToIpv4(finalInt);
114+
}
115+
116+
/**
117+
* Checks if an IPv4 string falls within a given IPv4 CIDR range.
118+
*/
119+
export function isIpv4InCidr(ip: string, cidr: string): boolean {
120+
const [rangeIp, prefixStr] = cidr.split('/');
121+
const prefix = parseInt(prefixStr, 10);
122+
if (isNaN(prefix) || prefix < 0 || prefix > 32) return false;
123+
124+
const ipInt = ipv4ToInt(ip);
125+
const rangeInt = ipv4ToInt(rangeIp);
126+
if (ipInt === null || rangeInt === null) return false;
127+
128+
if (prefix === 0) return true;
129+
const mask = (0xffffffff << (32 - prefix)) >>> 0;
130+
return (ipInt & mask) === (rangeInt & mask);
131+
}
132+
133+
function ipv4ToInt(ip: string): number | null {
134+
const parts = ip.split('.').map((p) => parseInt(p, 10));
135+
if (parts.length !== 4 || parts.some((p) => isNaN(p) || p < 0 || p > 255)) {
136+
return null;
137+
}
138+
return (
139+
((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0
140+
);
141+
}
142+
143+
/**
144+
* Parses and expands an IPv6 address into a 128-bit BigInt.
145+
* Also handles IPv4-mapped IPv6 (::ffff:192.0.2.1) and IPv4-compatible (::192.0.2.1).
146+
*/
147+
export function ipv6ToBigInt(
148+
ip: string,
149+
): { bigInt: bigint; mappedIpv4?: string } | null {
150+
let cleaned = ip.trim().toLowerCase();
151+
// Strip bracket notation if present
152+
if (cleaned.startsWith('[') && cleaned.endsWith(']')) {
153+
cleaned = cleaned.slice(1, -1);
154+
}
155+
156+
// Check for IPv4 mapped or compatible at end: e.g. ::ffff:192.168.1.1
157+
const lastColon = cleaned.lastIndexOf(':');
158+
let mappedIpv4: string | undefined;
159+
if (lastColon !== -1) {
160+
const possibleIpv4 = cleaned.slice(lastColon + 1);
161+
if (possibleIpv4.includes('.')) {
162+
const parsedV4 = parseAndNormalizeIpv4(possibleIpv4);
163+
if (parsedV4 !== null) {
164+
mappedIpv4 = parsedV4;
165+
const v4Parts = parsedV4.split('.').map((p) => parseInt(p, 10));
166+
const hex1 = ((v4Parts[0] << 8) | v4Parts[1]).toString(16);
167+
const hex2 = ((v4Parts[2] << 8) | v4Parts[3]).toString(16);
168+
cleaned = `${cleaned.slice(0, lastColon)}:${hex1}:${hex2}`;
169+
}
170+
}
171+
}
172+
173+
if (!net.isIPv6(cleaned)) {
174+
return null;
175+
}
176+
177+
const parts = cleaned.split('::');
178+
if (parts.length > 2) return null;
179+
180+
let leftParts: string[] = [];
181+
let rightParts: string[] = [];
182+
183+
if (parts.length === 2) {
184+
leftParts = parts[0] ? parts[0].split(':') : [];
185+
rightParts = parts[1] ? parts[1].split(':') : [];
186+
const missing = 8 - (leftParts.length + rightParts.length);
187+
if (missing < 0) return null;
188+
const middle: string[] = new Array<string>(missing).fill('0');
189+
parts[0] = [...leftParts, ...middle, ...rightParts].join(':');
190+
}
191+
192+
const fullSegments = parts[0].split(':');
193+
if (fullSegments.length !== 8) return null;
194+
195+
let result = 0n;
196+
for (const seg of fullSegments) {
197+
const val = parseInt(seg || '0', 16);
198+
if (isNaN(val) || val < 0 || val > 0xffff) return null;
199+
result = (result << 16n) | BigInt(val);
200+
}
201+
202+
return { bigInt: result, mappedIpv4 };
203+
}
204+
205+
/**
206+
* Checks if an IPv6 address falls within a given IPv6 CIDR range.
207+
*/
208+
export function isIpv6InCidr(ipBigInt: bigint, cidr: string): boolean {
209+
const [rangeIp, prefixStr] = cidr.split('/');
210+
const prefix = parseInt(prefixStr, 10);
211+
if (isNaN(prefix) || prefix < 0 || prefix > 128) return false;
212+
213+
const rangeParsed = ipv6ToBigInt(rangeIp);
214+
if (!rangeParsed) return false;
215+
216+
if (prefix === 0) return true;
217+
const shift = 128n - BigInt(prefix);
218+
return ipBigInt >> shift === rangeParsed.bigInt >> shift;
219+
}
220+
221+
/**
222+
* Validates any IP address (IPv4 or IPv6, including all encoded formats)
223+
* against forbidden network ranges and metadata IPs.
224+
*/
225+
export function validateIpAddress(ipInput: string): IpValidationResult {
226+
const trimmed = ipInput.trim();
227+
228+
// Try IPv4 first (including encoded)
229+
const normalizedIpv4 = parseAndNormalizeIpv4(trimmed);
230+
if (normalizedIpv4 !== null) {
231+
// Check cloud metadata explicit IPs
232+
if (CLOUD_METADATA_IPS.includes(normalizedIpv4)) {
233+
return {
234+
isForbidden: true,
235+
reason: `Target IP ${normalizedIpv4} matches cloud metadata service`,
236+
normalizedIp: normalizedIpv4,
237+
};
238+
}
239+
240+
// Check forbidden IPv4 CIDRs
241+
for (const cidr of FORBIDDEN_IPV4_CIDRS) {
242+
if (isIpv4InCidr(normalizedIpv4, cidr)) {
243+
return {
244+
isForbidden: true,
245+
reason: `Target IP ${normalizedIpv4} falls within forbidden range ${cidr}`,
246+
normalizedIp: normalizedIpv4,
247+
};
248+
}
249+
}
250+
251+
return {
252+
isForbidden: false,
253+
normalizedIp: normalizedIpv4,
254+
};
255+
}
256+
257+
// Try IPv6
258+
const parsedIpv6 = ipv6ToBigInt(trimmed);
259+
if (parsedIpv6 !== null) {
260+
// If it's an IPv4-mapped or IPv4-compatible address, check the mapped IPv4 too!
261+
if (parsedIpv6.mappedIpv4) {
262+
const v4Result = validateIpAddress(parsedIpv6.mappedIpv4);
263+
if (v4Result.isForbidden) {
264+
return {
265+
isForbidden: true,
266+
reason: `IPv4-mapped target ${parsedIpv6.mappedIpv4} is forbidden: ${v4Result.reason}`,
267+
normalizedIp: trimmed,
268+
};
269+
}
270+
}
271+
272+
// Check forbidden IPv6 CIDRs
273+
for (const cidr of FORBIDDEN_IPV6_CIDRS) {
274+
if (isIpv6InCidr(parsedIpv6.bigInt, cidr)) {
275+
return {
276+
isForbidden: true,
277+
reason: `Target IPv6 address falls within forbidden range ${cidr}`,
278+
normalizedIp: trimmed,
279+
};
280+
}
281+
}
282+
283+
return {
284+
isForbidden: false,
285+
normalizedIp: trimmed,
286+
};
287+
}
288+
289+
// Neither valid IPv4 nor IPv6
290+
return {
291+
isForbidden: true,
292+
reason: `Invalid or unrecognized IP address format: ${ipInput}`,
293+
};
294+
}

0 commit comments

Comments
 (0)