fast-uri 4.2.2 → 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 +11 -1
- package/README.md +8 -0
- package/index.js +93 -17
- package/lib/utils.js +52 -0
- package/package.json +1 -1
- package/test/host-delimiter-security.test.js +94 -0
- 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
|
@@ -11,6 +11,14 @@ on:
|
|
|
11
11
|
- patch
|
|
12
12
|
- minor
|
|
13
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
|
|
14
22
|
|
|
15
23
|
permissions: {}
|
|
16
24
|
|
|
@@ -19,6 +27,8 @@ jobs:
|
|
|
19
27
|
permissions:
|
|
20
28
|
id-token: write # required for npm provenance via OIDC
|
|
21
29
|
contents: write # required to push the release commit and the tag
|
|
22
|
-
uses: fastify/workflows/.github/workflows/reusable-release.yml@
|
|
30
|
+
uses: fastify/workflows/.github/workflows/reusable-release.yml@b0a2ab2d8259b6bd574b804cd6874bbd75c3577c
|
|
23
31
|
with:
|
|
24
32
|
semver: ${{ inputs.semver }}
|
|
33
|
+
preid: ${{ inputs.preid }}
|
|
34
|
+
dist-tag: ${{ startsWith(inputs.semver, 'pre') && 'next' || 'latest' }}
|
package/README.md
CHANGED
|
@@ -15,12 +15,18 @@ All of the above functions can accept an additional options argument that is an
|
|
|
15
15
|
|
|
16
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
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.
|
|
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.
|
|
20
24
|
|
|
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, encodeHost, isIPv4, nonSimpleDomain } = require('./lib/utils')
|
|
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
|
/**
|
|
@@ -88,6 +96,12 @@ function resolve (baseURI, relativeURI, options) {
|
|
|
88
96
|
}
|
|
89
97
|
const resolvedHostIsIP = resolvedHost !== undefined && resolvedHost !== '' &&
|
|
90
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
|
+
}
|
|
91
105
|
canonicalizeHost(resolved, options || {}, resolvedSchemeHandler, resolvedHostIsIP)
|
|
92
106
|
// Percent escapes in an ASCII reg-name are encoded data. The WHATWG hostname
|
|
93
107
|
// parser can reject them even though fast-uri preserves them safely as RFC
|
|
@@ -167,14 +181,17 @@ function resolveComponent (base, relative, options, skipNormalization) {
|
|
|
167
181
|
if (relative.path[0] === '/') {
|
|
168
182
|
target.path = removeDotSegments(relative.path)
|
|
169
183
|
} else {
|
|
170
|
-
|
|
184
|
+
const baseHasAuthority = base.userinfo !== undefined || base.host !== undefined || base.port !== undefined
|
|
185
|
+
if (baseHasAuthority && !base.path) {
|
|
171
186
|
target.path = '/' + relative.path
|
|
172
187
|
} else if (!base.path) {
|
|
173
188
|
target.path = relative.path
|
|
174
189
|
} else {
|
|
175
190
|
target.path = base.path.slice(0, base.path.lastIndexOf('/') + 1) + relative.path
|
|
176
191
|
}
|
|
177
|
-
target.path =
|
|
192
|
+
target.path = base.scheme === undefined && !baseHasAuthority && target.path[0] !== '/'
|
|
193
|
+
? removeDotSegmentsRelative(target.path)
|
|
194
|
+
: removeDotSegments(target.path)
|
|
178
195
|
}
|
|
179
196
|
target.query = relative.query
|
|
180
197
|
}
|
|
@@ -235,7 +252,7 @@ function serialize (cmpts, opts) {
|
|
|
235
252
|
const uriTokens = []
|
|
236
253
|
|
|
237
254
|
if (component.scheme) {
|
|
238
|
-
component.scheme =
|
|
255
|
+
component.scheme = validateScheme(component.scheme)
|
|
239
256
|
}
|
|
240
257
|
|
|
241
258
|
// find scheme handler
|
|
@@ -245,7 +262,11 @@ function serialize (cmpts, opts) {
|
|
|
245
262
|
if (schemeHandler && schemeHandler.serialize) schemeHandler.serialize(component, options)
|
|
246
263
|
|
|
247
264
|
const hasAuthority = component.userinfo !== undefined || component.host !== undefined || component.port !== undefined
|
|
248
|
-
|
|
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
|
|
249
270
|
|
|
250
271
|
if (component.path !== undefined) {
|
|
251
272
|
if (!options.skipEscape) {
|
|
@@ -257,7 +278,7 @@ function serialize (cmpts, opts) {
|
|
|
257
278
|
|
|
258
279
|
if (options.reference !== 'suffix' && component.scheme) {
|
|
259
280
|
// Scheme handlers may replace the scheme during serialization.
|
|
260
|
-
component.scheme =
|
|
281
|
+
component.scheme = validateScheme(component.scheme)
|
|
261
282
|
uriTokens.push(component.scheme, ':')
|
|
262
283
|
}
|
|
263
284
|
|
|
@@ -278,13 +299,18 @@ function serialize (cmpts, opts) {
|
|
|
278
299
|
let s = component.path
|
|
279
300
|
|
|
280
301
|
if (!options.absolutePath && (!schemeHandler || !schemeHandler.absolutePath)) {
|
|
281
|
-
|
|
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)
|
|
282
308
|
}
|
|
283
309
|
|
|
284
310
|
// Dot-segment removal can expose a colon that was not originally in the
|
|
285
311
|
// first segment (for example, "./a:b"). Reapply path-noscheme encoding so
|
|
286
312
|
// the serialized relative reference cannot be reparsed as a URI scheme.
|
|
287
|
-
if (
|
|
313
|
+
if (schemelessReference) {
|
|
288
314
|
s = serializePathEncoding(s, true)
|
|
289
315
|
}
|
|
290
316
|
|
|
@@ -404,6 +430,11 @@ function canonicalizeHost (parsed, options, schemeHandler, isIP) {
|
|
|
404
430
|
parsed.host[0] === '[' &&
|
|
405
431
|
parsed.host[parsed.host.length - 1] === ']'
|
|
406
432
|
|
|
433
|
+
if (hasForbiddenDomainHost(parsed, options, schemeHandler, isIP)) {
|
|
434
|
+
parsed.error = parsed.error || MALFORMED_HOST_ERROR
|
|
435
|
+
return true
|
|
436
|
+
}
|
|
437
|
+
|
|
407
438
|
if (
|
|
408
439
|
!options.unicodeSupport &&
|
|
409
440
|
(!schemeHandler || !schemeHandler.unicodeSupport) &&
|
|
@@ -414,17 +445,63 @@ function canonicalizeHost (parsed, options, schemeHandler, isIP) {
|
|
|
414
445
|
!bracketedIPLiteral &&
|
|
415
446
|
nonSimpleDomain(parsed.host)
|
|
416
447
|
) {
|
|
448
|
+
let url
|
|
417
449
|
try {
|
|
418
|
-
|
|
450
|
+
url = new URL('http://' + parsed.host)
|
|
419
451
|
} catch (e) {
|
|
420
452
|
parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e
|
|
421
453
|
return true
|
|
422
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
|
|
423
463
|
}
|
|
424
464
|
|
|
425
465
|
return false
|
|
426
466
|
}
|
|
427
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
|
+
|
|
428
505
|
/**
|
|
429
506
|
* @param {string} uri
|
|
430
507
|
* @param {import('./types/index').Options} [opts]
|
|
@@ -517,9 +594,8 @@ function parseWithStatus (uri, opts) {
|
|
|
517
594
|
parsed.fragment = matches[8]
|
|
518
595
|
|
|
519
596
|
if (parsed.scheme !== undefined) {
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
parsed.scheme = decodedScheme.toLowerCase()
|
|
597
|
+
if (VALID_SCHEME.test(parsed.scheme)) {
|
|
598
|
+
parsed.scheme = parsed.scheme.toLowerCase()
|
|
523
599
|
} else {
|
|
524
600
|
parsed.error = parsed.error || MALFORMED_SCHEME_ERROR
|
|
525
601
|
malformedScheme = true
|
|
@@ -563,7 +639,7 @@ function parseWithStatus (uri, opts) {
|
|
|
563
639
|
}
|
|
564
640
|
|
|
565
641
|
if (malformedIPLiteral) {
|
|
566
|
-
parsed.error = parsed.error ||
|
|
642
|
+
parsed.error = parsed.error || MALFORMED_HOST_ERROR
|
|
567
643
|
malformedAuthorityOrPort = true
|
|
568
644
|
}
|
|
569
645
|
} else {
|
package/lib/utils.js
CHANGED
|
@@ -352,6 +352,57 @@ function removeDotSegments (path) {
|
|
|
352
352
|
return output.join('')
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
+
/**
|
|
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
|
+
|
|
355
406
|
/**
|
|
356
407
|
* Encodes a host according to the RFC 3986 host grammar. Valid IP literals
|
|
357
408
|
* have already been checked by `normalizeIPv6`, so their address separators
|
|
@@ -857,6 +908,7 @@ module.exports = {
|
|
|
857
908
|
encodeFragment,
|
|
858
909
|
escapePreservingEscapes,
|
|
859
910
|
removeDotSegments,
|
|
911
|
+
removeDotSegmentsRelative,
|
|
860
912
|
isIPv4,
|
|
861
913
|
isUUID,
|
|
862
914
|
normalizeIPv6,
|
package/package.json
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
const MALFORMED_HOST_ERROR = 'URI host is malformed.'
|
|
7
|
+
|
|
8
|
+
const domainHostSchemes = ['http', 'https', 'ws', 'wss']
|
|
9
|
+
|
|
10
|
+
// Hosts containing ASCII characters that RFC 3986 does not allow in a
|
|
11
|
+
// reg-name. Handing them to the WHATWG URL parser would read "@" as a userinfo
|
|
12
|
+
// delimiter (dropping the data before it) and strip TAB, LF and CR.
|
|
13
|
+
const forbiddenHosts = [
|
|
14
|
+
'b@evil.example',
|
|
15
|
+
'ev\til.example',
|
|
16
|
+
'ev\nil.example',
|
|
17
|
+
'ev\ril.example',
|
|
18
|
+
'ev\r\nil.example',
|
|
19
|
+
'a b.example',
|
|
20
|
+
'a"b.example',
|
|
21
|
+
'a<b.example',
|
|
22
|
+
'a>b.example',
|
|
23
|
+
'a^b.example',
|
|
24
|
+
'a`b.example',
|
|
25
|
+
'a{b.example',
|
|
26
|
+
'a|b.example',
|
|
27
|
+
'a}b.example',
|
|
28
|
+
'a\u007Fb.example',
|
|
29
|
+
'a\u0000b.example'
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
test('domain hosts with forbidden ASCII characters fail closed', (t) => {
|
|
33
|
+
for (const scheme of domainHostSchemes) {
|
|
34
|
+
for (const host of forbiddenHosts) {
|
|
35
|
+
const uri = `${scheme}://user@${host}/path`
|
|
36
|
+
const label = JSON.stringify(uri)
|
|
37
|
+
|
|
38
|
+
const parsed = fastURI.parse(uri)
|
|
39
|
+
t.equal(parsed.error, MALFORMED_HOST_ERROR, `${label} reports a malformed host`)
|
|
40
|
+
t.notEqual(parsed.host, 'evil.example', `${label} does not report a truncated host`)
|
|
41
|
+
|
|
42
|
+
t.equal(fastURI.normalize(uri), uri, `${label} normalizes to itself`)
|
|
43
|
+
t.equal(fastURI.normalize(uri, { unicodeSupport: true }), uri, `${label} normalizes to itself with unicodeSupport`)
|
|
44
|
+
t.equal(fastURI.equal(uri, uri), false, `${label} is not comparable`)
|
|
45
|
+
t.equal(fastURI.equal(uri, `${scheme}://user@evil.example/path`), false, `${label} does not alias the stripped host`)
|
|
46
|
+
|
|
47
|
+
t.throws(
|
|
48
|
+
() => fastURI.resolve(`${scheme}://trusted.example/`, `//user@${host}/path`),
|
|
49
|
+
/URI host is malformed\./,
|
|
50
|
+
`${label} is rejected by resolve`
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
t.end()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('a second "@" cannot move the authority to a different host', (t) => {
|
|
58
|
+
const uri = 'http://a@b@evil.example/'
|
|
59
|
+
const parsed = fastURI.parse(uri)
|
|
60
|
+
t.equal(parsed.userinfo, 'a')
|
|
61
|
+
t.notEqual(parsed.host, 'evil.example', 'host is not silently truncated')
|
|
62
|
+
t.equal(parsed.error, MALFORMED_HOST_ERROR)
|
|
63
|
+
t.equal(fastURI.normalize(uri), uri)
|
|
64
|
+
|
|
65
|
+
t.throws(
|
|
66
|
+
() => fastURI.resolve('http://trusted.example/', '//a@b%41@evil.example/p'),
|
|
67
|
+
/URI host is malformed\./,
|
|
68
|
+
'percent-encoded data does not bypass the check in resolve'
|
|
69
|
+
)
|
|
70
|
+
t.end()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('the domainHost option applies the same rules to other schemes', (t) => {
|
|
74
|
+
const parsed = fastURI.parse('foo://a@b@evil.example/', { domainHost: true })
|
|
75
|
+
t.equal(parsed.error, MALFORMED_HOST_ERROR)
|
|
76
|
+
t.end()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('valid domain hosts are unaffected', (t) => {
|
|
80
|
+
t.equal(fastURI.normalize('http://Example.COM/'), 'http://example.com/')
|
|
81
|
+
t.equal(fastURI.normalize('http://%65xample.com/'), 'http://example.com/')
|
|
82
|
+
t.equal(fastURI.normalize('http://bücher.de/'), 'http://xn--bcher-kva.de/')
|
|
83
|
+
t.equal(fastURI.normalize('http://bücher.de/', { unicodeSupport: true }), 'http://bücher.de/')
|
|
84
|
+
t.equal(fastURI.parse("http://a!$&'()*+,;=b.example/").error, undefined, 'sub-delims are allowed')
|
|
85
|
+
t.equal(fastURI.parse('http://user:pass@example.com:8080/').host, 'example.com')
|
|
86
|
+
t.equal(fastURI.resolve('http://trusted.example/', '//user@example.com/p'), 'http://user@example.com/p')
|
|
87
|
+
t.end()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('schemes without domain hosts keep encoding reg-name data', (t) => {
|
|
91
|
+
t.equal(fastURI.normalize('foo://a@b@evil.example/'), 'foo://a@b%40evil.example/')
|
|
92
|
+
t.equal(fastURI.resolve('foo://x/', '//a@b@evil.example/p'), 'foo://a@b%40evil.example/p')
|
|
93
|
+
t.end()
|
|
94
|
+
})
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
const ABSOLUTE_BASES = [
|
|
7
|
+
'http://h/p/q/r',
|
|
8
|
+
'http://h/',
|
|
9
|
+
'foo://h/a/b/',
|
|
10
|
+
'http://h/p/q/'
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const RELATIVE_BASES = ['', 'base', 'relative/base']
|
|
14
|
+
|
|
15
|
+
test('normalize keeps the meaning of relative-path references', (t) => {
|
|
16
|
+
const cases = [
|
|
17
|
+
['a/../b', 'b'],
|
|
18
|
+
['x/..', './'],
|
|
19
|
+
['../x', '../x'],
|
|
20
|
+
['./../x', '../x'],
|
|
21
|
+
['a/b/../../..', '..'],
|
|
22
|
+
['a/./b', 'a/b'],
|
|
23
|
+
['a/b/..', 'a/'],
|
|
24
|
+
['.', './'],
|
|
25
|
+
['..', '..'],
|
|
26
|
+
['../', '../'],
|
|
27
|
+
['../../a/./b/../c', '../../a/c'],
|
|
28
|
+
['a//..', 'a/'],
|
|
29
|
+
['a/../b?q#f', 'b?q#f']
|
|
30
|
+
]
|
|
31
|
+
for (const [input, expected] of cases) {
|
|
32
|
+
t.equal(fastURI.normalize(input), expected, `${input} -> ${expected}`)
|
|
33
|
+
}
|
|
34
|
+
t.end()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('normalize never turns a relative-path reference into a root or network path', (t) => {
|
|
38
|
+
t.equal(fastURI.normalize('.//x'), './/x', 'leading empty segment is kept behind "."')
|
|
39
|
+
t.equal(fastURI.normalize('a/..//x'), './/x', 'collapsed prefix does not expose "//"')
|
|
40
|
+
t.equal(fastURI.parse(fastURI.normalize('a/..//x')).host, undefined, 'no authority is introduced')
|
|
41
|
+
t.equal(fastURI.normalize('./a:b'), './a:b', 'first-segment colon is kept behind a "." segment (RFC 3986 section 4.2)')
|
|
42
|
+
t.equal(fastURI.parse(fastURI.normalize('./a:b')).scheme, undefined, 'no scheme is introduced')
|
|
43
|
+
t.end()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('normalize keeps the existing behavior for root-relative and absolute paths', (t) => {
|
|
47
|
+
t.equal(fastURI.normalize('/a/../b'), '/b')
|
|
48
|
+
t.equal(fastURI.normalize('/../x'), '/x')
|
|
49
|
+
t.equal(fastURI.normalize('http://h/a/../../b'), 'http://h/b')
|
|
50
|
+
t.end()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('equal does not alias relative references with different targets', (t) => {
|
|
54
|
+
t.equal(fastURI.equal('../x', 'x'), false)
|
|
55
|
+
t.equal(fastURI.equal('a/../b', '/b'), false)
|
|
56
|
+
t.equal(fastURI.equal('x/..', '/'), false)
|
|
57
|
+
t.equal(fastURI.equal('a/../b', 'b'), true)
|
|
58
|
+
t.end()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('resolving a normalized relative reference matches resolving the original', (t) => {
|
|
62
|
+
for (const base of [...ABSOLUTE_BASES, ...RELATIVE_BASES]) {
|
|
63
|
+
for (const ref of ['a/../b', 'x/..', '../x', './../x', 'a/b/../../..', '.', '..', './/x']) {
|
|
64
|
+
t.equal(
|
|
65
|
+
fastURI.resolve(base, fastURI.normalize(ref)),
|
|
66
|
+
fastURI.resolve(base, ref),
|
|
67
|
+
`${base} + ${ref}`
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
t.equal(fastURI.resolve('http://h/p/q/r', fastURI.normalize('a/../b')), 'http://h/p/q/b')
|
|
72
|
+
t.equal(fastURI.resolve('http://h/p/q/r', fastURI.normalize('../x')), 'http://h/p/x')
|
|
73
|
+
t.end()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('property: normalization of relative references is resolution-preserving and idempotent', (t) => {
|
|
77
|
+
const segments = ['a', 'b', '.', '..', '', 'a:b']
|
|
78
|
+
// Deterministic linear congruential generator so failures are reproducible.
|
|
79
|
+
let seed = 0x5ec02
|
|
80
|
+
const next = (n) => {
|
|
81
|
+
seed = (seed * 1103515245 + 12345) % 2147483648
|
|
82
|
+
return Math.floor((seed / 2147483648) * n)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let failures = 0
|
|
86
|
+
let checked = 0
|
|
87
|
+
for (let i = 0; i < 4000; i++) {
|
|
88
|
+
const length = 1 + next(6)
|
|
89
|
+
const parts = []
|
|
90
|
+
for (let j = 0; j < length; j++) parts.push(segments[next(segments.length)])
|
|
91
|
+
const ref = parts.join('/')
|
|
92
|
+
// Only relative-path references: empty refs, refs starting with "/", and
|
|
93
|
+
// refs whose first segment contains ":" are not affected by this change.
|
|
94
|
+
if (ref === '' || ref[0] === '/' || fastURI.parse(ref).scheme !== undefined) continue
|
|
95
|
+
|
|
96
|
+
checked++
|
|
97
|
+
const normalized = fastURI.normalize(ref)
|
|
98
|
+
if (fastURI.normalize(normalized) !== normalized) {
|
|
99
|
+
failures++
|
|
100
|
+
t.fail(`not idempotent: ${ref} -> ${normalized} -> ${fastURI.normalize(normalized)}`)
|
|
101
|
+
}
|
|
102
|
+
for (const base of ABSOLUTE_BASES) {
|
|
103
|
+
const expected = fastURI.resolve(base, ref)
|
|
104
|
+
const actual = fastURI.resolve(base, normalized)
|
|
105
|
+
if (actual !== expected) {
|
|
106
|
+
failures++
|
|
107
|
+
t.fail(`${base} + ${ref}: normalized ${normalized} resolves to ${actual}, expected ${expected}`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
t.ok(checked > 1000, `checked ${checked} relative references`)
|
|
113
|
+
t.equal(failures, 0, 'no relative reference changed meaning')
|
|
114
|
+
t.end()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('suffix normalization preserves relative path meaning', (t) => {
|
|
118
|
+
const options = { reference: 'suffix', scheme: 'x' }
|
|
119
|
+
for (const [input, expected] of [
|
|
120
|
+
['a/../b', 'b'],
|
|
121
|
+
['x/..', './'],
|
|
122
|
+
['a/../a:b', './a:b']
|
|
123
|
+
]) {
|
|
124
|
+
t.equal(fastURI.normalize(input, options), expected, `${input} -> ${expected}`)
|
|
125
|
+
}
|
|
126
|
+
t.end()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('normalize does not promote a colon-first relative reference to a scheme (skipEscape)', (t) => {
|
|
130
|
+
// RFC 3986 section 4.2: a first path segment containing ":" must stay
|
|
131
|
+
// protected by a leading "./", or it is mistaken for a scheme. Collapsing
|
|
132
|
+
// the protecting dot segment must not expose the colon. The default path
|
|
133
|
+
// escapes the colon; with skipEscape the colon is emitted raw and the
|
|
134
|
+
// reference is reparsed as absolute (scheme "a"), changing its meaning.
|
|
135
|
+
const colonRefs = ['./a:b', 'a/../a:b']
|
|
136
|
+
for (const ref of colonRefs) {
|
|
137
|
+
const normalized = fastURI.normalize(ref, { skipEscape: true })
|
|
138
|
+
t.equal(
|
|
139
|
+
fastURI.parse(normalized).scheme,
|
|
140
|
+
undefined,
|
|
141
|
+
`${ref} -> ${normalized} must not introduce a scheme`
|
|
142
|
+
)
|
|
143
|
+
for (const base of ABSOLUTE_BASES) {
|
|
144
|
+
t.equal(
|
|
145
|
+
fastURI.resolve(base, normalized, { skipEscape: true }),
|
|
146
|
+
fastURI.resolve(base, ref, { skipEscape: true }),
|
|
147
|
+
`${base} + ${ref}: normalized ${normalized} must resolve the same`
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
t.end()
|
|
152
|
+
})
|
|
@@ -77,13 +77,13 @@ test('serialize uses the RFC 3986 path character set without opening escapes', (
|
|
|
77
77
|
)
|
|
78
78
|
t.equal(
|
|
79
79
|
fastURI.serialize({ path: './a:b/c:d' }),
|
|
80
|
-
'a
|
|
81
|
-
'
|
|
80
|
+
'./a:b/c:d',
|
|
81
|
+
'a first-segment colon exposed by dot-segment removal is kept behind a "." segment'
|
|
82
82
|
)
|
|
83
83
|
t.equal(
|
|
84
84
|
fastURI.normalize('./a:b'),
|
|
85
|
-
'a
|
|
86
|
-
'normalization keeps a relative path from becoming a scheme'
|
|
85
|
+
'./a:b',
|
|
86
|
+
'normalization keeps a relative path from becoming a scheme without re-encoding the colon'
|
|
87
87
|
)
|
|
88
88
|
t.equal(
|
|
89
89
|
fastURI.serialize({ path: '/Kſ' }),
|
|
@@ -107,3 +107,31 @@ test('equal distinguishes escaped reserved path data from literal syntax', (t) =
|
|
|
107
107
|
)
|
|
108
108
|
t.end()
|
|
109
109
|
})
|
|
110
|
+
|
|
111
|
+
// A suffix reference never emits its scheme token, so it is a scheme-less
|
|
112
|
+
// (path-noscheme) reference: a bare ':' in its first path segment must be
|
|
113
|
+
// escaped, or the serialized output reparses as an absolute URI with a scheme.
|
|
114
|
+
// @see https://github.com/fastify/fast-uri/security/advisories/GHSA-333x-w4r6-6crp
|
|
115
|
+
test('suffix reference escapes a first-segment colon instead of emitting a scheme', (t) => {
|
|
116
|
+
const dangerous = /^(?:javascript|data|vbscript):/i
|
|
117
|
+
const options = { reference: 'suffix', scheme: 'x' }
|
|
118
|
+
|
|
119
|
+
for (const input of ['javascript:alert(1)', '%6Aavascript:alert(1)', 'data:text/html,x', 'vbscript:msgbox(1)']) {
|
|
120
|
+
t.notOk(dangerous.test(fastURI.normalize(input, options)), `${input} normalizes to an inert reference`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
t.equal(
|
|
124
|
+
fastURI.normalize('javascript:alert(1)', options),
|
|
125
|
+
'javascript%3Aalert(1)',
|
|
126
|
+
'escapes the leading-segment colon to %3A'
|
|
127
|
+
)
|
|
128
|
+
t.notOk(
|
|
129
|
+
dangerous.test(fastURI.resolve('https://a.example/base/', '%6Aavascript:alert(1)', { reference: 'suffix' })),
|
|
130
|
+
'resolve in suffix mode does not emit a live scheme'
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
// legitimate suffix usage is unchanged
|
|
134
|
+
t.equal(fastURI.normalize('path/to/file', options), 'path/to/file', 'plain path unchanged')
|
|
135
|
+
t.equal(fastURI.normalize('a/b:c', options), 'a/b:c', 'colon in a later segment stays literal')
|
|
136
|
+
t.end()
|
|
137
|
+
})
|
|
@@ -15,7 +15,15 @@ const malformedSchemes = [
|
|
|
15
15
|
'foo_bar:value',
|
|
16
16
|
'éxample:value',
|
|
17
17
|
'Kttp://example.com/',
|
|
18
|
-
'ſcheme:value'
|
|
18
|
+
'ſcheme:value',
|
|
19
|
+
// RFC 3986 section 3.1 does not allow percent-encoding in a scheme.
|
|
20
|
+
'%4Aavascript:alert(1)',
|
|
21
|
+
'%6aavascript:alert(1)',
|
|
22
|
+
'foo%2Bbar:value',
|
|
23
|
+
'%u006Aavascript:1',
|
|
24
|
+
'ht%74ps://example.com/',
|
|
25
|
+
'ma%69lto:user@example.org',
|
|
26
|
+
'%64ata:text/html,x'
|
|
19
27
|
]
|
|
20
28
|
|
|
21
29
|
test('parse validates the decoded scheme against RFC 3986', (t) => {
|
|
@@ -23,10 +31,7 @@ test('parse validates the decoded scheme against RFC 3986', (t) => {
|
|
|
23
31
|
['a:value', 'a'],
|
|
24
32
|
['HTTP://example.com/', 'http'],
|
|
25
33
|
['a1+.-:value', 'a1+.-'],
|
|
26
|
-
['
|
|
27
|
-
['foo%2Bbar:value', 'foo+bar'],
|
|
28
|
-
['%u006Aavascript:1', 'javascript'],
|
|
29
|
-
['ht%74ps://example.com/', 'https']
|
|
34
|
+
['foo+bar:value', 'foo+bar']
|
|
30
35
|
]
|
|
31
36
|
|
|
32
37
|
for (const [uri, scheme] of validSchemes) {
|
|
@@ -42,15 +47,39 @@ test('parse validates the decoded scheme against RFC 3986', (t) => {
|
|
|
42
47
|
t.end()
|
|
43
48
|
})
|
|
44
49
|
|
|
45
|
-
test('
|
|
50
|
+
test('percent-encoded schemes do not select scheme handlers', (t) => {
|
|
46
51
|
t.equal(
|
|
47
52
|
fastURI.normalize('ht%74ps://example.com:443'),
|
|
48
|
-
'
|
|
49
|
-
'HTTP normalization
|
|
53
|
+
'ht%74ps://example.com:443',
|
|
54
|
+
'HTTP normalization does not run for an encoded scheme'
|
|
50
55
|
)
|
|
51
56
|
|
|
52
57
|
const mailto = fastURI.parse('ma%69lto:user@example.org')
|
|
53
|
-
t.
|
|
58
|
+
t.equal(mailto.to, undefined, 'mailto parsing does not run for an encoded scheme')
|
|
59
|
+
t.equal(mailto.error, MALFORMED_SCHEME_ERROR)
|
|
60
|
+
t.end()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('normalization cannot synthesize a scheme from encoded input', (t) => {
|
|
64
|
+
const inputs = [
|
|
65
|
+
'%6Aavascript:alert(1)',
|
|
66
|
+
'%4A%41%56%41%53%43%52%49%50%54:alert(1)',
|
|
67
|
+
'%u006Aavascript:alert(1)',
|
|
68
|
+
'j%61vascript:alert(1)',
|
|
69
|
+
'%64ata:text/html,<script>alert(1)</script>',
|
|
70
|
+
'vb%73cript:msgbox(1)'
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
for (const uri of inputs) {
|
|
74
|
+
const parsed = fastURI.parse(uri)
|
|
75
|
+
t.equal(parsed.error, MALFORMED_SCHEME_ERROR, uri + ' is malformed')
|
|
76
|
+
t.notOk(/^(?:javascript|data|vbscript):/i.test(String(parsed.scheme) + ':'), uri + ' parsed scheme is not decoded')
|
|
77
|
+
t.equal(fastURI.normalize(uri), uri, uri + ' normalizes to itself')
|
|
78
|
+
t.equal(fastURI.equal(uri, uri), false, uri + ' is not comparable')
|
|
79
|
+
t.throws(() => fastURI.resolve('https://example.com/app/', uri), /URI scheme is malformed\./, uri + ' is rejected by resolve')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
t.equal(fastURI.equal('%68ttp://a/', 'http://a/'), false, 'encoded scheme does not alias a plain scheme')
|
|
54
83
|
t.end()
|
|
55
84
|
})
|
|
56
85
|
|
|
@@ -93,11 +122,21 @@ test('resolve rejects malformed decoded schemes in either input', (t) => {
|
|
|
93
122
|
t.end()
|
|
94
123
|
})
|
|
95
124
|
|
|
96
|
-
test('serialize validates
|
|
125
|
+
test('serialize validates component schemes without decoding them', (t) => {
|
|
97
126
|
t.equal(
|
|
98
|
-
fastURI.serialize({ scheme: 'foo
|
|
127
|
+
fastURI.serialize({ scheme: 'foo+bar', path: 'value' }),
|
|
99
128
|
'foo+bar:value',
|
|
100
|
-
'valid
|
|
129
|
+
'valid scheme is serialized'
|
|
130
|
+
)
|
|
131
|
+
t.throws(
|
|
132
|
+
() => fastURI.serialize({ scheme: 'foo%2Bbar', path: 'value' }),
|
|
133
|
+
/URI scheme is malformed\./,
|
|
134
|
+
'percent-encoded scheme is rejected'
|
|
135
|
+
)
|
|
136
|
+
t.throws(
|
|
137
|
+
() => fastURI.serialize({ scheme: '%6Aavascript', path: 'alert(1)' }),
|
|
138
|
+
/URI scheme is malformed\./,
|
|
139
|
+
'encoded scheme cannot serialize as javascript:'
|
|
101
140
|
)
|
|
102
141
|
t.throws(
|
|
103
142
|
() => fastURI.serialize({ scheme: '//evil.example', path: '/pwn' }),
|