Skip to content

Commit 0a06a78

Browse files
committed
Fix: Dont allow internal URL resolution, and add flag to reenable internal / private URL resolution
BREAKING CHANGE: Fix security vulnerability allowing default http resolver to make requests to internal hostnames
1 parent b8a8a6f commit 0a06a78

File tree

5 files changed

+224
-1
lines changed

5 files changed

+224
-1
lines changed

lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type {
2828
ResolverOptions,
2929
HTTPResolverOptions,
3030
} from "./types/index.js";
31+
import { isUnsafeUrl } from "./util/url.js";
3132

3233
export type RefParserSchema = string | JSONSchema;
3334

@@ -439,4 +440,5 @@ export {
439440
normalizeArgs as jsonSchemaParserNormalizeArgs,
440441
getJsonSchemaRefParserDefaultOptions,
441442
$Refs,
443+
isUnsafeUrl,
442444
};

lib/resolvers/http.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,18 @@ export default {
3636
*/
3737
withCredentials: false,
3838

39+
/**
40+
* Set this to `false` if you want to allow unsafe URLs (e.g., `127.0.0.1`, localhost, and other internal URLs).
41+
*/
42+
safeUrlResolver: true,
43+
3944
/**
4045
* Determines whether this resolver can read a given file reference.
4146
* Resolvers that return true will be tried in order, until one successfully resolves the file.
4247
* Resolvers that return false will not be given a chance to resolve the file.
4348
*/
4449
canRead(file: FileInfo) {
45-
return url.isHttp(file.url);
50+
return url.isHttp(file.url) && (!this.safeUrlResolver || !url.isUnsafeUrl(file.url));
4651
},
4752

4853
/**

lib/types/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ export interface HTTPResolverOptions<S extends object = JSONSchema> extends Part
4141
* Set this to `true` if you're downloading files from a CORS-enabled server that requires authentication
4242
*/
4343
withCredentials?: boolean;
44+
45+
/**
46+
* Set this to `false` if you want to allow unsafe URLs (e.g., `127.0.0.1`, localhost, and other internal URLs).
47+
*/
48+
safeUrlResolver: true;
4449
}
4550

4651
/**

lib/util/url.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,173 @@ export function isHttp(path: string) {
167167
return false;
168168
}
169169
}
170+
/**
171+
* Determines whether the given url is an unsafe or internal url.
172+
*
173+
* @param path - The URL or path to check
174+
* @returns true if the URL is unsafe/internal, false otherwise
175+
*/
176+
export function isUnsafeUrl(path: string): boolean {
177+
if (!path || typeof path !== "string") {
178+
return true;
179+
}
180+
181+
// Trim whitespace and convert to lowercase for comparison
182+
const normalizedPath = path.trim().toLowerCase();
183+
184+
// Empty or just whitespace
185+
if (!normalizedPath) {
186+
return true;
187+
}
188+
189+
// JavaScript protocols
190+
if (
191+
normalizedPath.startsWith("javascript:") ||
192+
normalizedPath.startsWith("vbscript:") ||
193+
normalizedPath.startsWith("data:")
194+
) {
195+
return true;
196+
}
170197

198+
// File protocol
199+
if (normalizedPath.startsWith("file:")) {
200+
return true;
201+
}
202+
203+
// Local/internal network addresses
204+
const localPatterns = [
205+
// Localhost variations
206+
"localhost",
207+
"127.0.0.1",
208+
"::1",
209+
210+
// Private IP ranges (RFC 1918)
211+
"10.",
212+
"172.16.",
213+
"172.17.",
214+
"172.18.",
215+
"172.19.",
216+
"172.20.",
217+
"172.21.",
218+
"172.22.",
219+
"172.23.",
220+
"172.24.",
221+
"172.25.",
222+
"172.26.",
223+
"172.27.",
224+
"172.28.",
225+
"172.29.",
226+
"172.30.",
227+
"172.31.",
228+
"192.168.",
229+
230+
// Link-local addresses
231+
"169.254.",
232+
233+
// Internal domains
234+
".local",
235+
".internal",
236+
".intranet",
237+
".corp",
238+
".home",
239+
".lan",
240+
];
241+
242+
try {
243+
// Try to parse as URL
244+
const url = new URL(normalizedPath.startsWith("//") ? "http:" + normalizedPath : normalizedPath);
245+
246+
const hostname = url.hostname.toLowerCase();
247+
248+
// Check against local patterns
249+
for (const pattern of localPatterns) {
250+
if (hostname === pattern || hostname.startsWith(pattern) || hostname.endsWith(pattern)) {
251+
return true;
252+
}
253+
}
254+
255+
// Check for IP addresses in private ranges
256+
if (isPrivateIP(hostname)) {
257+
return true;
258+
}
259+
260+
// Check for non-standard ports that might indicate internal services
261+
const port = url.port;
262+
if (port && isInternalPort(parseInt(port))) {
263+
return true;
264+
}
265+
} catch (e) {
266+
// If URL parsing fails, check if it's a relative path or contains suspicious patterns
267+
268+
// Relative paths starting with / are generally safe for same-origin
269+
if (normalizedPath.startsWith("/") && !normalizedPath.startsWith("//")) {
270+
return false;
271+
}
272+
273+
// Check for localhost patterns in non-URL strings
274+
for (const pattern of localPatterns) {
275+
if (normalizedPath.includes(pattern)) {
276+
return true;
277+
}
278+
}
279+
}
280+
281+
return false;
282+
}
283+
284+
/**
285+
* Helper function to check if an IP address is in a private range
286+
*/
287+
function isPrivateIP(ip: string): boolean {
288+
const ipRegex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
289+
const match = ip.match(ipRegex);
290+
291+
if (!match) {
292+
return false;
293+
}
294+
295+
const [, a, b, c, d] = match.map(Number);
296+
297+
// Validate IP format
298+
if (a > 255 || b > 255 || c > 255 || d > 255) {
299+
return false;
300+
}
301+
302+
// Private IP ranges
303+
return (
304+
a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) // Link-local
305+
);
306+
}
307+
308+
/**
309+
* Helper function to check if a port is typically used for internal services
310+
*/
311+
function isInternalPort(port: number): boolean {
312+
const internalPorts = [
313+
22, // SSH
314+
23, // Telnet
315+
25, // SMTP
316+
53, // DNS
317+
135, // RPC
318+
139, // NetBIOS
319+
445, // SMB
320+
993, // IMAPS
321+
995, // POP3S
322+
1433, // SQL Server
323+
1521, // Oracle
324+
3306, // MySQL
325+
3389, // RDP
326+
5432, // PostgreSQL
327+
5900, // VNC
328+
6379, // Redis
329+
8080, // Common internal web
330+
8443, // Common internal HTTPS
331+
9200, // Elasticsearch
332+
27017, // MongoDB
333+
];
334+
335+
return internalPorts.includes(port);
336+
}
171337
/**
172338
* Determines whether the given path is a filesystem path.
173339
* This includes "file://" URLs.

test/specs/resolvers/resolvers.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,4 +252,49 @@ describe("options.resolve", () => {
252252
);
253253
expect(parsed).to.equal("custom://Path/Is/Case/Sensitive");
254254
});
255+
256+
it("should block unsafe URLs when safeUrlResolver is true (default)", async () => {
257+
const unsafeUrls = [
258+
"http://localhost/schema.json",
259+
"http://127.0.0.1/schema.json",
260+
"http://192.168.1.1/schema.json",
261+
"http://10.0.0.1/schema.json",
262+
"http://172.16.0.1/schema.json",
263+
];
264+
265+
for (const unsafeUrl of unsafeUrls) {
266+
try {
267+
await $RefParser.dereference({ $ref: unsafeUrl });
268+
helper.shouldNotGetCalled();
269+
} catch (err) {
270+
expect(err).to.be.an.instanceOf(Error);
271+
expect((err as Error).message).to.contain("Unable to resolve $ref pointer");
272+
}
273+
}
274+
});
275+
276+
it("should allow unsafe URLs when safeUrlResolver is false", async () => {
277+
const mockHttpResolver = {
278+
order: 200,
279+
canRead: /^https?:\/\//i,
280+
safeUrlResolver: false,
281+
read() {
282+
return { type: "object", properties: { test: { type: "string" } } };
283+
},
284+
};
285+
286+
const schema = await $RefParser.dereference(
287+
{ $ref: "http://localhost/schema.json" },
288+
{
289+
resolve: {
290+
http: mockHttpResolver,
291+
},
292+
} as ParserOptions,
293+
);
294+
295+
expect(schema).to.deep.equal({
296+
type: "object",
297+
properties: { test: { type: "string" } },
298+
});
299+
});
255300
});

0 commit comments

Comments
 (0)