Skip to content

Commit dea50b0

Browse files
committed
fix: harden safe URL resolver
1 parent a786bc6 commit dea50b0

4 files changed

Lines changed: 300 additions & 73 deletions

File tree

lib/resolvers/http.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ async function download<S extends object = JSONSchema>(
8080
redirects.push(u.href);
8181

8282
try {
83+
if (httpOptions.safeUrlResolver && url.isUnsafeUrl(u.href)) {
84+
throw new Error(`Unsafe URL blocked by safeUrlResolver: ${u.href}`);
85+
}
86+
8387
const res = await get(u, httpOptions);
8488
if (res.status >= 400) {
8589
const error = new Error(`HTTP ERROR ${res.status}`) as Error & { status?: number };
@@ -92,12 +96,16 @@ async function download<S extends object = JSONSchema>(
9296
) as Error & { status?: number };
9397
error.status = res.status;
9498
throw new ResolverError(error);
95-
} else if (!("location" in res.headers) || !res.headers.location) {
96-
const error = new Error(`HTTP ${res.status} redirect with no location header`) as Error & { status?: number };
97-
error.status = res.status;
98-
throw error;
9999
} else {
100-
const redirectTo = url.resolve(u.href, res.headers.location as string);
100+
const location = getHeader(res, "location");
101+
102+
if (!location) {
103+
const error = new Error(`HTTP ${res.status} redirect with no location header`) as Error & { status?: number };
104+
error.status = res.status;
105+
throw error;
106+
}
107+
108+
const redirectTo = url.resolve(u.href, location);
101109
return download(redirectTo, httpOptions, redirects);
102110
}
103111
} else {
@@ -130,6 +138,7 @@ async function get<S extends object = JSONSchema>(u: RequestInfo | URL, httpOpti
130138
method: "GET",
131139
headers: httpOptions.headers || {},
132140
credentials: httpOptions.withCredentials ? "include" : "same-origin",
141+
redirect: "manual",
133142
signal: controller ? controller.signal : null,
134143
});
135144
if (timeoutId) {
@@ -138,3 +147,7 @@ async function get<S extends object = JSONSchema>(u: RequestInfo | URL, httpOpti
138147

139148
return response;
140149
}
150+
151+
function getHeader(response: Response, name: string): string | null {
152+
return response.headers.get(name);
153+
}

lib/util/url.ts

Lines changed: 186 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const urlEncodePatterns = [
1818
// RegExp patterns to URL-decode special characters for local filesystem paths
1919
const urlDecodePatterns = [/%23/g, "#", /%24/g, "$", /%26/g, "&", /%2C/g, ",", /%40/g, "@"];
2020

21+
const unsafeDomainSuffixes = [".localhost", ".local", ".internal", ".intranet", ".corp", ".home", ".lan"];
22+
2123
export const parse = (u: string | URL) => new URL(u);
2224

2325
/**
@@ -211,60 +213,11 @@ export function isUnsafeUrl(path: string | unknown): boolean {
211213
return false;
212214
}
213215

214-
// Local/internal network addresses
215-
const localPatterns = [
216-
// Localhost variations
217-
"localhost",
218-
"127.0.0.1",
219-
"::1",
220-
221-
// Private IP ranges (RFC 1918)
222-
"10.",
223-
"172.16.",
224-
"172.17.",
225-
"172.18.",
226-
"172.19.",
227-
"172.20.",
228-
"172.21.",
229-
"172.22.",
230-
"172.23.",
231-
"172.24.",
232-
"172.25.",
233-
"172.26.",
234-
"172.27.",
235-
"172.28.",
236-
"172.29.",
237-
"172.30.",
238-
"172.31.",
239-
"192.168.",
240-
241-
// Link-local addresses
242-
"169.254.",
243-
244-
// Internal domains
245-
".local",
246-
".internal",
247-
".intranet",
248-
".corp",
249-
".home",
250-
".lan",
251-
];
252-
253216
try {
254217
// Try to parse as URL
255218
const url = new URL(normalizedPath.startsWith("//") ? "http:" + normalizedPath : normalizedPath);
256219

257-
const hostname = url.hostname.toLowerCase();
258-
259-
// Check against local patterns
260-
for (const pattern of localPatterns) {
261-
if (hostname === pattern || hostname.startsWith(pattern) || hostname.endsWith(pattern)) {
262-
return true;
263-
}
264-
}
265-
266-
// Check for IP addresses in private ranges
267-
if (isPrivateIP(hostname)) {
220+
if (isUnsafeHostname(url.hostname)) {
268221
return true;
269222
}
270223

@@ -281,41 +234,207 @@ export function isUnsafeUrl(path: string | unknown): boolean {
281234
return false;
282235
}
283236

284-
// Check for localhost patterns in non-URL strings
285-
for (const pattern of localPatterns) {
286-
if (normalizedPath.includes(pattern)) {
287-
return true;
288-
}
237+
if (containsUnsafeHostname(normalizedPath)) {
238+
return true;
289239
}
290240
}
291241

292242
return false;
293243
}
294244

295245
/**
296-
* Helper function to check if an IP address is in a private range
246+
* Helper function to check if a hostname is local or resolves to a non-public literal address.
297247
*/
298-
function isPrivateIP(ip: string): boolean {
299-
const ipRegex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
300-
const match = ip.match(ipRegex);
248+
function isUnsafeHostname(hostname: string): boolean {
249+
const normalizedHostname = normalizeHostname(hostname);
301250

302-
if (!match) {
303-
return false;
251+
if (!normalizedHostname) {
252+
return true;
304253
}
305254

306-
const [, a, b, c, d] = match.map(Number);
255+
if (normalizedHostname === "localhost" || unsafeDomainSuffixes.some((suffix) => normalizedHostname.endsWith(suffix))) {
256+
return true;
257+
}
307258

308-
// Validate IP format
309-
if (a > 255 || b > 255 || c > 255 || d > 255) {
310-
return false;
259+
const ipv4 = parseIPv4Address(normalizedHostname);
260+
if (ipv4) {
261+
return isUnsafeIPv4Address(ipv4);
262+
}
263+
264+
const ipv6 = parseIPv6Address(normalizedHostname);
265+
if (ipv6) {
266+
return isUnsafeIPv6Address(ipv6);
311267
}
312268

313-
// Private IP ranges
269+
return false;
270+
}
271+
272+
function normalizeHostname(hostname: string): string {
273+
let normalizedHostname = hostname.trim().toLowerCase();
274+
275+
if (normalizedHostname.startsWith("[") && normalizedHostname.endsWith("]")) {
276+
normalizedHostname = normalizedHostname.slice(1, -1);
277+
}
278+
279+
while (normalizedHostname.endsWith(".")) {
280+
normalizedHostname = normalizedHostname.slice(0, -1);
281+
}
282+
283+
return normalizedHostname;
284+
}
285+
286+
function parseIPv4Address(ip: string): number[] | undefined {
287+
const parts = ip.split(".");
288+
289+
if (parts.length !== 4) {
290+
return undefined;
291+
}
292+
293+
const octets = parts.map((part) => {
294+
if (!/^\d+$/.test(part)) {
295+
return Number.NaN;
296+
}
297+
298+
return Number(part);
299+
});
300+
301+
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
302+
return undefined;
303+
}
304+
305+
return octets;
306+
}
307+
308+
/**
309+
* Helper function to check if an IPv4 address is in a non-public range.
310+
*/
311+
function isUnsafeIPv4Address([a, b, c, d]: number[]): boolean {
314312
return (
315-
a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) // Link-local
313+
a === 0 ||
314+
a === 10 ||
315+
a === 127 ||
316+
(a === 100 && b >= 64 && b <= 127) ||
317+
(a === 169 && b === 254) ||
318+
(a === 172 && b >= 16 && b <= 31) ||
319+
(a === 192 && b === 0 && c === 0) ||
320+
(a === 192 && b === 168) ||
321+
(a === 198 && (b === 18 || b === 19)) ||
322+
a >= 224 ||
323+
(a === 255 && b === 255 && c === 255 && d === 255)
316324
);
317325
}
318326

327+
function parseIPv6Address(ip: string): number[] | undefined {
328+
if (!ip.includes(":")) {
329+
return undefined;
330+
}
331+
332+
let normalizedIP = ip;
333+
const lastSeparator = normalizedIP.lastIndexOf(":");
334+
const possibleIPv4 = normalizedIP.slice(lastSeparator + 1);
335+
336+
if (possibleIPv4.includes(".")) {
337+
const ipv4 = parseIPv4Address(possibleIPv4);
338+
339+
if (!ipv4) {
340+
return undefined;
341+
}
342+
343+
const firstGroup = ipv4[0] * 256 + ipv4[1];
344+
const secondGroup = ipv4[2] * 256 + ipv4[3];
345+
normalizedIP = `${normalizedIP.slice(0, lastSeparator + 1)}${firstGroup.toString(16)}:${secondGroup.toString(16)}`;
346+
}
347+
348+
const halves = normalizedIP.split("::");
349+
350+
if (halves.length > 2) {
351+
return undefined;
352+
}
353+
354+
const head = parseIPv6Groups(halves[0]);
355+
const tail = halves.length === 2 ? parseIPv6Groups(halves[1]) : [];
356+
357+
if (!head || !tail) {
358+
return undefined;
359+
}
360+
361+
if (halves.length === 1) {
362+
return head.length === 8 ? head : undefined;
363+
}
364+
365+
const missingGroups = 8 - head.length - tail.length;
366+
367+
if (missingGroups < 1) {
368+
return undefined;
369+
}
370+
371+
return [...head, ...Array<number>(missingGroups).fill(0), ...tail];
372+
}
373+
374+
function parseIPv6Groups(groups: string): number[] | undefined {
375+
if (!groups) {
376+
return [];
377+
}
378+
379+
const parsedGroups = groups.split(":").map((group) => {
380+
if (!/^[\da-f]{1,4}$/i.test(group)) {
381+
return Number.NaN;
382+
}
383+
384+
return Number.parseInt(group, 16);
385+
});
386+
387+
if (parsedGroups.some((group) => !Number.isInteger(group) || group < 0 || group > 0xffff)) {
388+
return undefined;
389+
}
390+
391+
return parsedGroups;
392+
}
393+
394+
/**
395+
* Helper function to check if an IPv6 address is in a non-public range.
396+
*/
397+
function isUnsafeIPv6Address(groups: number[]): boolean {
398+
if (groups.length !== 8) {
399+
return false;
400+
}
401+
402+
const isUnspecified = groups.every((group) => group === 0);
403+
const isLoopback = groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1;
404+
const isUniqueLocal = (groups[0] & 0xfe00) === 0xfc00;
405+
const isLinkLocal = (groups[0] & 0xffc0) === 0xfe80;
406+
const isMulticast = (groups[0] & 0xff00) === 0xff00;
407+
408+
if (isUnspecified || isLoopback || isUniqueLocal || isLinkLocal || isMulticast) {
409+
return true;
410+
}
411+
412+
const mappedIPv4 = getMappedIPv4Address(groups);
413+
414+
return mappedIPv4 ? isUnsafeIPv4Address(mappedIPv4) : false;
415+
}
416+
417+
function getMappedIPv4Address(groups: number[]): number[] | undefined {
418+
const firstFiveGroupsAreZero = groups.slice(0, 5).every((group) => group === 0);
419+
const firstSixGroupsAreZero = firstFiveGroupsAreZero && groups[5] === 0;
420+
const isIPv4Mapped = firstFiveGroupsAreZero && groups[5] === 0xffff;
421+
422+
if (!firstSixGroupsAreZero && !isIPv4Mapped) {
423+
return undefined;
424+
}
425+
426+
return [Math.floor(groups[6] / 256), groups[6] % 256, Math.floor(groups[7] / 256), groups[7] % 256];
427+
}
428+
429+
function containsUnsafeHostname(value: string): boolean {
430+
const candidates = value
431+
.split(/[\s/?#]+/)
432+
.map((candidate) => candidate.replace(/^[a-z][\d+.a-z-]*:\/\//i, "").replace(/:\d+$/, ""))
433+
.filter(Boolean);
434+
435+
return candidates.some((candidate) => isUnsafeHostname(candidate));
436+
}
437+
319438
/**
320439
* Helper function to check if a port is typically used for internal services
321440
*/

0 commit comments

Comments
 (0)