@apidevtools/json-schema-ref-parser 15.3.5 → 15.4.0

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 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
- yarn add @apidevtools/json-schema-ref-parser
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
- `yarn install`
145
+ `pnpm install`
146
146
 
147
147
  3. **Run the tests**<br>
148
- `yarn test`
148
+ `pnpm test`
149
149
 
150
150
  ## License
151
151
 
@@ -13,6 +13,7 @@ export interface InventoryEntry {
13
13
  circular: any;
14
14
  extended: any;
15
15
  external: any;
16
+ nestedResource: boolean;
16
17
  indirections: any;
17
18
  }
18
19
  /**
@@ -119,6 +119,7 @@ function inventory$Ref($refParent, $refKey, path, scopeBase, dynamicIdScope, pat
119
119
  const file = url.stripHash(pointer.path);
120
120
  const hash = url.getHash(pointer.path);
121
121
  const external = file !== $refs._root$Ref.path && !$refs._aliases[file];
122
+ const nestedResource = Boolean($refs._aliases[file]) && pointer.$ref.value !== $refs._root$Ref.value;
122
123
  const extended = $Ref.isExtended$Ref($ref);
123
124
  indirections += pointer.indirections;
124
125
  const existingEntry = findInInventory(inventory, $refParent, $refKey);
@@ -143,6 +144,7 @@ function inventory$Ref($refParent, $refKey, path, scopeBase, dynamicIdScope, pat
143
144
  circular: pointer.circular, // Is this $ref pointer DIRECTLY circular? (i.e. it references itself)
144
145
  extended, // Does this $ref extend its resolved value? (i.e. it has extra properties, in addition to "$ref")
145
146
  external, // Does this $ref pointer point to a file other than the main JSON Schema file?
147
+ nestedResource, // Does this $ref resolve to an embedded schema resource with its own $id?
146
148
  indirections, // The number of indirect references that were traversed to resolve the value
147
149
  });
148
150
  // Recursively crawl the resolved value
@@ -221,8 +223,10 @@ function remap(inventory, options, rootId) {
221
223
  if (!entry.external) {
222
224
  // This $ref already resolves to the main JSON Schema file.
223
225
  // When optimizeInternalRefs is false, preserve the original internal ref path
224
- // instead of rewriting it to the fully resolved hash.
225
- if (bundleOpts.optimizeInternalRefs !== false) {
226
+ // instead of rewriting it to the fully resolved hash. References to nested
227
+ // resources must also retain their resource URI so that "#" does not point
228
+ // at the document root instead.
229
+ if (bundleOpts.optimizeInternalRefs !== false && !entry.nestedResource) {
226
230
  entry.$ref.$ref = entry.hash;
227
231
  }
228
232
  }
@@ -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 redirectTo = url.resolve(u.href, res.headers.location);
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
+ }
@@ -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
- const hostname = url.hostname.toLowerCase();
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
- // Check for localhost patterns in non-URL strings
256
- for (const pattern of localPatterns) {
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 an IP address is in a private range
220
+ * Helper function to check if a hostname is local or resolves to a non-public literal address.
266
221
  */
267
- function isPrivateIP(ip) {
268
- const ipRegex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
269
- const match = ip.match(ipRegex);
270
- if (!match) {
271
- return false;
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 [, a, b, c, d] = match.map(Number);
274
- // Validate IP format
275
- if (a > 255 || b > 255 || c > 255 || d > 255) {
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
- // Private IP ranges
279
- return (a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) // Link-local
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/bundle.ts CHANGED
@@ -20,6 +20,7 @@ export interface InventoryEntry {
20
20
  circular: any;
21
21
  extended: any;
22
22
  external: any;
23
+ nestedResource: boolean;
23
24
  indirections: any;
24
25
  }
25
26
  /**
@@ -194,6 +195,7 @@ function inventory$Ref<S extends object = JSONSchema, O extends ParserOptions<S>
194
195
  const file = url.stripHash(pointer.path);
195
196
  const hash = url.getHash(pointer.path);
196
197
  const external = file !== $refs._root$Ref.path && !$refs._aliases[file];
198
+ const nestedResource = Boolean($refs._aliases[file]) && pointer.$ref.value !== $refs._root$Ref.value;
197
199
  const extended = $Ref.isExtended$Ref($ref);
198
200
  indirections += pointer.indirections;
199
201
 
@@ -219,6 +221,7 @@ function inventory$Ref<S extends object = JSONSchema, O extends ParserOptions<S>
219
221
  circular: pointer.circular, // Is this $ref pointer DIRECTLY circular? (i.e. it references itself)
220
222
  extended, // Does this $ref extend its resolved value? (i.e. it has extra properties, in addition to "$ref")
221
223
  external, // Does this $ref pointer point to a file other than the main JSON Schema file?
224
+ nestedResource, // Does this $ref resolve to an embedded schema resource with its own $id?
222
225
  indirections, // The number of indirect references that were traversed to resolve the value
223
226
  });
224
227
 
@@ -316,8 +319,10 @@ function remap<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
316
319
  if (!entry.external) {
317
320
  // This $ref already resolves to the main JSON Schema file.
318
321
  // When optimizeInternalRefs is false, preserve the original internal ref path
319
- // instead of rewriting it to the fully resolved hash.
320
- if (bundleOpts.optimizeInternalRefs !== false) {
322
+ // instead of rewriting it to the fully resolved hash. References to nested
323
+ // resources must also retain their resource URI so that "#" does not point
324
+ // at the document root instead.
325
+ if (bundleOpts.optimizeInternalRefs !== false && !entry.nestedResource) {
321
326
  entry.$ref.$ref = entry.hash;
322
327
  }
323
328
  } else if (entry.file === file && entry.hash === hash) {
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;
@@ -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 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);
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
- 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)) {
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
- // 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;
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 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.
297
247
  */
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);
301
250
 
302
- if (!match) {
303
- return false;
251
+ if (!normalizedHostname) {
252
+ return true;
304
253
  }
305
254
 
306
- const [, a, b, c, d] = match.map(Number);
255
+ if (normalizedHostname === "localhost" || unsafeDomainSuffixes.some((suffix) => normalizedHostname.endsWith(suffix))) {
256
+ return true;
257
+ }
307
258
 
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);
311
267
  }
312
268
 
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 {
314
312
  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)
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.5",
3
+ "version": "15.4.0",
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": "yarn build",
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": "yarn test",
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.1.1"
75
+ "js-yaml": "^4.2.0"
76
76
  },
77
77
  "devDependencies": {
78
- "@eslint/compat": "^2.0.3",
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.57.2",
85
- "@typescript-eslint/parser": "^8.57.2",
86
- "@vitest/coverage-v8": "^4.1.2",
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.0",
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.5",
92
- "eslint-plugin-promise": "^7.2.1",
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.4.0",
95
- "jsdom": "^29.0.1",
96
- "prettier": "^3.8.1",
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.2",
99
- "typescript-eslint": "^8.57.2",
100
- "vitest": "^4.1.2"
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": "yarn@4.13.0"
113
+ "packageManager": "pnpm@11.5.3"
114
114
  }