@apidevtools/json-schema-ref-parser 15.3.5 → 15.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/lib/pointer.js +9 -0
- package/dist/lib/resolvers/http.js +14 -6
- package/dist/lib/util/url.js +144 -61
- package/lib/pointer.ts +13 -0
- package/lib/resolvers/http.ts +18 -5
- package/lib/util/url.ts +186 -67
- package/package.json +19 -19
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ Install using [npm](https://docs.npmjs.com/about-npm/):
|
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
npm install @apidevtools/json-schema-ref-parser
|
|
18
|
-
|
|
18
|
+
pnpm add @apidevtools/json-schema-ref-parser
|
|
19
19
|
bun add @apidevtools/json-schema-ref-parser
|
|
20
20
|
```
|
|
21
21
|
|
|
@@ -142,10 +142,10 @@ To build/test the project locally on your computer:
|
|
|
142
142
|
`git clone https://github.com/APIDevTools/json-schema-ref-parser.git`
|
|
143
143
|
|
|
144
144
|
2. **Install dependencies**<br>
|
|
145
|
-
`
|
|
145
|
+
`pnpm install`
|
|
146
146
|
|
|
147
147
|
3. **Run the tests**<br>
|
|
148
|
-
`
|
|
148
|
+
`pnpm test`
|
|
149
149
|
|
|
150
150
|
## License
|
|
151
151
|
|
package/dist/lib/pointer.js
CHANGED
|
@@ -7,6 +7,7 @@ const slashes = /\//g;
|
|
|
7
7
|
const tildes = /~/g;
|
|
8
8
|
const escapedSlash = /~1/g;
|
|
9
9
|
const escapedTilde = /~0/g;
|
|
10
|
+
const unsafeSetTokens = new Set(["__proto__", "constructor", "prototype"]);
|
|
10
11
|
/**
|
|
11
12
|
* This class represents a single JSON pointer and its resolved value.
|
|
12
13
|
*
|
|
@@ -161,6 +162,7 @@ class Pointer {
|
|
|
161
162
|
this.value = value;
|
|
162
163
|
return value;
|
|
163
164
|
}
|
|
165
|
+
assertSafeSetTokens(this.path, tokens);
|
|
164
166
|
// Crawl the object, one token at a time
|
|
165
167
|
this.value = unwrapOrThrow(obj);
|
|
166
168
|
if (this.$ref.dynamicIdScope && !isAliasedResource(this.$ref)) {
|
|
@@ -316,6 +318,13 @@ function setValue(pointer, token, value) {
|
|
|
316
318
|
}
|
|
317
319
|
return value;
|
|
318
320
|
}
|
|
321
|
+
function assertSafeSetTokens(pointerPath, tokens) {
|
|
322
|
+
for (const token of tokens) {
|
|
323
|
+
if (unsafeSetTokens.has(token)) {
|
|
324
|
+
throw new JSONParserError(`Error assigning $ref pointer "${pointerPath}". \nUnsafe JSON Pointer token "${token}" cannot be used for assignment.`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
319
328
|
function unwrapOrThrow(value) {
|
|
320
329
|
if (isHandledError(value)) {
|
|
321
330
|
throw value;
|
|
@@ -63,6 +63,9 @@ async function download(u, httpOptions, _redirects) {
|
|
|
63
63
|
const redirects = _redirects || [];
|
|
64
64
|
redirects.push(u.href);
|
|
65
65
|
try {
|
|
66
|
+
if (httpOptions.safeUrlResolver && url.isUnsafeUrl(u.href)) {
|
|
67
|
+
throw new Error(`Unsafe URL blocked by safeUrlResolver: ${u.href}`);
|
|
68
|
+
}
|
|
66
69
|
const res = await get(u, httpOptions);
|
|
67
70
|
if (res.status >= 400) {
|
|
68
71
|
const error = new Error(`HTTP ERROR ${res.status}`);
|
|
@@ -75,13 +78,14 @@ async function download(u, httpOptions, _redirects) {
|
|
|
75
78
|
error.status = res.status;
|
|
76
79
|
throw new ResolverError(error);
|
|
77
80
|
}
|
|
78
|
-
else if (!("location" in res.headers) || !res.headers.location) {
|
|
79
|
-
const error = new Error(`HTTP ${res.status} redirect with no location header`);
|
|
80
|
-
error.status = res.status;
|
|
81
|
-
throw error;
|
|
82
|
-
}
|
|
83
81
|
else {
|
|
84
|
-
const
|
|
82
|
+
const location = getHeader(res, "location");
|
|
83
|
+
if (!location) {
|
|
84
|
+
const error = new Error(`HTTP ${res.status} redirect with no location header`);
|
|
85
|
+
error.status = res.status;
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
const redirectTo = url.resolve(u.href, location);
|
|
85
89
|
return download(redirectTo, httpOptions, redirects);
|
|
86
90
|
}
|
|
87
91
|
}
|
|
@@ -114,6 +118,7 @@ async function get(u, httpOptions) {
|
|
|
114
118
|
method: "GET",
|
|
115
119
|
headers: httpOptions.headers || {},
|
|
116
120
|
credentials: httpOptions.withCredentials ? "include" : "same-origin",
|
|
121
|
+
redirect: "manual",
|
|
117
122
|
signal: controller ? controller.signal : null,
|
|
118
123
|
});
|
|
119
124
|
if (timeoutId) {
|
|
@@ -121,3 +126,6 @@ async function get(u, httpOptions) {
|
|
|
121
126
|
}
|
|
122
127
|
return response;
|
|
123
128
|
}
|
|
129
|
+
function getHeader(response, name) {
|
|
130
|
+
return response.headers.get(name);
|
|
131
|
+
}
|
package/dist/lib/util/url.js
CHANGED
|
@@ -12,6 +12,7 @@ const urlEncodePatterns = [
|
|
|
12
12
|
];
|
|
13
13
|
// RegExp patterns to URL-decode special characters for local filesystem paths
|
|
14
14
|
const urlDecodePatterns = [/%23/g, "#", /%24/g, "$", /%26/g, "&", /%2C/g, ",", /%40/g, "@"];
|
|
15
|
+
const unsafeDomainSuffixes = [".localhost", ".local", ".internal", ".intranet", ".corp", ".home", ".lan"];
|
|
15
16
|
export const parse = (u) => new URL(u);
|
|
16
17
|
/**
|
|
17
18
|
* Returns resolved target URL relative to a base URL in a manner similar to that of a Web browser resolving an anchor tag HREF.
|
|
@@ -191,53 +192,10 @@ export function isUnsafeUrl(path) {
|
|
|
191
192
|
if (typeof window !== "undefined" && window.location && window.location.href) {
|
|
192
193
|
return false;
|
|
193
194
|
}
|
|
194
|
-
// Local/internal network addresses
|
|
195
|
-
const localPatterns = [
|
|
196
|
-
// Localhost variations
|
|
197
|
-
"localhost",
|
|
198
|
-
"127.0.0.1",
|
|
199
|
-
"::1",
|
|
200
|
-
// Private IP ranges (RFC 1918)
|
|
201
|
-
"10.",
|
|
202
|
-
"172.16.",
|
|
203
|
-
"172.17.",
|
|
204
|
-
"172.18.",
|
|
205
|
-
"172.19.",
|
|
206
|
-
"172.20.",
|
|
207
|
-
"172.21.",
|
|
208
|
-
"172.22.",
|
|
209
|
-
"172.23.",
|
|
210
|
-
"172.24.",
|
|
211
|
-
"172.25.",
|
|
212
|
-
"172.26.",
|
|
213
|
-
"172.27.",
|
|
214
|
-
"172.28.",
|
|
215
|
-
"172.29.",
|
|
216
|
-
"172.30.",
|
|
217
|
-
"172.31.",
|
|
218
|
-
"192.168.",
|
|
219
|
-
// Link-local addresses
|
|
220
|
-
"169.254.",
|
|
221
|
-
// Internal domains
|
|
222
|
-
".local",
|
|
223
|
-
".internal",
|
|
224
|
-
".intranet",
|
|
225
|
-
".corp",
|
|
226
|
-
".home",
|
|
227
|
-
".lan",
|
|
228
|
-
];
|
|
229
195
|
try {
|
|
230
196
|
// Try to parse as URL
|
|
231
197
|
const url = new URL(normalizedPath.startsWith("//") ? "http:" + normalizedPath : normalizedPath);
|
|
232
|
-
|
|
233
|
-
// Check against local patterns
|
|
234
|
-
for (const pattern of localPatterns) {
|
|
235
|
-
if (hostname === pattern || hostname.startsWith(pattern) || hostname.endsWith(pattern)) {
|
|
236
|
-
return true;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
// Check for IP addresses in private ranges
|
|
240
|
-
if (isPrivateIP(hostname)) {
|
|
198
|
+
if (isUnsafeHostname(url.hostname)) {
|
|
241
199
|
return true;
|
|
242
200
|
}
|
|
243
201
|
// Check for non-standard ports that might indicate internal services
|
|
@@ -252,32 +210,157 @@ export function isUnsafeUrl(path) {
|
|
|
252
210
|
if (normalizedPath.startsWith("/") && !normalizedPath.startsWith("//")) {
|
|
253
211
|
return false;
|
|
254
212
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (normalizedPath.includes(pattern)) {
|
|
258
|
-
return true;
|
|
259
|
-
}
|
|
213
|
+
if (containsUnsafeHostname(normalizedPath)) {
|
|
214
|
+
return true;
|
|
260
215
|
}
|
|
261
216
|
}
|
|
262
217
|
return false;
|
|
263
218
|
}
|
|
264
219
|
/**
|
|
265
|
-
* Helper function to check if
|
|
220
|
+
* Helper function to check if a hostname is local or resolves to a non-public literal address.
|
|
266
221
|
*/
|
|
267
|
-
function
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
222
|
+
function isUnsafeHostname(hostname) {
|
|
223
|
+
const normalizedHostname = normalizeHostname(hostname);
|
|
224
|
+
if (!normalizedHostname) {
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
if (normalizedHostname === "localhost" || unsafeDomainSuffixes.some((suffix) => normalizedHostname.endsWith(suffix))) {
|
|
228
|
+
return true;
|
|
272
229
|
}
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
230
|
+
const ipv4 = parseIPv4Address(normalizedHostname);
|
|
231
|
+
if (ipv4) {
|
|
232
|
+
return isUnsafeIPv4Address(ipv4);
|
|
233
|
+
}
|
|
234
|
+
const ipv6 = parseIPv6Address(normalizedHostname);
|
|
235
|
+
if (ipv6) {
|
|
236
|
+
return isUnsafeIPv6Address(ipv6);
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
function normalizeHostname(hostname) {
|
|
241
|
+
let normalizedHostname = hostname.trim().toLowerCase();
|
|
242
|
+
if (normalizedHostname.startsWith("[") && normalizedHostname.endsWith("]")) {
|
|
243
|
+
normalizedHostname = normalizedHostname.slice(1, -1);
|
|
244
|
+
}
|
|
245
|
+
while (normalizedHostname.endsWith(".")) {
|
|
246
|
+
normalizedHostname = normalizedHostname.slice(0, -1);
|
|
247
|
+
}
|
|
248
|
+
return normalizedHostname;
|
|
249
|
+
}
|
|
250
|
+
function parseIPv4Address(ip) {
|
|
251
|
+
const parts = ip.split(".");
|
|
252
|
+
if (parts.length !== 4) {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
const octets = parts.map((part) => {
|
|
256
|
+
if (!/^\d+$/.test(part)) {
|
|
257
|
+
return Number.NaN;
|
|
258
|
+
}
|
|
259
|
+
return Number(part);
|
|
260
|
+
});
|
|
261
|
+
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
return octets;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Helper function to check if an IPv4 address is in a non-public range.
|
|
268
|
+
*/
|
|
269
|
+
function isUnsafeIPv4Address([a, b, c, d]) {
|
|
270
|
+
return (a === 0 ||
|
|
271
|
+
a === 10 ||
|
|
272
|
+
a === 127 ||
|
|
273
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
274
|
+
(a === 169 && b === 254) ||
|
|
275
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
276
|
+
(a === 192 && b === 0 && c === 0) ||
|
|
277
|
+
(a === 192 && b === 168) ||
|
|
278
|
+
(a === 198 && (b === 18 || b === 19)) ||
|
|
279
|
+
a >= 224 ||
|
|
280
|
+
(a === 255 && b === 255 && c === 255 && d === 255));
|
|
281
|
+
}
|
|
282
|
+
function parseIPv6Address(ip) {
|
|
283
|
+
if (!ip.includes(":")) {
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
let normalizedIP = ip;
|
|
287
|
+
const lastSeparator = normalizedIP.lastIndexOf(":");
|
|
288
|
+
const possibleIPv4 = normalizedIP.slice(lastSeparator + 1);
|
|
289
|
+
if (possibleIPv4.includes(".")) {
|
|
290
|
+
const ipv4 = parseIPv4Address(possibleIPv4);
|
|
291
|
+
if (!ipv4) {
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
const firstGroup = ipv4[0] * 256 + ipv4[1];
|
|
295
|
+
const secondGroup = ipv4[2] * 256 + ipv4[3];
|
|
296
|
+
normalizedIP = `${normalizedIP.slice(0, lastSeparator + 1)}${firstGroup.toString(16)}:${secondGroup.toString(16)}`;
|
|
297
|
+
}
|
|
298
|
+
const halves = normalizedIP.split("::");
|
|
299
|
+
if (halves.length > 2) {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
const head = parseIPv6Groups(halves[0]);
|
|
303
|
+
const tail = halves.length === 2 ? parseIPv6Groups(halves[1]) : [];
|
|
304
|
+
if (!head || !tail) {
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
if (halves.length === 1) {
|
|
308
|
+
return head.length === 8 ? head : undefined;
|
|
309
|
+
}
|
|
310
|
+
const missingGroups = 8 - head.length - tail.length;
|
|
311
|
+
if (missingGroups < 1) {
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
return [...head, ...Array(missingGroups).fill(0), ...tail];
|
|
315
|
+
}
|
|
316
|
+
function parseIPv6Groups(groups) {
|
|
317
|
+
if (!groups) {
|
|
318
|
+
return [];
|
|
319
|
+
}
|
|
320
|
+
const parsedGroups = groups.split(":").map((group) => {
|
|
321
|
+
if (!/^[\da-f]{1,4}$/i.test(group)) {
|
|
322
|
+
return Number.NaN;
|
|
323
|
+
}
|
|
324
|
+
return Number.parseInt(group, 16);
|
|
325
|
+
});
|
|
326
|
+
if (parsedGroups.some((group) => !Number.isInteger(group) || group < 0 || group > 0xffff)) {
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
return parsedGroups;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Helper function to check if an IPv6 address is in a non-public range.
|
|
333
|
+
*/
|
|
334
|
+
function isUnsafeIPv6Address(groups) {
|
|
335
|
+
if (groups.length !== 8) {
|
|
276
336
|
return false;
|
|
277
337
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
);
|
|
338
|
+
const isUnspecified = groups.every((group) => group === 0);
|
|
339
|
+
const isLoopback = groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1;
|
|
340
|
+
const isUniqueLocal = (groups[0] & 0xfe00) === 0xfc00;
|
|
341
|
+
const isLinkLocal = (groups[0] & 0xffc0) === 0xfe80;
|
|
342
|
+
const isMulticast = (groups[0] & 0xff00) === 0xff00;
|
|
343
|
+
if (isUnspecified || isLoopback || isUniqueLocal || isLinkLocal || isMulticast) {
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
const mappedIPv4 = getMappedIPv4Address(groups);
|
|
347
|
+
return mappedIPv4 ? isUnsafeIPv4Address(mappedIPv4) : false;
|
|
348
|
+
}
|
|
349
|
+
function getMappedIPv4Address(groups) {
|
|
350
|
+
const firstFiveGroupsAreZero = groups.slice(0, 5).every((group) => group === 0);
|
|
351
|
+
const firstSixGroupsAreZero = firstFiveGroupsAreZero && groups[5] === 0;
|
|
352
|
+
const isIPv4Mapped = firstFiveGroupsAreZero && groups[5] === 0xffff;
|
|
353
|
+
if (!firstSixGroupsAreZero && !isIPv4Mapped) {
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
return [Math.floor(groups[6] / 256), groups[6] % 256, Math.floor(groups[7] / 256), groups[7] % 256];
|
|
357
|
+
}
|
|
358
|
+
function containsUnsafeHostname(value) {
|
|
359
|
+
const candidates = value
|
|
360
|
+
.split(/[\s/?#]+/)
|
|
361
|
+
.map((candidate) => candidate.replace(/^[a-z][\d+.a-z-]*:\/\//i, "").replace(/:\d+$/, ""))
|
|
362
|
+
.filter(Boolean);
|
|
363
|
+
return candidates.some((candidate) => isUnsafeHostname(candidate));
|
|
281
364
|
}
|
|
282
365
|
/**
|
|
283
366
|
* Helper function to check if a port is typically used for internal services
|
package/lib/pointer.ts
CHANGED
|
@@ -13,6 +13,7 @@ const slashes = /\//g;
|
|
|
13
13
|
const tildes = /~/g;
|
|
14
14
|
const escapedSlash = /~1/g;
|
|
15
15
|
const escapedTilde = /~0/g;
|
|
16
|
+
const unsafeSetTokens = new Set(["__proto__", "constructor", "prototype"]);
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* This class represents a single JSON pointer and its resolved value.
|
|
@@ -194,6 +195,8 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
|
|
|
194
195
|
return value;
|
|
195
196
|
}
|
|
196
197
|
|
|
198
|
+
assertSafeSetTokens(this.path, tokens);
|
|
199
|
+
|
|
197
200
|
// Crawl the object, one token at a time
|
|
198
201
|
this.value = unwrapOrThrow(obj);
|
|
199
202
|
if (this.$ref.dynamicIdScope && !isAliasedResource(this.$ref)) {
|
|
@@ -372,6 +375,16 @@ function setValue(pointer: Pointer, token: string, value: JSONSchema4Type | JSON
|
|
|
372
375
|
return value;
|
|
373
376
|
}
|
|
374
377
|
|
|
378
|
+
function assertSafeSetTokens(pointerPath: string, tokens: string[]) {
|
|
379
|
+
for (const token of tokens) {
|
|
380
|
+
if (unsafeSetTokens.has(token)) {
|
|
381
|
+
throw new JSONParserError(
|
|
382
|
+
`Error assigning $ref pointer "${pointerPath}". \nUnsafe JSON Pointer token "${token}" cannot be used for assignment.`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
375
388
|
function unwrapOrThrow(value: unknown) {
|
|
376
389
|
if (isHandledError(value)) {
|
|
377
390
|
throw value;
|
package/lib/resolvers/http.ts
CHANGED
|
@@ -80,6 +80,10 @@ async function download<S extends object = JSONSchema>(
|
|
|
80
80
|
redirects.push(u.href);
|
|
81
81
|
|
|
82
82
|
try {
|
|
83
|
+
if (httpOptions.safeUrlResolver && url.isUnsafeUrl(u.href)) {
|
|
84
|
+
throw new Error(`Unsafe URL blocked by safeUrlResolver: ${u.href}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
83
87
|
const res = await get(u, httpOptions);
|
|
84
88
|
if (res.status >= 400) {
|
|
85
89
|
const error = new Error(`HTTP ERROR ${res.status}`) as Error & { status?: number };
|
|
@@ -92,12 +96,16 @@ async function download<S extends object = JSONSchema>(
|
|
|
92
96
|
) as Error & { status?: number };
|
|
93
97
|
error.status = res.status;
|
|
94
98
|
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;
|
|
99
99
|
} else {
|
|
100
|
-
const
|
|
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);
|
|
101
109
|
return download(redirectTo, httpOptions, redirects);
|
|
102
110
|
}
|
|
103
111
|
} else {
|
|
@@ -130,6 +138,7 @@ async function get<S extends object = JSONSchema>(u: RequestInfo | URL, httpOpti
|
|
|
130
138
|
method: "GET",
|
|
131
139
|
headers: httpOptions.headers || {},
|
|
132
140
|
credentials: httpOptions.withCredentials ? "include" : "same-origin",
|
|
141
|
+
redirect: "manual",
|
|
133
142
|
signal: controller ? controller.signal : null,
|
|
134
143
|
});
|
|
135
144
|
if (timeoutId) {
|
|
@@ -138,3 +147,7 @@ async function get<S extends object = JSONSchema>(u: RequestInfo | URL, httpOpti
|
|
|
138
147
|
|
|
139
148
|
return response;
|
|
140
149
|
}
|
|
150
|
+
|
|
151
|
+
function getHeader(response: Response, name: string): string | null {
|
|
152
|
+
return response.headers.get(name);
|
|
153
|
+
}
|
package/lib/util/url.ts
CHANGED
|
@@ -18,6 +18,8 @@ const urlEncodePatterns = [
|
|
|
18
18
|
// RegExp patterns to URL-decode special characters for local filesystem paths
|
|
19
19
|
const urlDecodePatterns = [/%23/g, "#", /%24/g, "$", /%26/g, "&", /%2C/g, ",", /%40/g, "@"];
|
|
20
20
|
|
|
21
|
+
const unsafeDomainSuffixes = [".localhost", ".local", ".internal", ".intranet", ".corp", ".home", ".lan"];
|
|
22
|
+
|
|
21
23
|
export const parse = (u: string | URL) => new URL(u);
|
|
22
24
|
|
|
23
25
|
/**
|
|
@@ -211,60 +213,11 @@ export function isUnsafeUrl(path: string | unknown): boolean {
|
|
|
211
213
|
return false;
|
|
212
214
|
}
|
|
213
215
|
|
|
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
|
-
|
|
253
216
|
try {
|
|
254
217
|
// Try to parse as URL
|
|
255
218
|
const url = new URL(normalizedPath.startsWith("//") ? "http:" + normalizedPath : normalizedPath);
|
|
256
219
|
|
|
257
|
-
|
|
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)) {
|
|
268
221
|
return true;
|
|
269
222
|
}
|
|
270
223
|
|
|
@@ -281,11 +234,8 @@ export function isUnsafeUrl(path: string | unknown): boolean {
|
|
|
281
234
|
return false;
|
|
282
235
|
}
|
|
283
236
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (normalizedPath.includes(pattern)) {
|
|
287
|
-
return true;
|
|
288
|
-
}
|
|
237
|
+
if (containsUnsafeHostname(normalizedPath)) {
|
|
238
|
+
return true;
|
|
289
239
|
}
|
|
290
240
|
}
|
|
291
241
|
|
|
@@ -293,29 +243,198 @@ export function isUnsafeUrl(path: string | unknown): boolean {
|
|
|
293
243
|
}
|
|
294
244
|
|
|
295
245
|
/**
|
|
296
|
-
* Helper function to check if
|
|
246
|
+
* Helper function to check if a hostname is local or resolves to a non-public literal address.
|
|
297
247
|
*/
|
|
298
|
-
function
|
|
299
|
-
const
|
|
300
|
-
const match = ip.match(ipRegex);
|
|
248
|
+
function isUnsafeHostname(hostname: string): boolean {
|
|
249
|
+
const normalizedHostname = normalizeHostname(hostname);
|
|
301
250
|
|
|
302
|
-
if (!
|
|
303
|
-
return
|
|
251
|
+
if (!normalizedHostname) {
|
|
252
|
+
return true;
|
|
304
253
|
}
|
|
305
254
|
|
|
306
|
-
|
|
255
|
+
if (normalizedHostname === "localhost" || unsafeDomainSuffixes.some((suffix) => normalizedHostname.endsWith(suffix))) {
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
307
258
|
|
|
308
|
-
|
|
309
|
-
if (
|
|
310
|
-
return
|
|
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);
|
|
311
267
|
}
|
|
312
268
|
|
|
313
|
-
|
|
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 {
|
|
314
312
|
return (
|
|
315
|
-
a ===
|
|
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)
|
|
316
324
|
);
|
|
317
325
|
}
|
|
318
326
|
|
|
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
|
+
|
|
319
438
|
/**
|
|
320
439
|
* Helper function to check if a port is typically used for internal services
|
|
321
440
|
*/
|
package/package.json
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apidevtools/json-schema-ref-parser",
|
|
3
|
-
"version": "15.3.
|
|
3
|
+
"version": "15.3.6",
|
|
4
4
|
"description": "Parse, Resolve, and Dereference JSON Schema $ref pointers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "dist/lib/index.d.ts",
|
|
7
7
|
"module": "dist/lib/index.js",
|
|
8
8
|
"main": "dist/lib/index.js",
|
|
9
9
|
"scripts": {
|
|
10
|
-
"prepublishOnly": "
|
|
10
|
+
"prepublishOnly": "pnpm build",
|
|
11
11
|
"lint": "eslint lib",
|
|
12
12
|
"build": "rimraf dist && tsc",
|
|
13
13
|
"typecheck": "tsc --noEmit",
|
|
14
14
|
"prettier": "prettier --write \"**/*.+(js|jsx|ts|tsx|har||json|css|md)\"",
|
|
15
15
|
"test": "vitest --coverage",
|
|
16
16
|
"test:specific": "vitest invalid",
|
|
17
|
-
"test:node": "
|
|
17
|
+
"test:node": "pnpm test",
|
|
18
18
|
"test:browser": "node ./test/fixtures/run-browser-tests.mjs",
|
|
19
19
|
"test:update": "vitest -u",
|
|
20
20
|
"test:watch": "vitest -w"
|
|
@@ -72,32 +72,32 @@
|
|
|
72
72
|
"@types/json-schema": "^7.0.15"
|
|
73
73
|
},
|
|
74
74
|
"dependencies": {
|
|
75
|
-
"js-yaml": "^4.
|
|
75
|
+
"js-yaml": "^4.2.0"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
|
-
"@eslint/compat": "^2.0
|
|
78
|
+
"@eslint/compat": "^2.1.0",
|
|
79
79
|
"@eslint/js": "^10.0.1",
|
|
80
80
|
"@types/eslint": "^9.6.1",
|
|
81
81
|
"@types/js-yaml": "^4.0.9",
|
|
82
82
|
"@types/json-schema": "^7.0.15",
|
|
83
|
-
"@types/node": "^25",
|
|
84
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
85
|
-
"@typescript-eslint/parser": "^8.
|
|
86
|
-
"@vitest/coverage-v8": "^4.1.
|
|
83
|
+
"@types/node": "^25.9.3",
|
|
84
|
+
"@typescript-eslint/eslint-plugin": "^8.61.0",
|
|
85
|
+
"@typescript-eslint/parser": "^8.61.0",
|
|
86
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
87
87
|
"cross-env": "^10.1.0",
|
|
88
|
-
"eslint": "^10.1
|
|
88
|
+
"eslint": "^10.4.1",
|
|
89
89
|
"eslint-config-prettier": "^10.1.8",
|
|
90
90
|
"eslint-plugin-import": "^2.32.0",
|
|
91
|
-
"eslint-plugin-prettier": "^5.5.
|
|
92
|
-
"eslint-plugin-promise": "^7.
|
|
91
|
+
"eslint-plugin-prettier": "^5.5.6",
|
|
92
|
+
"eslint-plugin-promise": "^7.3.0",
|
|
93
93
|
"eslint-plugin-unused-imports": "^4.4.1",
|
|
94
|
-
"globals": "^17.
|
|
95
|
-
"jsdom": "^29.
|
|
96
|
-
"prettier": "^3.8.
|
|
94
|
+
"globals": "^17.6.0",
|
|
95
|
+
"jsdom": "^29.1.1",
|
|
96
|
+
"prettier": "^3.8.4",
|
|
97
97
|
"rimraf": "^6.1.3",
|
|
98
|
-
"typescript": "^6.0.
|
|
99
|
-
"typescript-eslint": "^8.
|
|
100
|
-
"vitest": "^4.1.
|
|
98
|
+
"typescript": "^6.0.3",
|
|
99
|
+
"typescript-eslint": "^8.61.0",
|
|
100
|
+
"vitest": "^4.1.8"
|
|
101
101
|
},
|
|
102
102
|
"release": {
|
|
103
103
|
"branches": [
|
|
@@ -110,5 +110,5 @@
|
|
|
110
110
|
"@semantic-release/github"
|
|
111
111
|
]
|
|
112
112
|
},
|
|
113
|
-
"packageManager": "
|
|
113
|
+
"packageManager": "pnpm@11.5.3"
|
|
114
114
|
}
|