@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 +3 -3
- package/dist/lib/bundle.js +10 -5
- package/dist/lib/dereference.js +10 -5
- package/dist/lib/pointer.js +18 -8
- package/dist/lib/resolve-external.js +8 -3
- package/dist/lib/resolvers/http.js +14 -6
- package/dist/lib/util/url.js +148 -62
- package/lib/bundle.ts +11 -5
- package/lib/dereference.ts +11 -5
- package/lib/pointer.ts +23 -8
- package/lib/resolve-external.ts +8 -3
- package/lib/resolvers/http.ts +18 -5
- package/lib/util/url.ts +191 -68
- package/package.json +20 -20
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/bundle.js
CHANGED
|
@@ -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 + "#",
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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.
|
package/dist/lib/dereference.js
CHANGED
|
@@ -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,
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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) {
|
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
|
*
|
|
@@ -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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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 + "#",
|
|
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 =
|
|
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 &&
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
package/lib/dereference.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
+
}
|
package/lib/resolve-external.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 &&
|
|
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
|
}
|
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
|
@@ -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
|
-
|
|
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
|
*/
|
|
@@ -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.
|
|
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": "
|
|
18
|
-
"test:browser": "
|
|
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.
|
|
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
|
}
|