@apidevtools/json-schema-ref-parser 15.3.4 → 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 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
 
@@ -12,9 +12,12 @@ import { getSchemaBasePath } from "./util/schema-resources.js";
12
12
  */
13
13
  function bundle(parser, options) {
14
14
  // console.log('Bundling $ref pointers in %s', parser.$refs._root$Ref.path);
15
+ const rootScopeBase = parser.$refs._root$Ref.dynamicIdScope
16
+ ? getSchemaBasePath(parser.$refs._root$Ref.path, parser.schema)
17
+ : parser.$refs._root$Ref.path;
15
18
  // Build an inventory of all $ref pointers in the JSON Schema
16
19
  const inventory = [];
17
- crawl(parser, "schema", parser.$refs._root$Ref.path + "#", parser.$refs._root$Ref.path, parser.$refs._root$Ref.dynamicIdScope, "#", 0, inventory, parser.$refs, options);
20
+ crawl(parser, "schema", parser.$refs._root$Ref.path + "#", rootScopeBase, parser.$refs._root$Ref.dynamicIdScope, "#", 0, inventory, parser.$refs, options);
18
21
  // Get the root schema's $id (if any) for qualifying refs inside sub-schemas with their own $id
19
22
  const rootId = parser.schema && typeof parser.schema === "object" && "$id" in parser.schema
20
23
  ? parser.schema.$id
@@ -44,7 +47,7 @@ function crawl(parent, key, path, scopeBase, dynamicIdScope, pathFromRoot, indir
44
47
  const bundleOptions = (options.bundle || {});
45
48
  const isExcludedPath = bundleOptions.excludedPathMatcher || (() => false);
46
49
  if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !isExcludedPath(pathFromRoot)) {
47
- const currentScopeBase = dynamicIdScope ? getSchemaBasePath(scopeBase, obj) : scopeBase;
50
+ const currentScopeBase = scopeBase;
48
51
  if ($Ref.isAllowed$Ref(obj)) {
49
52
  inventory$Ref(parent, key, path, currentScopeBase, dynamicIdScope, pathFromRoot, indirections, inventory, $refs, options);
50
53
  }
@@ -71,12 +74,14 @@ function crawl(parent, key, path, scopeBase, dynamicIdScope, pathFromRoot, indir
71
74
  const keyPath = Pointer.join(path, key);
72
75
  const keyPathFromRoot = Pointer.join(pathFromRoot, key);
73
76
  const value = obj[key];
77
+ const childScopeBase = dynamicIdScope && value && typeof value === "object" && !ArrayBuffer.isView(value)
78
+ ? getSchemaBasePath(currentScopeBase, value)
79
+ : currentScopeBase;
74
80
  if ($Ref.isAllowed$Ref(value)) {
75
- const valueScopeBase = dynamicIdScope ? getSchemaBasePath(currentScopeBase, value) : currentScopeBase;
76
- inventory$Ref(obj, key, keyPath, valueScopeBase, dynamicIdScope, keyPathFromRoot, indirections, inventory, $refs, options);
81
+ inventory$Ref(obj, key, keyPath, childScopeBase, dynamicIdScope, keyPathFromRoot, indirections, inventory, $refs, options);
77
82
  }
78
83
  else {
79
- crawl(obj, key, keyPath, currentScopeBase, dynamicIdScope, keyPathFromRoot, indirections, inventory, $refs, options);
84
+ crawl(obj, key, keyPath, childScopeBase, dynamicIdScope, keyPathFromRoot, indirections, inventory, $refs, options);
80
85
  }
81
86
  // We need to ensure that we have an object to work with here because we may be crawling
82
87
  // an `examples` schema and `value` may be nullish.
@@ -13,8 +13,11 @@ export default dereference;
13
13
  */
14
14
  function dereference(parser, options) {
15
15
  const start = Date.now();
16
+ const rootScopeBase = parser.$refs._root$Ref.dynamicIdScope
17
+ ? getSchemaBasePath(parser.$refs._root$Ref.path, parser.schema)
18
+ : parser.$refs._root$Ref.path;
16
19
  // console.log('Dereferencing $ref pointers in %s', parser.$refs._root$Ref.path);
17
- const dereferenced = crawl(parser.schema, parser.$refs._root$Ref.path, parser.$refs._root$Ref.path, parser.$refs._root$Ref.dynamicIdScope, "#", new Set(), new Set(), new Map(), parser.$refs, options, start, 0);
20
+ const dereferenced = crawl(parser.schema, parser.$refs._root$Ref.path, rootScopeBase, parser.$refs._root$Ref.dynamicIdScope, "#", new Set(), new Set(), new Map(), parser.$refs, options, start, 0);
18
21
  parser.$refs.circular = dereferenced.circular;
19
22
  parser.schema = dereferenced.value;
20
23
  }
@@ -52,7 +55,7 @@ function crawl(obj, path, scopeBase, dynamicIdScope, pathFromRoot, parents, proc
52
55
  if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !isExcludedPath(pathFromRoot)) {
53
56
  parents.add(obj);
54
57
  processedObjects.add(obj);
55
- const currentScopeBase = dynamicIdScope ? getSchemaBasePath(scopeBase, obj) : scopeBase;
58
+ const currentScopeBase = scopeBase;
56
59
  if ($Ref.isAllowed$Ref(obj, options)) {
57
60
  dereferenced = dereference$Ref(obj, path, currentScopeBase, dynamicIdScope, pathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime, depth);
58
61
  result.circular = dereferenced.circular;
@@ -67,10 +70,12 @@ function crawl(obj, path, scopeBase, dynamicIdScope, pathFromRoot, parents, proc
67
70
  continue;
68
71
  }
69
72
  const value = obj[key];
73
+ const childScopeBase = dynamicIdScope && value && typeof value === "object" && !ArrayBuffer.isView(value)
74
+ ? getSchemaBasePath(currentScopeBase, value)
75
+ : currentScopeBase;
70
76
  let circular;
71
77
  if ($Ref.isAllowed$Ref(value, options)) {
72
- const valueScopeBase = dynamicIdScope ? getSchemaBasePath(currentScopeBase, value) : currentScopeBase;
73
- dereferenced = dereference$Ref(value, keyPath, valueScopeBase, dynamicIdScope, keyPathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime, depth);
78
+ dereferenced = dereference$Ref(value, keyPath, childScopeBase, dynamicIdScope, keyPathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime, depth);
74
79
  circular = dereferenced.circular;
75
80
  // Avoid pointless mutations; breaks frozen objects to no profit
76
81
  if (obj[key] !== dereferenced.value) {
@@ -107,7 +112,7 @@ function crawl(obj, path, scopeBase, dynamicIdScope, pathFromRoot, parents, proc
107
112
  }
108
113
  else {
109
114
  if (!parents.has(value)) {
110
- dereferenced = crawl(value, keyPath, currentScopeBase, dynamicIdScope, keyPathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime, depth + 1);
115
+ dereferenced = crawl(value, keyPath, childScopeBase, dynamicIdScope, keyPathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime, depth + 1);
111
116
  circular = dereferenced.circular;
112
117
  // Avoid pointless mutations; breaks frozen objects to no profit
113
118
  if (obj[key] !== dereferenced.value) {
@@ -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
  *
@@ -74,7 +75,7 @@ class Pointer {
74
75
  const found = [];
75
76
  // Crawl the object, one token at a time
76
77
  this.value = unwrapOrThrow(obj);
77
- if (this.$ref.dynamicIdScope) {
78
+ if (this.$ref.dynamicIdScope && !isAliasedResource(this.$ref)) {
78
79
  this.scopeBase = getSchemaBasePath(this.scopeBase, this.value);
79
80
  }
80
81
  for (let i = 0; i < tokens.length; i++) {
@@ -161,9 +162,10 @@ 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
- if (this.$ref.dynamicIdScope) {
168
+ if (this.$ref.dynamicIdScope && !isAliasedResource(this.$ref)) {
167
169
  this.scopeBase = getSchemaBasePath(this.scopeBase, this.value);
168
170
  }
169
171
  for (let i = 0; i < tokens.length - 1; i++) {
@@ -273,9 +275,6 @@ function resolveIf$Ref(pointer, options, pathFromRoot) {
273
275
  // This JSON reference "extends" the resolved value, rather than simply pointing to it.
274
276
  // So the resolved path does NOT change. Just the value does.
275
277
  pointer.value = $Ref.dereference(pointer.value, resolved.value, options);
276
- if (pointer.$ref.dynamicIdScope) {
277
- pointer.scopeBase = getSchemaBasePath(pointer.scopeBase, pointer.value);
278
- }
279
278
  return false;
280
279
  }
281
280
  else {
@@ -283,9 +282,10 @@ function resolveIf$Ref(pointer, options, pathFromRoot) {
283
282
  pointer.$ref = resolved.$ref;
284
283
  pointer.path = resolved.path;
285
284
  pointer.value = resolved.value;
286
- pointer.scopeBase = pointer.$ref.dynamicIdScope
287
- ? getSchemaBasePath(pointer.$ref.path, pointer.value)
288
- : pointer.$ref.path;
285
+ // `pointer.$ref.path` is already the canonical location of the resolved resource.
286
+ // Re-applying the resource's own `$id` here would duplicate nested path segments
287
+ // such as `nested/nested/foo.json`.
288
+ pointer.scopeBase = pointer.$ref.path;
289
289
  }
290
290
  return true;
291
291
  }
@@ -318,6 +318,13 @@ function setValue(pointer, token, value) {
318
318
  }
319
319
  return value;
320
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
+ }
321
328
  function unwrapOrThrow(value) {
322
329
  if (isHandledError(value)) {
323
330
  throw value;
@@ -327,3 +334,6 @@ function unwrapOrThrow(value) {
327
334
  function isRootPath(pathFromRoot) {
328
335
  return typeof pathFromRoot == "string" && Pointer.parse(pathFromRoot).length == 0;
329
336
  }
337
+ function isAliasedResource($ref) {
338
+ return Boolean($ref.path && $ref.path in $ref.$refs._aliases);
339
+ }
@@ -20,8 +20,11 @@ function resolveExternal(parser, options) {
20
20
  return Promise.resolve();
21
21
  }
22
22
  try {
23
+ const rootScopeBase = parser.$refs._root$Ref.dynamicIdScope
24
+ ? getSchemaBasePath(parser.$refs._root$Ref.path, parser.schema)
25
+ : parser.$refs._root$Ref.path;
23
26
  // console.log('Resolving $ref pointers in %s', parser.$refs._root$Ref.path);
24
- const promises = crawl(parser.schema, parser.$refs._root$Ref.path + "#", parser.$refs._root$Ref.path, parser.$refs._root$Ref.dynamicIdScope, parser.$refs, options);
27
+ const promises = crawl(parser.schema, parser.$refs._root$Ref.path + "#", rootScopeBase, parser.$refs._root$Ref.dynamicIdScope, parser.$refs, options);
25
28
  return Promise.all(promises);
26
29
  }
27
30
  catch (e) {
@@ -49,7 +52,7 @@ function crawl(obj, path, scopeBase, dynamicIdScope, $refs, options, seen, exter
49
52
  let promises = [];
50
53
  if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !seen.has(obj)) {
51
54
  seen.add(obj); // Track previously seen objects to avoid infinite recursion
52
- const currentScopeBase = dynamicIdScope ? getSchemaBasePath(scopeBase, obj) : scopeBase;
55
+ const currentScopeBase = scopeBase;
53
56
  if ($Ref.isExternal$Ref(obj)) {
54
57
  promises.push(resolve$Ref(obj, path, currentScopeBase, dynamicIdScope, $refs, options));
55
58
  }
@@ -57,7 +60,9 @@ function crawl(obj, path, scopeBase, dynamicIdScope, $refs, options, seen, exter
57
60
  for (const key of keys) {
58
61
  const keyPath = Pointer.join(path, key);
59
62
  const value = obj[key];
60
- const childScopeBase = dynamicIdScope && $Ref.isExternal$Ref(value) ? getSchemaBasePath(currentScopeBase, value) : currentScopeBase;
63
+ const childScopeBase = dynamicIdScope && value && typeof value === "object" && !ArrayBuffer.isView(value)
64
+ ? getSchemaBasePath(currentScopeBase, value)
65
+ : currentScopeBase;
61
66
  promises = promises.concat(crawl(value, keyPath, childScopeBase, dynamicIdScope, $refs, options, seen, external));
62
67
  }
63
68
  }
@@ -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
+ }
@@ -4,7 +4,7 @@ const protocolPattern = /^(\w{2,}):\/\//i;
4
4
  const jsonPointerSlash = /~1/g;
5
5
  const jsonPointerTilde = /~0/g;
6
6
  import { isWindows } from "./is-windows.js";
7
- const isAbsoluteWin32Path = /^[a-zA-Z]:\\/;
7
+ const isAbsoluteWin32Path = /^[a-zA-Z]:[\\/]/;
8
8
  // RegExp patterns to URL-encode special characters in local filesystem paths
9
9
  const urlEncodePatterns = [
10
10
  [/\?/g, "%3F"],
@@ -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
@@ -381,6 +464,9 @@ export function fromFileSystemPath(path) {
381
464
  * Converts a URL to a local filesystem path.
382
465
  */
383
466
  export function toFileSystemPath(path, keepFileProtocol) {
467
+ // Bare "%" characters are valid in filesystem paths, but they make `decodeURI` throw.
468
+ // Escape only the non-encoded ones so percent-encoded sequences still decode normally.
469
+ path = path.replace(/%(?![0-9A-Fa-f]{2})/g, "%25");
384
470
  // Step 1: `decodeURI` will decode characters such as Cyrillic characters, spaces, etc.
385
471
  path = decodeURI(path);
386
472
  // Step 2: Manually decode characters that are not decoded by `decodeURI`.
package/lib/bundle.ts CHANGED
@@ -35,6 +35,9 @@ function bundle<S extends object = JSONSchema, O extends ParserOptions<S> = Pars
35
35
  options: O,
36
36
  ) {
37
37
  // console.log('Bundling $ref pointers in %s', parser.$refs._root$Ref.path);
38
+ const rootScopeBase = parser.$refs._root$Ref.dynamicIdScope
39
+ ? getSchemaBasePath(parser.$refs._root$Ref.path!, parser.schema)
40
+ : parser.$refs._root$Ref.path!;
38
41
 
39
42
  // Build an inventory of all $ref pointers in the JSON Schema
40
43
  const inventory: InventoryEntry[] = [];
@@ -42,7 +45,7 @@ function bundle<S extends object = JSONSchema, O extends ParserOptions<S> = Pars
42
45
  parser,
43
46
  "schema",
44
47
  parser.$refs._root$Ref.path + "#",
45
- parser.$refs._root$Ref.path!,
48
+ rootScopeBase,
46
49
  parser.$refs._root$Ref.dynamicIdScope,
47
50
  "#",
48
51
  0,
@@ -96,7 +99,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
96
99
  const isExcludedPath = bundleOptions.excludedPathMatcher || (() => false);
97
100
 
98
101
  if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !isExcludedPath(pathFromRoot)) {
99
- const currentScopeBase = dynamicIdScope ? getSchemaBasePath(scopeBase, obj) : scopeBase;
102
+ const currentScopeBase = scopeBase;
100
103
  if ($Ref.isAllowed$Ref(obj)) {
101
104
  inventory$Ref(parent, key, path, currentScopeBase, dynamicIdScope, pathFromRoot, indirections, inventory, $refs, options);
102
105
  } else {
@@ -121,14 +124,17 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
121
124
  const keyPath = Pointer.join(path, key);
122
125
  const keyPathFromRoot = Pointer.join(pathFromRoot, key);
123
126
  const value = obj[key];
127
+ const childScopeBase =
128
+ dynamicIdScope && value && typeof value === "object" && !ArrayBuffer.isView(value)
129
+ ? getSchemaBasePath(currentScopeBase, value)
130
+ : currentScopeBase;
124
131
 
125
132
  if ($Ref.isAllowed$Ref(value)) {
126
- const valueScopeBase = dynamicIdScope ? getSchemaBasePath(currentScopeBase, value) : currentScopeBase;
127
133
  inventory$Ref(
128
134
  obj,
129
135
  key,
130
136
  keyPath,
131
- valueScopeBase,
137
+ childScopeBase,
132
138
  dynamicIdScope,
133
139
  keyPathFromRoot,
134
140
  indirections,
@@ -137,7 +143,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
137
143
  options,
138
144
  );
139
145
  } else {
140
- crawl(obj, key, keyPath, currentScopeBase, dynamicIdScope, keyPathFromRoot, indirections, inventory, $refs, options);
146
+ crawl(obj, key, keyPath, childScopeBase, dynamicIdScope, keyPathFromRoot, indirections, inventory, $refs, options);
141
147
  }
142
148
 
143
149
  // We need to ensure that we have an object to work with here because we may be crawling
@@ -21,11 +21,14 @@ function dereference<S extends object = JSONSchema, O extends ParserOptions<S> =
21
21
  options: O,
22
22
  ) {
23
23
  const start = Date.now();
24
+ const rootScopeBase = parser.$refs._root$Ref.dynamicIdScope
25
+ ? getSchemaBasePath(parser.$refs._root$Ref.path!, parser.schema)
26
+ : parser.$refs._root$Ref.path!;
24
27
  // console.log('Dereferencing $ref pointers in %s', parser.$refs._root$Ref.path);
25
28
  const dereferenced = crawl<S, O>(
26
29
  parser.schema,
27
30
  parser.$refs._root$Ref.path!,
28
- parser.$refs._root$Ref.path!,
31
+ rootScopeBase,
29
32
  parser.$refs._root$Ref.dynamicIdScope,
30
33
  "#",
31
34
  new Set(),
@@ -92,7 +95,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
92
95
  if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !isExcludedPath(pathFromRoot)) {
93
96
  parents.add(obj);
94
97
  processedObjects.add(obj);
95
- const currentScopeBase = dynamicIdScope ? getSchemaBasePath(scopeBase, obj) : scopeBase;
98
+ const currentScopeBase = scopeBase;
96
99
 
97
100
  if ($Ref.isAllowed$Ref(obj, options)) {
98
101
  dereferenced = dereference$Ref(
@@ -123,14 +126,17 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
123
126
  }
124
127
 
125
128
  const value = obj[key];
129
+ const childScopeBase =
130
+ dynamicIdScope && value && typeof value === "object" && !ArrayBuffer.isView(value)
131
+ ? getSchemaBasePath(currentScopeBase, value)
132
+ : currentScopeBase;
126
133
  let circular;
127
134
 
128
135
  if ($Ref.isAllowed$Ref(value, options)) {
129
- const valueScopeBase = dynamicIdScope ? getSchemaBasePath(currentScopeBase, value) : currentScopeBase;
130
136
  dereferenced = dereference$Ref(
131
137
  value,
132
138
  keyPath,
133
- valueScopeBase,
139
+ childScopeBase,
134
140
  dynamicIdScope,
135
141
  keyPathFromRoot,
136
142
  parents,
@@ -183,7 +189,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
183
189
  dereferenced = crawl(
184
190
  value,
185
191
  keyPath,
186
- currentScopeBase,
192
+ childScopeBase,
187
193
  dynamicIdScope,
188
194
  keyPathFromRoot,
189
195
  parents,
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.
@@ -95,7 +96,7 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
95
96
 
96
97
  // Crawl the object, one token at a time
97
98
  this.value = unwrapOrThrow(obj);
98
- if (this.$ref.dynamicIdScope) {
99
+ if (this.$ref.dynamicIdScope && !isAliasedResource(this.$ref)) {
99
100
  this.scopeBase = getSchemaBasePath(this.scopeBase, this.value);
100
101
  }
101
102
 
@@ -194,9 +195,11 @@ 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
- if (this.$ref.dynamicIdScope) {
202
+ if (this.$ref.dynamicIdScope && !isAliasedResource(this.$ref)) {
200
203
  this.scopeBase = getSchemaBasePath(this.scopeBase, this.value);
201
204
  }
202
205
 
@@ -327,18 +330,16 @@ function resolveIf$Ref<S extends object = JSONSchema, O extends ParserOptions<S>
327
330
  // This JSON reference "extends" the resolved value, rather than simply pointing to it.
328
331
  // So the resolved path does NOT change. Just the value does.
329
332
  pointer.value = $Ref.dereference(pointer.value, resolved.value, options);
330
- if (pointer.$ref.dynamicIdScope) {
331
- pointer.scopeBase = getSchemaBasePath(pointer.scopeBase, pointer.value);
332
- }
333
333
  return false;
334
334
  } else {
335
335
  // Resolve the reference
336
336
  pointer.$ref = resolved.$ref;
337
337
  pointer.path = resolved.path;
338
338
  pointer.value = resolved.value;
339
- pointer.scopeBase = pointer.$ref.dynamicIdScope
340
- ? getSchemaBasePath(pointer.$ref.path!, pointer.value)
341
- : pointer.$ref.path!;
339
+ // `pointer.$ref.path` is already the canonical location of the resolved resource.
340
+ // Re-applying the resource's own `$id` here would duplicate nested path segments
341
+ // such as `nested/nested/foo.json`.
342
+ pointer.scopeBase = pointer.$ref.path!;
342
343
  }
343
344
 
344
345
  return true;
@@ -374,6 +375,16 @@ function setValue(pointer: Pointer, token: string, value: JSONSchema4Type | JSON
374
375
  return value;
375
376
  }
376
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
+
377
388
  function unwrapOrThrow(value: unknown) {
378
389
  if (isHandledError(value)) {
379
390
  throw value;
@@ -385,3 +396,7 @@ function unwrapOrThrow(value: unknown) {
385
396
  function isRootPath(pathFromRoot: string | unknown): boolean {
386
397
  return typeof pathFromRoot == "string" && Pointer.parse(pathFromRoot).length == 0;
387
398
  }
399
+
400
+ function isAliasedResource($ref: $Ref<any, any>) {
401
+ return Boolean($ref.path && $ref.path in $ref.$refs._aliases);
402
+ }
@@ -29,11 +29,14 @@ function resolveExternal<S extends object = JSONSchema, O extends ParserOptions<
29
29
  }
30
30
 
31
31
  try {
32
+ const rootScopeBase = parser.$refs._root$Ref.dynamicIdScope
33
+ ? getSchemaBasePath(parser.$refs._root$Ref.path!, parser.schema)
34
+ : parser.$refs._root$Ref.path!;
32
35
  // console.log('Resolving $ref pointers in %s', parser.$refs._root$Ref.path);
33
36
  const promises = crawl(
34
37
  parser.schema,
35
38
  parser.$refs._root$Ref.path + "#",
36
- parser.$refs._root$Ref.path!,
39
+ rootScopeBase,
37
40
  parser.$refs._root$Ref.dynamicIdScope,
38
41
  parser.$refs,
39
42
  options,
@@ -75,7 +78,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
75
78
 
76
79
  if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !seen.has(obj)) {
77
80
  seen.add(obj); // Track previously seen objects to avoid infinite recursion
78
- const currentScopeBase = dynamicIdScope ? getSchemaBasePath(scopeBase, obj) : scopeBase;
81
+ const currentScopeBase = scopeBase;
79
82
  if ($Ref.isExternal$Ref(obj)) {
80
83
  promises.push(resolve$Ref<S, O>(obj, path, currentScopeBase, dynamicIdScope, $refs, options));
81
84
  }
@@ -85,7 +88,9 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
85
88
  const keyPath = Pointer.join(path, key);
86
89
  const value = obj[key as keyof typeof obj] as string | JSONSchema | Buffer | undefined;
87
90
  const childScopeBase =
88
- dynamicIdScope && $Ref.isExternal$Ref(value) ? getSchemaBasePath(currentScopeBase, value) : currentScopeBase;
91
+ dynamicIdScope && value && typeof value === "object" && !ArrayBuffer.isView(value)
92
+ ? getSchemaBasePath(currentScopeBase, value)
93
+ : currentScopeBase;
89
94
  promises = promises.concat(crawl(value, keyPath, childScopeBase, dynamicIdScope, $refs, options, seen, external));
90
95
  }
91
96
  }
@@ -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
@@ -7,7 +7,7 @@ const jsonPointerTilde = /~0/g;
7
7
 
8
8
  import { isWindows } from "./is-windows.js";
9
9
 
10
- const isAbsoluteWin32Path = /^[a-zA-Z]:\\/;
10
+ const isAbsoluteWin32Path = /^[a-zA-Z]:[\\/]/;
11
11
 
12
12
  // RegExp patterns to URL-encode special characters in local filesystem paths
13
13
  const urlEncodePatterns = [
@@ -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
  */
@@ -426,6 +545,10 @@ export function fromFileSystemPath(path: string) {
426
545
  * Converts a URL to a local filesystem path.
427
546
  */
428
547
  export function toFileSystemPath(path: string | undefined, keepFileProtocol?: boolean): string {
548
+ // Bare "%" characters are valid in filesystem paths, but they make `decodeURI` throw.
549
+ // Escape only the non-encoded ones so percent-encoded sequences still decode normally.
550
+ path = path!.replace(/%(?![0-9A-Fa-f]{2})/g, "%25");
551
+
429
552
  // Step 1: `decodeURI` will decode characters such as Cyrillic characters, spaces, etc.
430
553
  path = decodeURI(path!);
431
554
 
package/package.json CHANGED
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "name": "@apidevtools/json-schema-ref-parser",
3
- "version": "15.3.4",
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": "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",
18
- "test:browser": "cross-env BROWSER=\"true\" yarn test",
17
+ "test:node": "pnpm test",
18
+ "test:browser": "node ./test/fixtures/run-browser-tests.mjs",
19
19
  "test:update": "vitest -u",
20
20
  "test:watch": "vitest -w"
21
21
  },
@@ -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
  }