fast-uri 4.2.1 → 4.2.3
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/.github/workflows/release.yml +34 -0
- package/README.md +9 -1
- package/index.js +146 -30
- package/lib/schemes.js +4 -1
- package/lib/utils.js +87 -21
- package/package.json +2 -2
- package/test/c0-reference-security.test.js +137 -0
- package/test/host-delimiter-security.test.js +94 -0
- package/test/host-encoding.test.js +60 -0
- package/test/parse.test.js +7 -5
- package/test/relative-normalization-security.test.js +152 -0
- package/test/reserved-path-normalization.test.js +32 -4
- package/test/scheme-validation.test.js +51 -12
- package/test/security.test.js +2 -2
- package/test/uri-js.test.js +7 -5
- package/test/urn-full-input.test.js +18 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Release the package
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
semver:
|
|
7
|
+
description: 'Release bump type'
|
|
8
|
+
required: true
|
|
9
|
+
type: choice
|
|
10
|
+
options:
|
|
11
|
+
- patch
|
|
12
|
+
- minor
|
|
13
|
+
- major
|
|
14
|
+
- prepatch
|
|
15
|
+
- preminor
|
|
16
|
+
- premajor
|
|
17
|
+
- prerelease
|
|
18
|
+
preid:
|
|
19
|
+
description: 'Prerelease identifier (e.g. rc), used by the pre* bump types'
|
|
20
|
+
required: false
|
|
21
|
+
type: string
|
|
22
|
+
|
|
23
|
+
permissions: {}
|
|
24
|
+
|
|
25
|
+
jobs:
|
|
26
|
+
release:
|
|
27
|
+
permissions:
|
|
28
|
+
id-token: write # required for npm provenance via OIDC
|
|
29
|
+
contents: write # required to push the release commit and the tag
|
|
30
|
+
uses: fastify/workflows/.github/workflows/reusable-release.yml@b0a2ab2d8259b6bd574b804cd6874bbd75c3577c
|
|
31
|
+
with:
|
|
32
|
+
semver: ${{ inputs.semver }}
|
|
33
|
+
preid: ${{ inputs.preid }}
|
|
34
|
+
dist-tag: ${{ startsWith(inputs.semver, 'pre') && 'next' || 'latest' }}
|
package/README.md
CHANGED
|
@@ -13,7 +13,11 @@ Dependency-free RFC 3986 URI toolbox.
|
|
|
13
13
|
|
|
14
14
|
All of the above functions can accept an additional options argument that is an object that can contain one or more of the following properties:
|
|
15
15
|
|
|
16
|
-
Malformed authorities
|
|
16
|
+
Malformed authorities, out-of-range ports, and raw leading or trailing C0 controls or spaces (U+0000–U+0020) are reported through the parsed component's `error` field. `normalize()` leaves these malformed string inputs unchanged, and `equal()` returns `false`. `resolve()` rejects them in either input and rejects HTTP(S) results with an empty host. Percent-encoded and interior component data remain valid.
|
|
17
|
+
|
|
18
|
+
Schemes are never percent-decoded. RFC 3986 does not allow percent-encoding in a scheme, so an input such as `%6Aavascript:alert(1)` is reported as malformed (`error: 'URI scheme is malformed.'`) instead of being parsed as `javascript:`. `normalize()` leaves it unchanged, `equal()` returns `false`, and `resolve()` and `serialize()` throw.
|
|
19
|
+
|
|
20
|
+
For schemes with domain hosts (`http`, `https`, `ws`, `wss`, or any scheme with the `domainHost` option), a host containing an ASCII character that RFC 3986 does not allow in a reg-name (for example `@`, TAB, LF, CR, space, `"`, `<`, `>`, `^`, `` ` ``, `{`, `|`, `}`) is reported as malformed (`error: 'URI host is malformed.'`). It is never passed through a WHATWG URL parser that could treat `@` as a delimiter or strip whitespace. `normalize()` leaves such input unchanged, `equal()` returns `false`, and `resolve()` throws.
|
|
17
21
|
|
|
18
22
|
* `scheme` (string)
|
|
19
23
|
Indicates the scheme that the URI should be treated as, overriding the URI's normal scheme parsing behavior.
|
|
@@ -21,6 +25,8 @@ Malformed authorities and out-of-range ports are reported through the parsed com
|
|
|
21
25
|
* `reference` (string)
|
|
22
26
|
If set to `"suffix"`, it indicates that the URI is in the suffix format and the parser will use the option's `scheme` property to determine the URI's scheme.
|
|
23
27
|
|
|
28
|
+
A suffix reference is written without its scheme and without the `//` authority marker (RFC 3986 section 4.5). Such a reference is inherently ambiguous: an authority component cannot always be distinguished from a path. When the input carries an authority of the form `host:port` (for example `//javascript:80/x`) or a userinfo containing a colon, the serialized suffix output can begin with text that reparses as a URI scheme (for example `javascript:80/x`). Do not treat suffix output as a trusted absolute URL, and do not render it or use it in a scheme allowlist without first validating it with `parse()`. If you need an unambiguous result, use the default (non-suffix) serialization.
|
|
29
|
+
|
|
24
30
|
* `tolerant` (boolean, false)
|
|
25
31
|
If set to `true`, the parser will relax URI resolving rules.
|
|
26
32
|
|
|
@@ -80,6 +86,8 @@ uri.normalize('http://example.com/a%2Fb')
|
|
|
80
86
|
|
|
81
87
|
Reserved path escapes such as `%2F` and `%2E` are preserved as path data during normalization and comparison.
|
|
82
88
|
|
|
89
|
+
Dot segments in relative references are removed without changing what the reference resolves to: leading `..` segments that cannot be collapsed are kept, a relative path is never turned into a root-relative one, and a first-segment colon exposed by the removal is kept behind a leading `./` so the reference is not reparsed as a scheme (`a/../b` → `b`, `../x` → `../x`, `x/..` → `./`, `a/../a:b` → `./a:b`).
|
|
90
|
+
|
|
83
91
|
### Equal
|
|
84
92
|
|
|
85
93
|
```js
|
package/index.js
CHANGED
|
@@ -1,21 +1,29 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, serializePathEncoding, normalizeQueryFragmentEncoding, encodeQuery, encodeFragment,
|
|
3
|
+
const { normalizeIPv6, removeDotSegments, removeDotSegmentsRelative, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, serializePathEncoding, normalizeQueryFragmentEncoding, encodeQuery, encodeFragment, encodeHost, isIPv4, nonSimpleDomain } = require('./lib/utils')
|
|
4
4
|
const { SCHEMES, getSchemeHandler } = require('./lib/schemes')
|
|
5
5
|
|
|
6
6
|
const VALID_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*$/u
|
|
7
7
|
const MALFORMED_SCHEME_ERROR = 'URI scheme is malformed.'
|
|
8
|
+
const MALFORMED_HOST_ERROR = 'URI host is malformed.'
|
|
9
|
+
// ASCII characters outside the RFC 3986 reg-name set (unreserved, sub-delims
|
|
10
|
+
// and "%" for pct-encoded): C0 controls, space, DEL, and "#/:<>?@[\]^`{|}.
|
|
11
|
+
const FORBIDDEN_DOMAIN_HOST_CHARACTER = /[\0-\x20"#/:<>?@[\\\]^`{|}\x7F]/u
|
|
8
12
|
|
|
9
13
|
/**
|
|
14
|
+
* RFC 3986 section 3.1 does not allow percent-encoding in a scheme, so the
|
|
15
|
+
* scheme is validated as-is and never decoded. Decoding would let inert input
|
|
16
|
+
* such as `%6Aavascript:` become a live `javascript:` scheme.
|
|
17
|
+
*
|
|
10
18
|
* @param {string} scheme
|
|
11
19
|
* @returns {string}
|
|
12
20
|
*/
|
|
13
|
-
function
|
|
14
|
-
const
|
|
15
|
-
if (!VALID_SCHEME.test(
|
|
21
|
+
function validateScheme (scheme) {
|
|
22
|
+
const stringScheme = String(scheme)
|
|
23
|
+
if (!VALID_SCHEME.test(stringScheme)) {
|
|
16
24
|
throw new TypeError(MALFORMED_SCHEME_ERROR)
|
|
17
25
|
}
|
|
18
|
-
return
|
|
26
|
+
return stringScheme
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
/**
|
|
@@ -44,6 +52,7 @@ function resolve (baseURI, relativeURI, options) {
|
|
|
44
52
|
const {
|
|
45
53
|
parsed: baseParsed,
|
|
46
54
|
malformedAuthorityOrPort: baseMalformed,
|
|
55
|
+
malformedReferenceEdge: baseMalformedReferenceEdge,
|
|
47
56
|
malformedPercentEncoding: baseMalformedPercentEncoding,
|
|
48
57
|
malformedSchemeSpecific: baseMalformedSchemeSpecific,
|
|
49
58
|
malformedHost: baseMalformedHost,
|
|
@@ -52,6 +61,7 @@ function resolve (baseURI, relativeURI, options) {
|
|
|
52
61
|
const {
|
|
53
62
|
parsed: relativeParsed,
|
|
54
63
|
malformedAuthorityOrPort: relativeMalformed,
|
|
64
|
+
malformedReferenceEdge: relativeMalformedReferenceEdge,
|
|
55
65
|
malformedPercentEncoding: relativeMalformedPercentEncoding,
|
|
56
66
|
malformedSchemeSpecific: relativeMalformedSchemeSpecific,
|
|
57
67
|
malformedHost: relativeMalformedHost,
|
|
@@ -60,6 +70,8 @@ function resolve (baseURI, relativeURI, options) {
|
|
|
60
70
|
if (
|
|
61
71
|
baseMalformed ||
|
|
62
72
|
relativeMalformed ||
|
|
73
|
+
baseMalformedReferenceEdge ||
|
|
74
|
+
relativeMalformedReferenceEdge ||
|
|
63
75
|
baseMalformedPercentEncoding ||
|
|
64
76
|
relativeMalformedPercentEncoding ||
|
|
65
77
|
baseMalformedSchemeSpecific ||
|
|
@@ -74,8 +86,22 @@ function resolve (baseURI, relativeURI, options) {
|
|
|
74
86
|
const resolved = resolveComponent(baseParsed, relativeParsed, schemelessOptions, true)
|
|
75
87
|
const resolvedSchemeHandler = getSchemeHandler((options && options.scheme) || resolved.scheme)
|
|
76
88
|
const resolvedHost = resolved.host
|
|
89
|
+
// Inputs are parsed scheme-neutrally during resolution, so enforce the final
|
|
90
|
+
// HTTP scheme's required host before serializing an invalid HTTP URI.
|
|
91
|
+
if (
|
|
92
|
+
(resolved.scheme === 'http' || resolved.scheme === 'https') &&
|
|
93
|
+
!resolvedHost
|
|
94
|
+
) {
|
|
95
|
+
throw new Error('HTTP URIs must have a host.')
|
|
96
|
+
}
|
|
77
97
|
const resolvedHostIsIP = resolvedHost !== undefined && resolvedHost !== '' &&
|
|
78
98
|
(isIPv4(resolvedHost) || normalizeIPv6(resolvedHost).isIPV6)
|
|
99
|
+
// Inputs are parsed scheme-neutrally, so delimiters and controls in a domain
|
|
100
|
+
// host are only detected here, once the final scheme is known. They always
|
|
101
|
+
// fail closed, including in percent-encoded hosts.
|
|
102
|
+
if (hasForbiddenDomainHost(resolved, options || {}, resolvedSchemeHandler, resolvedHostIsIP)) {
|
|
103
|
+
throw new Error(MALFORMED_HOST_ERROR)
|
|
104
|
+
}
|
|
79
105
|
canonicalizeHost(resolved, options || {}, resolvedSchemeHandler, resolvedHostIsIP)
|
|
80
106
|
// Percent escapes in an ASCII reg-name are encoded data. The WHATWG hostname
|
|
81
107
|
// parser can reject them even though fast-uri preserves them safely as RFC
|
|
@@ -86,6 +112,9 @@ function resolve (baseURI, relativeURI, options) {
|
|
|
86
112
|
throw new Error(resolved.error)
|
|
87
113
|
}
|
|
88
114
|
schemelessOptions.skipEscape = true
|
|
115
|
+
if (resolvedSchemeHandler && resolvedSchemeHandler.absolutePath) {
|
|
116
|
+
schemelessOptions.absolutePath = true
|
|
117
|
+
}
|
|
89
118
|
return serialize(resolved, schemelessOptions)
|
|
90
119
|
}
|
|
91
120
|
|
|
@@ -127,7 +156,10 @@ function resolveComponent (base, relative, options, skipNormalization) {
|
|
|
127
156
|
target.userinfo = relative.userinfo
|
|
128
157
|
copyHost(target, relative)
|
|
129
158
|
target.port = relative.port
|
|
130
|
-
|
|
159
|
+
const relativeSchemeHandler = getSchemeHandler(relative.scheme)
|
|
160
|
+
target.path = relativeSchemeHandler && relativeSchemeHandler.absolutePath
|
|
161
|
+
? relative.path || ''
|
|
162
|
+
: removeDotSegments(relative.path || '')
|
|
131
163
|
target.query = relative.query
|
|
132
164
|
} else {
|
|
133
165
|
if (relative.userinfo !== undefined || relative.host !== undefined || relative.port !== undefined) {
|
|
@@ -149,14 +181,17 @@ function resolveComponent (base, relative, options, skipNormalization) {
|
|
|
149
181
|
if (relative.path[0] === '/') {
|
|
150
182
|
target.path = removeDotSegments(relative.path)
|
|
151
183
|
} else {
|
|
152
|
-
|
|
184
|
+
const baseHasAuthority = base.userinfo !== undefined || base.host !== undefined || base.port !== undefined
|
|
185
|
+
if (baseHasAuthority && !base.path) {
|
|
153
186
|
target.path = '/' + relative.path
|
|
154
187
|
} else if (!base.path) {
|
|
155
188
|
target.path = relative.path
|
|
156
189
|
} else {
|
|
157
190
|
target.path = base.path.slice(0, base.path.lastIndexOf('/') + 1) + relative.path
|
|
158
191
|
}
|
|
159
|
-
target.path =
|
|
192
|
+
target.path = base.scheme === undefined && !baseHasAuthority && target.path[0] !== '/'
|
|
193
|
+
? removeDotSegmentsRelative(target.path)
|
|
194
|
+
: removeDotSegments(target.path)
|
|
160
195
|
}
|
|
161
196
|
target.query = relative.query
|
|
162
197
|
}
|
|
@@ -217,7 +252,7 @@ function serialize (cmpts, opts) {
|
|
|
217
252
|
const uriTokens = []
|
|
218
253
|
|
|
219
254
|
if (component.scheme) {
|
|
220
|
-
component.scheme =
|
|
255
|
+
component.scheme = validateScheme(component.scheme)
|
|
221
256
|
}
|
|
222
257
|
|
|
223
258
|
// find scheme handler
|
|
@@ -227,7 +262,11 @@ function serialize (cmpts, opts) {
|
|
|
227
262
|
if (schemeHandler && schemeHandler.serialize) schemeHandler.serialize(component, options)
|
|
228
263
|
|
|
229
264
|
const hasAuthority = component.userinfo !== undefined || component.host !== undefined || component.port !== undefined
|
|
230
|
-
|
|
265
|
+
// A reference with no emitted scheme is path-noscheme: a bare ':' in its
|
|
266
|
+
// first path segment would reparse as a scheme (RFC 3986 3.3/4.2). Enforced
|
|
267
|
+
// under skipEscape too (resolve()), so the reapply below gates on this flag.
|
|
268
|
+
const schemelessReference = (component.scheme === undefined || options.reference === 'suffix') && !hasAuthority
|
|
269
|
+
const pathNoScheme = !options.skipEscape && schemelessReference
|
|
231
270
|
|
|
232
271
|
if (component.path !== undefined) {
|
|
233
272
|
if (!options.skipEscape) {
|
|
@@ -239,11 +278,12 @@ function serialize (cmpts, opts) {
|
|
|
239
278
|
|
|
240
279
|
if (options.reference !== 'suffix' && component.scheme) {
|
|
241
280
|
// Scheme handlers may replace the scheme during serialization.
|
|
242
|
-
component.scheme =
|
|
281
|
+
component.scheme = validateScheme(component.scheme)
|
|
243
282
|
uriTokens.push(component.scheme, ':')
|
|
244
283
|
}
|
|
245
284
|
|
|
246
|
-
const
|
|
285
|
+
const allowNonAsciiHost = options.unicodeSupport === true || Boolean(schemeHandler && schemeHandler.unicodeSupport)
|
|
286
|
+
const authority = recomposeAuthority(component, allowNonAsciiHost)
|
|
247
287
|
if (authority !== undefined) {
|
|
248
288
|
if (options.reference !== 'suffix') {
|
|
249
289
|
uriTokens.push('//')
|
|
@@ -259,13 +299,18 @@ function serialize (cmpts, opts) {
|
|
|
259
299
|
let s = component.path
|
|
260
300
|
|
|
261
301
|
if (!options.absolutePath && (!schemeHandler || !schemeHandler.absolutePath)) {
|
|
262
|
-
|
|
302
|
+
// A relative-path reference keeps its unresolvable leading ".." segments
|
|
303
|
+
// and must never become root-relative (RFC 3986 section 5.2.4 applies to
|
|
304
|
+
// merged target paths, not to the reference itself).
|
|
305
|
+
s = schemelessReference && s[0] !== '/'
|
|
306
|
+
? removeDotSegmentsRelative(s)
|
|
307
|
+
: removeDotSegments(s)
|
|
263
308
|
}
|
|
264
309
|
|
|
265
310
|
// Dot-segment removal can expose a colon that was not originally in the
|
|
266
311
|
// first segment (for example, "./a:b"). Reapply path-noscheme encoding so
|
|
267
312
|
// the serialized relative reference cannot be reparsed as a URI scheme.
|
|
268
|
-
if (
|
|
313
|
+
if (schemelessReference) {
|
|
269
314
|
s = serializePathEncoding(s, true)
|
|
270
315
|
}
|
|
271
316
|
|
|
@@ -385,6 +430,11 @@ function canonicalizeHost (parsed, options, schemeHandler, isIP) {
|
|
|
385
430
|
parsed.host[0] === '[' &&
|
|
386
431
|
parsed.host[parsed.host.length - 1] === ']'
|
|
387
432
|
|
|
433
|
+
if (hasForbiddenDomainHost(parsed, options, schemeHandler, isIP)) {
|
|
434
|
+
parsed.error = parsed.error || MALFORMED_HOST_ERROR
|
|
435
|
+
return true
|
|
436
|
+
}
|
|
437
|
+
|
|
388
438
|
if (
|
|
389
439
|
!options.unicodeSupport &&
|
|
390
440
|
(!schemeHandler || !schemeHandler.unicodeSupport) &&
|
|
@@ -395,21 +445,67 @@ function canonicalizeHost (parsed, options, schemeHandler, isIP) {
|
|
|
395
445
|
!bracketedIPLiteral &&
|
|
396
446
|
nonSimpleDomain(parsed.host)
|
|
397
447
|
) {
|
|
448
|
+
let url
|
|
398
449
|
try {
|
|
399
|
-
|
|
450
|
+
url = new URL('http://' + parsed.host)
|
|
400
451
|
} catch (e) {
|
|
401
452
|
parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e
|
|
402
453
|
return true
|
|
403
454
|
}
|
|
455
|
+
// The WHATWG parser must have read the whole input as a host. Anything
|
|
456
|
+
// else means it found a delimiter fast-uri did not, so its hostname would
|
|
457
|
+
// describe a different authority.
|
|
458
|
+
if (!isHostOnlyURL(url)) {
|
|
459
|
+
parsed.error = parsed.error || MALFORMED_HOST_ERROR
|
|
460
|
+
return true
|
|
461
|
+
}
|
|
462
|
+
parsed.host = url.hostname
|
|
404
463
|
}
|
|
405
464
|
|
|
406
465
|
return false
|
|
407
466
|
}
|
|
408
467
|
|
|
468
|
+
/**
|
|
469
|
+
* Whether a domain host contains an ASCII character that RFC 3986 does not
|
|
470
|
+
* allow in a reg-name (unreserved, sub-delims, or pct-encoded). Such a host is
|
|
471
|
+
* rejected instead of being handed to the WHATWG URL parser, which would treat
|
|
472
|
+
* "@" as a userinfo delimiter (silently dropping the data before it) and strip
|
|
473
|
+
* TAB, LF, and CR. Non-ASCII characters remain allowed for IDNs.
|
|
474
|
+
*
|
|
475
|
+
* @param {import('./types/index').URIComponent} parsed
|
|
476
|
+
* @param {import('./types/index').Options} options
|
|
477
|
+
* @param {{ domainHost?: boolean }|undefined} schemeHandler
|
|
478
|
+
* @param {boolean} isIP
|
|
479
|
+
* @returns {boolean}
|
|
480
|
+
*/
|
|
481
|
+
function hasForbiddenDomainHost (parsed, options, schemeHandler, isIP) {
|
|
482
|
+
return Boolean(
|
|
483
|
+
parsed.host &&
|
|
484
|
+
isIP === false &&
|
|
485
|
+
!isIPLiteral(parsed.host) &&
|
|
486
|
+
(options.domainHost || (schemeHandler && schemeHandler.domainHost)) &&
|
|
487
|
+
FORBIDDEN_DOMAIN_HOST_CHARACTER.test(parsed.host)
|
|
488
|
+
)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* @param {URL} url
|
|
493
|
+
* @returns {boolean}
|
|
494
|
+
*/
|
|
495
|
+
function isHostOnlyURL (url) {
|
|
496
|
+
return url.username === '' &&
|
|
497
|
+
url.password === '' &&
|
|
498
|
+
url.port === '' &&
|
|
499
|
+
url.pathname === '/' &&
|
|
500
|
+
url.search === '' &&
|
|
501
|
+
url.hash === '' &&
|
|
502
|
+
url.hostname !== ''
|
|
503
|
+
}
|
|
504
|
+
|
|
409
505
|
/**
|
|
410
506
|
* @param {string} uri
|
|
411
507
|
* @param {import('./types/index').Options} [opts]
|
|
412
|
-
* @returns {{ parsed: import('./types/index').URIComponent, malformedAuthorityOrPort: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
|
|
508
|
+
* @returns {{ parsed: import('./types/index').URIComponent, malformedAuthorityOrPort: boolean, malformedReferenceEdge: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
|
|
413
509
|
*/
|
|
414
510
|
function parseWithStatus (uri, opts) {
|
|
415
511
|
const options = Object.assign({}, opts)
|
|
@@ -425,6 +521,12 @@ function parseWithStatus (uri, opts) {
|
|
|
425
521
|
}
|
|
426
522
|
|
|
427
523
|
let malformedAuthorityOrPort = false
|
|
524
|
+
// Check the caller's original reference before suffix mode prepends a scheme
|
|
525
|
+
// or authority marker and turns its leading edge into interior data.
|
|
526
|
+
const malformedReferenceEdge = uri.length !== 0 && (
|
|
527
|
+
uri.charCodeAt(0) <= 0x20 ||
|
|
528
|
+
uri.charCodeAt(uri.length - 1) <= 0x20
|
|
529
|
+
)
|
|
428
530
|
let malformedPercentEncoding = false
|
|
429
531
|
let malformedSchemeSpecific = false
|
|
430
532
|
let malformedHost = false
|
|
@@ -475,6 +577,10 @@ function parseWithStatus (uri, opts) {
|
|
|
475
577
|
}
|
|
476
578
|
}
|
|
477
579
|
|
|
580
|
+
if (malformedReferenceEdge) {
|
|
581
|
+
parsed.error = parsed.error || 'URI must not contain leading or trailing C0 controls or spaces.'
|
|
582
|
+
}
|
|
583
|
+
|
|
478
584
|
const matches = uri.match(URI_PARSE)
|
|
479
585
|
|
|
480
586
|
if (matches) {
|
|
@@ -488,9 +594,8 @@ function parseWithStatus (uri, opts) {
|
|
|
488
594
|
parsed.fragment = matches[8]
|
|
489
595
|
|
|
490
596
|
if (parsed.scheme !== undefined) {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
parsed.scheme = decodedScheme.toLowerCase()
|
|
597
|
+
if (VALID_SCHEME.test(parsed.scheme)) {
|
|
598
|
+
parsed.scheme = parsed.scheme.toLowerCase()
|
|
494
599
|
} else {
|
|
495
600
|
parsed.error = parsed.error || MALFORMED_SCHEME_ERROR
|
|
496
601
|
malformedScheme = true
|
|
@@ -534,7 +639,7 @@ function parseWithStatus (uri, opts) {
|
|
|
534
639
|
}
|
|
535
640
|
|
|
536
641
|
if (malformedIPLiteral) {
|
|
537
|
-
parsed.error = parsed.error ||
|
|
642
|
+
parsed.error = parsed.error || MALFORMED_HOST_ERROR
|
|
538
643
|
malformedAuthorityOrPort = true
|
|
539
644
|
}
|
|
540
645
|
} else {
|
|
@@ -564,14 +669,24 @@ function parseWithStatus (uri, opts) {
|
|
|
564
669
|
malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP)
|
|
565
670
|
}
|
|
566
671
|
|
|
567
|
-
if (
|
|
568
|
-
let host =
|
|
569
|
-
if (!isIP) {
|
|
672
|
+
if (parsed.host !== undefined && !malformedIPLiteral) {
|
|
673
|
+
let host = parsed.host
|
|
674
|
+
if (uri.indexOf('%') !== -1 && !isIP) {
|
|
675
|
+
host = normalizePercentEncoding(host, true)
|
|
570
676
|
// Fold reg-name case after decoding unreserved octets. The second
|
|
571
677
|
// pass only restores uppercase hex in escapes that remain encoded.
|
|
572
678
|
host = normalizePercentEncoding(host.toLowerCase())
|
|
573
679
|
}
|
|
574
|
-
|
|
680
|
+
// Resolution parses both inputs with the internal `null` scheme before
|
|
681
|
+
// the effective scheme is known. Preserve raw host characters for that
|
|
682
|
+
// intermediate step so the final scheme can apply domain canonicalization;
|
|
683
|
+
// the resolved authority is encoded when it is serialized.
|
|
684
|
+
if (options.scheme !== 'null') {
|
|
685
|
+
const allowNonAsciiHost = options.unicodeSupport === true || Boolean(schemeHandler && schemeHandler.unicodeSupport)
|
|
686
|
+
parsed.host = encodeHost(host, isIP, allowNonAsciiHost)
|
|
687
|
+
} else {
|
|
688
|
+
parsed.host = host
|
|
689
|
+
}
|
|
575
690
|
}
|
|
576
691
|
|
|
577
692
|
if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
|
|
@@ -596,7 +711,7 @@ function parseWithStatus (uri, opts) {
|
|
|
596
711
|
} else {
|
|
597
712
|
parsed.error = parsed.error || 'URI can not be parsed.'
|
|
598
713
|
}
|
|
599
|
-
return { parsed, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme }
|
|
714
|
+
return { parsed, malformedAuthorityOrPort, malformedReferenceEdge, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme }
|
|
600
715
|
}
|
|
601
716
|
|
|
602
717
|
/**
|
|
@@ -620,13 +735,14 @@ function normalizeString (uri, opts) {
|
|
|
620
735
|
/**
|
|
621
736
|
* @param {string} uri
|
|
622
737
|
* @param {import('./types/index').Options} [opts]
|
|
623
|
-
* @returns {{ normalized: string, malformedAuthorityOrPort: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
|
|
738
|
+
* @returns {{ normalized: string, malformedAuthorityOrPort: boolean, malformedReferenceEdge: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
|
|
624
739
|
*/
|
|
625
740
|
function normalizeStringWithStatus (uri, opts) {
|
|
626
|
-
const { parsed, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = parseWithStatus(uri, opts)
|
|
741
|
+
const { parsed, malformedAuthorityOrPort, malformedReferenceEdge, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = parseWithStatus(uri, opts)
|
|
627
742
|
return {
|
|
628
|
-
normalized: malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? uri : serialize(parsed, opts),
|
|
743
|
+
normalized: malformedAuthorityOrPort || malformedReferenceEdge || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? uri : serialize(parsed, opts),
|
|
629
744
|
malformedAuthorityOrPort,
|
|
745
|
+
malformedReferenceEdge,
|
|
630
746
|
malformedPercentEncoding,
|
|
631
747
|
malformedSchemeSpecific,
|
|
632
748
|
malformedHost,
|
|
@@ -650,8 +766,8 @@ function normalizeComparableURI (uri, opts) {
|
|
|
650
766
|
} catch {
|
|
651
767
|
return undefined
|
|
652
768
|
}
|
|
653
|
-
const { normalized, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = normalizeStringWithStatus(value, opts)
|
|
654
|
-
return malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? undefined : normalized
|
|
769
|
+
const { normalized, malformedAuthorityOrPort, malformedReferenceEdge, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = normalizeStringWithStatus(value, opts)
|
|
770
|
+
return malformedAuthorityOrPort || malformedReferenceEdge || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? undefined : normalized
|
|
655
771
|
}
|
|
656
772
|
|
|
657
773
|
const fastUri = {
|
package/lib/schemes.js
CHANGED
|
@@ -230,7 +230,10 @@ const urn = /** @type {SchemeHandler} */ ({
|
|
|
230
230
|
scheme: 'urn',
|
|
231
231
|
parse: urnParse,
|
|
232
232
|
serialize: urnSerialize,
|
|
233
|
-
skipNormalize: true
|
|
233
|
+
skipNormalize: true,
|
|
234
|
+
// The NSS is opaque to generic URI path processing. In particular, RFC 2141
|
|
235
|
+
// and RFC 8141 do not treat slash-delimited dot segments as equivalent.
|
|
236
|
+
absolutePath: true
|
|
234
237
|
})
|
|
235
238
|
|
|
236
239
|
const urnuuid = /** @type {SchemeHandler} */ ({
|
package/lib/utils.js
CHANGED
|
@@ -24,6 +24,12 @@ const isQueryFragmentCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&
|
|
|
24
24
|
/** @type {(value: string) => boolean} */
|
|
25
25
|
const isUserinfoCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:]$/u)
|
|
26
26
|
|
|
27
|
+
/** @type {(value: string) => boolean} */
|
|
28
|
+
const isRegNameCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=]$/u)
|
|
29
|
+
|
|
30
|
+
/** @type {(value: string) => boolean} */
|
|
31
|
+
const isLiteralRegName = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=]*$/u)
|
|
32
|
+
|
|
27
33
|
/**
|
|
28
34
|
* @param {Array<string>} input
|
|
29
35
|
* @returns {string}
|
|
@@ -347,23 +353,78 @@ function removeDotSegments (path) {
|
|
|
347
353
|
}
|
|
348
354
|
|
|
349
355
|
/**
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
356
|
+
* Removes dot segments from the path of a relative reference (a path that does
|
|
357
|
+
* not start with "/") without changing what it resolves to against any base.
|
|
358
|
+
* Unlike `removeDotSegments`, leading ".." segments that cannot be collapsed
|
|
359
|
+
* are kept, the result never becomes root-relative, and a result that would be
|
|
360
|
+
* empty or start with an empty segment is prefixed with "." (an empty path
|
|
361
|
+
* means "same document" and a leading "//" would introduce an authority).
|
|
362
|
+
*
|
|
363
|
+
* @param {string} path
|
|
364
|
+
* @returns {string}
|
|
365
|
+
*/
|
|
366
|
+
function removeDotSegmentsRelative (path) {
|
|
367
|
+
if (path === '' || path[0] === '/') {
|
|
368
|
+
return removeDotSegments(path)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const segments = path.split('/')
|
|
372
|
+
const last = segments.length - 1
|
|
373
|
+
/** @type {string[]} */
|
|
374
|
+
const output = []
|
|
375
|
+
let trailingDirectory = false
|
|
376
|
+
|
|
377
|
+
for (let i = 0; i <= last; i++) {
|
|
378
|
+
const segment = segments[i]
|
|
379
|
+
if (segment === '.' || segment === '..') {
|
|
380
|
+
if (segment === '..') {
|
|
381
|
+
if (output.length !== 0 && output[output.length - 1] !== '..') {
|
|
382
|
+
output.pop()
|
|
383
|
+
} else {
|
|
384
|
+
output.push('..')
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
trailingDirectory = i === last
|
|
388
|
+
} else {
|
|
389
|
+
output.push(segment)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (trailingDirectory && output[output.length - 1] !== '..') {
|
|
394
|
+
output.push('')
|
|
395
|
+
}
|
|
396
|
+
if (output.length === 0 || output[0] === '' || output[0].includes(':')) {
|
|
397
|
+
// An empty path, a leading empty segment ("//"), or a first segment
|
|
398
|
+
// containing ":" (which would be mistaken for a scheme, RFC 3986 section
|
|
399
|
+
// 4.2) must stay behind a "." segment so the reference keeps its meaning.
|
|
400
|
+
output.unshift('.')
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return output.join('/')
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Encodes a host according to the RFC 3986 host grammar. Valid IP literals
|
|
408
|
+
* have already been checked by `normalizeIPv6`, so their address separators
|
|
409
|
+
* remain literal. Every other host is a reg-name and may contain only
|
|
410
|
+
* unreserved characters, sub-delimiters, and valid percent escapes.
|
|
354
411
|
*
|
|
355
412
|
* @param {string} host
|
|
356
|
-
* @param {boolean} isIP - true for IPv4/IPv6
|
|
413
|
+
* @param {boolean} isIP - true for a validated IPv4/IPv6 host
|
|
414
|
+
* @param {boolean} [allowNonAscii=false] - preserve non-ASCII IRI characters
|
|
357
415
|
* @returns {string}
|
|
358
416
|
*/
|
|
359
|
-
const
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
417
|
+
const IP_HOST_DELIMS = { '@': '%40', '/': '%2F', '?': '%3F', '#': '%23' }
|
|
418
|
+
const IP_HOST_DELIM_RE = /[@/?#]/g
|
|
419
|
+
|
|
420
|
+
function encodeHost (host, isIP, allowNonAscii = false) {
|
|
421
|
+
if (!isIP) {
|
|
422
|
+
if (isLiteralRegName(host)) return host
|
|
423
|
+
return encodeComponent(host, isRegNameCharacter, allowNonAscii)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
IP_HOST_DELIM_RE.lastIndex = 0
|
|
427
|
+
return host.replace(IP_HOST_DELIM_RE, (ch) => IP_HOST_DELIMS[ch])
|
|
367
428
|
}
|
|
368
429
|
|
|
369
430
|
/**
|
|
@@ -626,9 +687,10 @@ function normalizeQueryFragmentEncoding (input) {
|
|
|
626
687
|
*
|
|
627
688
|
* @param {string} input
|
|
628
689
|
* @param {(value: string) => boolean} isAllowed
|
|
690
|
+
* @param {boolean} [allowNonAscii=false]
|
|
629
691
|
* @returns {string}
|
|
630
692
|
*/
|
|
631
|
-
function encodeComponent (input, isAllowed) {
|
|
693
|
+
function encodeComponent (input, isAllowed, allowNonAscii = false) {
|
|
632
694
|
let output = ''
|
|
633
695
|
|
|
634
696
|
for (let i = 0; i < input.length; i++) {
|
|
@@ -649,17 +711,19 @@ function encodeComponent (input, isAllowed) {
|
|
|
649
711
|
if (code < 0x80) {
|
|
650
712
|
output += BYTE_HEX[code]
|
|
651
713
|
} else if (code < 0xD800 || code > 0xDFFF) {
|
|
652
|
-
output += percentEncodeNonAscii(code)
|
|
714
|
+
output += allowNonAscii ? ch : percentEncodeNonAscii(code)
|
|
653
715
|
} else if (code <= 0xDBFF && i + 1 < input.length) {
|
|
654
716
|
const low = input.charCodeAt(i + 1)
|
|
655
717
|
if (low >= 0xDC00 && low <= 0xDFFF) {
|
|
656
|
-
output +=
|
|
718
|
+
output += allowNonAscii
|
|
719
|
+
? ch + input[i + 1]
|
|
720
|
+
: percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
|
|
657
721
|
i++
|
|
658
722
|
} else {
|
|
659
|
-
output += percentEncodeNonAscii(0xFFFD)
|
|
723
|
+
output += allowNonAscii ? '\uFFFD' : percentEncodeNonAscii(0xFFFD)
|
|
660
724
|
}
|
|
661
725
|
} else {
|
|
662
|
-
output += percentEncodeNonAscii(0xFFFD)
|
|
726
|
+
output += allowNonAscii ? '\uFFFD' : percentEncodeNonAscii(0xFFFD)
|
|
663
727
|
}
|
|
664
728
|
}
|
|
665
729
|
}
|
|
@@ -773,9 +837,10 @@ function recomposeZonedIPv6Host (component) {
|
|
|
773
837
|
|
|
774
838
|
/**
|
|
775
839
|
* @param {import('../types/index').URIComponent} component
|
|
840
|
+
* @param {boolean} [allowNonAscii=false] - preserve non-ASCII IRI characters
|
|
776
841
|
* @returns {string|undefined}
|
|
777
842
|
*/
|
|
778
|
-
function recomposeAuthority (component) {
|
|
843
|
+
function recomposeAuthority (component, allowNonAscii = false) {
|
|
779
844
|
const uriTokens = []
|
|
780
845
|
|
|
781
846
|
if (component.userinfo !== undefined) {
|
|
@@ -808,7 +873,7 @@ function recomposeAuthority (component) {
|
|
|
808
873
|
if (ipV6res.isIPV6 === true || ipV6res.isIPVFuture === true) {
|
|
809
874
|
host = `[${ipV6res.escapedHost}]`
|
|
810
875
|
} else {
|
|
811
|
-
host =
|
|
876
|
+
host = encodeHost(host, false, allowNonAscii)
|
|
812
877
|
}
|
|
813
878
|
}
|
|
814
879
|
}
|
|
@@ -833,7 +898,7 @@ module.exports = {
|
|
|
833
898
|
nonSimpleDomain,
|
|
834
899
|
nonSimpleMailtoDomain,
|
|
835
900
|
recomposeAuthority,
|
|
836
|
-
|
|
901
|
+
encodeHost,
|
|
837
902
|
normalizePercentEncoding,
|
|
838
903
|
normalizePathEncoding,
|
|
839
904
|
serializePathEncoding,
|
|
@@ -843,6 +908,7 @@ module.exports = {
|
|
|
843
908
|
encodeFragment,
|
|
844
909
|
escapePreservingEscapes,
|
|
845
910
|
removeDotSegments,
|
|
911
|
+
removeDotSegmentsRelative,
|
|
846
912
|
isIPv4,
|
|
847
913
|
isUUID,
|
|
848
914
|
normalizeIPv6,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fast-uri",
|
|
3
3
|
"description": "Dependency-free RFC 3986 URI toolbox",
|
|
4
|
-
"version": "4.2.
|
|
4
|
+
"version": "4.2.3",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "commonjs",
|
|
7
7
|
"types": "types/index.d.ts",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"ajv": "^8.16.0",
|
|
62
62
|
"eslint": "^9.17.0",
|
|
63
63
|
"neostandard": "^0.13.0",
|
|
64
|
-
"playwright-test": "^
|
|
64
|
+
"playwright-test": "^15.0.1",
|
|
65
65
|
"tape": "^5.8.1",
|
|
66
66
|
"tstyche": "^7.0.0"
|
|
67
67
|
}
|