fast-uri 4.1.3 → 4.1.5
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/index.js +37 -11
- package/lib/schemes.js +46 -16
- package/lib/utils.js +8 -1
- package/package.json +1 -1
- package/test/component-safe-serialization.test.js +58 -0
- package/test/ipv6-validation.test.js +64 -0
- package/test/mailto-field-name-security.test.js +151 -0
- package/test/security-normalization.test.js +38 -0
package/index.js
CHANGED
|
@@ -328,6 +328,18 @@ function hasMalformedPercentEncoding (component) {
|
|
|
328
328
|
return false
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Whether the host is a bracketed IP literal (RFC 3986 `IP-literal`).
|
|
333
|
+
* An unterminated `[` is not a literal, so it must still be validated as a
|
|
334
|
+
* reg-name instead of being waved through as an IP.
|
|
335
|
+
*
|
|
336
|
+
* @param {string} host
|
|
337
|
+
* @returns {boolean}
|
|
338
|
+
*/
|
|
339
|
+
function isIPLiteral (host) {
|
|
340
|
+
return host[0] === '[' && host[host.length - 1] === ']'
|
|
341
|
+
}
|
|
342
|
+
|
|
331
343
|
/**
|
|
332
344
|
* @param {RegExpMatchArray} matches
|
|
333
345
|
* @returns {boolean}
|
|
@@ -337,7 +349,7 @@ function hasMalformedComponentPercentEncoding (matches) {
|
|
|
337
349
|
// compatibility. Their parsing is intentionally left to normalizeIPv6.
|
|
338
350
|
const host = matches[4]
|
|
339
351
|
return hasMalformedPercentEncoding(matches[3]) ||
|
|
340
|
-
(host !== undefined && !(host
|
|
352
|
+
(host !== undefined && !isIPLiteral(host) && hasMalformedPercentEncoding(host)) ||
|
|
341
353
|
hasMalformedPercentEncoding(matches[6]) ||
|
|
342
354
|
hasMalformedPercentEncoding(matches[7]) ||
|
|
343
355
|
hasMalformedPercentEncoding(matches[8])
|
|
@@ -351,13 +363,19 @@ function hasMalformedComponentPercentEncoding (matches) {
|
|
|
351
363
|
* @returns {boolean} whether host conversion failed
|
|
352
364
|
*/
|
|
353
365
|
function canonicalizeHost (parsed, options, schemeHandler, isIP) {
|
|
366
|
+
const bracketedIPLiteral =
|
|
367
|
+
parsed.host &&
|
|
368
|
+
parsed.host[0] === '[' &&
|
|
369
|
+
parsed.host[parsed.host.length - 1] === ']'
|
|
370
|
+
|
|
354
371
|
if (
|
|
355
372
|
!options.unicodeSupport &&
|
|
356
373
|
(!schemeHandler || !schemeHandler.unicodeSupport) &&
|
|
357
374
|
parsed.host &&
|
|
358
|
-
parsed.host
|
|
375
|
+
!isIPLiteral(parsed.host) &&
|
|
359
376
|
(options.domainHost || (schemeHandler && schemeHandler.domainHost)) &&
|
|
360
377
|
isIP === false &&
|
|
378
|
+
!bracketedIPLiteral &&
|
|
361
379
|
nonSimpleDomain(parsed.host)
|
|
362
380
|
) {
|
|
363
381
|
try {
|
|
@@ -367,6 +385,7 @@ function canonicalizeHost (parsed, options, schemeHandler, isIP) {
|
|
|
367
385
|
return true
|
|
368
386
|
}
|
|
369
387
|
}
|
|
388
|
+
|
|
370
389
|
return false
|
|
371
390
|
}
|
|
372
391
|
|
|
@@ -480,10 +499,11 @@ function parseWithStatus (uri, opts) {
|
|
|
480
499
|
if (parsed.host) {
|
|
481
500
|
const ipv4result = isIPv4(parsed.host)
|
|
482
501
|
if (ipv4result === false) {
|
|
483
|
-
const bracketedIPLiteral = parsed.host
|
|
502
|
+
const bracketedIPLiteral = isIPLiteral(parsed.host)
|
|
503
|
+
const hasIPLiteralBracket = parsed.host.indexOf('[') !== -1 || parsed.host.indexOf(']') !== -1
|
|
484
504
|
const ipv6result = normalizeIPv6(parsed.host)
|
|
485
505
|
isIP = ipv6result.isIPV6 || ipv6result.isIPVFuture === true
|
|
486
|
-
malformedIPLiteral =
|
|
506
|
+
malformedIPLiteral = hasIPLiteralBracket && (!bracketedIPLiteral || ipv6result.error === true)
|
|
487
507
|
parsed.host = isIP ? ipv6result.host : ipv6result.host.toLowerCase()
|
|
488
508
|
|
|
489
509
|
if (malformedIPLiteral) {
|
|
@@ -513,15 +533,21 @@ function parseWithStatus (uri, opts) {
|
|
|
513
533
|
const schemeHandler = getSchemeHandler(options.scheme || parsed.scheme)
|
|
514
534
|
|
|
515
535
|
// convert Unicode IDN -> ASCII IDN when the effective scheme uses domain hosts
|
|
516
|
-
|
|
536
|
+
if (!malformedIPLiteral) {
|
|
537
|
+
malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP)
|
|
538
|
+
}
|
|
517
539
|
|
|
518
|
-
if (
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
540
|
+
if (uri.indexOf('%') !== -1 && parsed.host !== undefined && !malformedIPLiteral) {
|
|
541
|
+
let host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true)
|
|
542
|
+
if (!isIP) {
|
|
543
|
+
// Fold reg-name case after decoding unreserved octets. The second
|
|
544
|
+
// pass only restores uppercase hex in escapes that remain encoded.
|
|
545
|
+
host = normalizePercentEncoding(host.toLowerCase())
|
|
524
546
|
}
|
|
547
|
+
parsed.host = reescapeHostDelimiters(host, isIP)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
|
|
525
551
|
if (parsed.path) {
|
|
526
552
|
parsed.path = normalizePathEncoding(parsed.path)
|
|
527
553
|
}
|
package/lib/schemes.js
CHANGED
|
@@ -495,23 +495,24 @@ function mailtoParse (component, options) {
|
|
|
495
495
|
mailtoComponent.error = mailtoComponent.error || 'URI mailto has malformed header fields.'
|
|
496
496
|
continue
|
|
497
497
|
}
|
|
498
|
-
const name = eqIdx === -1 ? token : token.slice(0, eqIdx)
|
|
498
|
+
const name = decodeHex(eqIdx === -1 ? token : token.slice(0, eqIdx))
|
|
499
|
+
const normalizedName = name.toLowerCase()
|
|
499
500
|
const value = eqIdx === -1 ? '' : token.slice(eqIdx + 1)
|
|
500
|
-
if (
|
|
501
|
+
if (normalizedName === 'to') {
|
|
501
502
|
const addrs = value.split(',')
|
|
502
503
|
for (let j = 0; j < addrs.length; j++) to.push(addrs[j])
|
|
503
504
|
continue
|
|
504
505
|
}
|
|
505
|
-
if (
|
|
506
|
+
if (normalizedName === 'subject') {
|
|
506
507
|
mailtoComponent.subject = decodeHex(value)
|
|
507
508
|
continue
|
|
508
509
|
}
|
|
509
|
-
if (
|
|
510
|
+
if (normalizedName === 'body') {
|
|
510
511
|
mailtoComponent.body = decodeHex(value)
|
|
511
512
|
continue
|
|
512
513
|
}
|
|
513
514
|
if (headers === null) headers = /** @type {Record<string,string>} */ (Object.create(null))
|
|
514
|
-
headers[
|
|
515
|
+
headers[name] = decodeHex(value)
|
|
515
516
|
}
|
|
516
517
|
if (headers !== null) mailtoComponent.headers = headers
|
|
517
518
|
}
|
|
@@ -536,6 +537,18 @@ function mailtoParse (component, options) {
|
|
|
536
537
|
return mailtoComponent
|
|
537
538
|
}
|
|
538
539
|
|
|
540
|
+
/**
|
|
541
|
+
* Encodes a decoded mailto header name. Unlike `encodeWithAllow`, a percent
|
|
542
|
+
* sign is always data here, even when followed by two hex digits. Preserving
|
|
543
|
+
* such a sequence as an existing escape would decode the name again on the
|
|
544
|
+
* next parse and could turn a generic header into `to`, `subject`, or `body`.
|
|
545
|
+
* @param {string} name
|
|
546
|
+
* @returns {string}
|
|
547
|
+
*/
|
|
548
|
+
function mailtoEncodeHeaderName (name) {
|
|
549
|
+
return encodeWithAllow(name.replace(/%/gu, '%25'), HFNAME)
|
|
550
|
+
}
|
|
551
|
+
|
|
539
552
|
/**
|
|
540
553
|
* @param {import('../types/index').URIComponent} component
|
|
541
554
|
* @param {import('../types/index').Options} options
|
|
@@ -544,6 +557,31 @@ function mailtoParse (component, options) {
|
|
|
544
557
|
function mailtoSerialize (component, options) {
|
|
545
558
|
const mailtoComponent = component
|
|
546
559
|
const to = Array.isArray(mailtoComponent.to) ? mailtoComponent.to.slice() : []
|
|
560
|
+
const sourceHeaders = mailtoComponent.headers && typeof mailtoComponent.headers === 'object'
|
|
561
|
+
? Object.assign(Object.create(null), mailtoComponent.headers)
|
|
562
|
+
: Object.create(null)
|
|
563
|
+
const headers = Object.create(null)
|
|
564
|
+
|
|
565
|
+
// Header field names are case-insensitive. Canonicalize the fields with
|
|
566
|
+
// dedicated component properties so callers cannot smuggle a second spelling
|
|
567
|
+
// through `headers`. As during parsing, all `to` fields are accumulated;
|
|
568
|
+
// repeated subject/body fields use the last value.
|
|
569
|
+
for (const name in sourceHeaders) {
|
|
570
|
+
const normalizedName = name.toLowerCase()
|
|
571
|
+
if (normalizedName === 'to') {
|
|
572
|
+
const addrs = String(sourceHeaders[name]).split(',')
|
|
573
|
+
for (let i = 0; i < addrs.length; i++) to.push(addrs[i])
|
|
574
|
+
} else if (normalizedName === 'subject' || normalizedName === 'body') {
|
|
575
|
+
headers[normalizedName] = sourceHeaders[name]
|
|
576
|
+
} else {
|
|
577
|
+
headers[name] = sourceHeaders[name]
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (mailtoComponent.subject !== undefined) headers.subject = mailtoComponent.subject
|
|
582
|
+
if (mailtoComponent.body !== undefined) headers.body = mailtoComponent.body
|
|
583
|
+
mailtoComponent.headers = headers
|
|
584
|
+
|
|
547
585
|
if (to.length) {
|
|
548
586
|
for (let i = 0; i < to.length; i++) {
|
|
549
587
|
const addr = String(to[i])
|
|
@@ -570,21 +608,13 @@ function mailtoSerialize (component, options) {
|
|
|
570
608
|
mailtoComponent.path = undefined
|
|
571
609
|
}
|
|
572
610
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
: Object.create(null)
|
|
576
|
-
|
|
577
|
-
if (mailtoComponent.subject) headers.subject = mailtoComponent.subject
|
|
578
|
-
if (mailtoComponent.body) headers.body = mailtoComponent.body
|
|
579
|
-
mailtoComponent.headers = headers
|
|
580
|
-
|
|
581
|
-
// `headers` has a null prototype and was filled via Object.assign, so every
|
|
582
|
-
// enumerable key is an own property and no hasOwnProperty guard is needed.
|
|
611
|
+
// `headers` has a null prototype, so every enumerable key is an own property
|
|
612
|
+
// and no hasOwnProperty guard is needed.
|
|
583
613
|
let query = ''
|
|
584
614
|
let count = 0
|
|
585
615
|
for (const name in headers) {
|
|
586
616
|
if (count++ !== 0) query += '&'
|
|
587
|
-
query +=
|
|
617
|
+
query += mailtoEncodeHeaderName(name) + '=' + encodeWithAllow(String(headers[name]), HFNAME)
|
|
588
618
|
}
|
|
589
619
|
|
|
590
620
|
if (count !== 0) {
|
package/lib/utils.js
CHANGED
|
@@ -6,6 +6,9 @@ const isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\d
|
|
|
6
6
|
/** @type {(value: string) => boolean} */
|
|
7
7
|
const isIPv4 = RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u)
|
|
8
8
|
|
|
9
|
+
/** @type {(value: string) => boolean} */
|
|
10
|
+
const isPort = RegExp.prototype.test.bind(/^\d*$/u)
|
|
11
|
+
|
|
9
12
|
/** @type {(value: string) => boolean} */
|
|
10
13
|
const isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu)
|
|
11
14
|
|
|
@@ -769,8 +772,12 @@ function recomposeAuthority (component) {
|
|
|
769
772
|
}
|
|
770
773
|
|
|
771
774
|
if (typeof component.port === 'number' || typeof component.port === 'string') {
|
|
775
|
+
const port = String(component.port)
|
|
776
|
+
if (!isPort(port)) {
|
|
777
|
+
throw new TypeError('URI port is malformed.')
|
|
778
|
+
}
|
|
772
779
|
uriTokens.push(':')
|
|
773
|
-
uriTokens.push(
|
|
780
|
+
uriTokens.push(port)
|
|
774
781
|
}
|
|
775
782
|
|
|
776
783
|
return uriTokens.length ? uriTokens.join('') : undefined
|
package/package.json
CHANGED
|
@@ -24,6 +24,64 @@ test('userinfo serialization cannot terminate the authority', (t) => {
|
|
|
24
24
|
t.end()
|
|
25
25
|
})
|
|
26
26
|
|
|
27
|
+
test('port serialization rejects non-digit values', (t) => {
|
|
28
|
+
const malformedPorts = [
|
|
29
|
+
'@127.0.0.1:8124',
|
|
30
|
+
'8080@evil.example',
|
|
31
|
+
'8080/path',
|
|
32
|
+
'8080?query',
|
|
33
|
+
'8080#fragment',
|
|
34
|
+
'8080:9000',
|
|
35
|
+
'-1',
|
|
36
|
+
'1.5',
|
|
37
|
+
1.5,
|
|
38
|
+
NaN,
|
|
39
|
+
Infinity,
|
|
40
|
+
'\u0661'
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
for (const port of malformedPorts) {
|
|
44
|
+
t.throws(
|
|
45
|
+
() => fastURI.serialize({ scheme: 'http', host: 'trusted.example', port, path: '/app' }),
|
|
46
|
+
/URI port is malformed\./,
|
|
47
|
+
String(port)
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
t.throws(
|
|
52
|
+
() => fastURI.normalize({ scheme: 'http', host: 'trusted.example', port: '@evil.example' }),
|
|
53
|
+
/URI port is malformed\./,
|
|
54
|
+
'object normalization rejects a malformed port'
|
|
55
|
+
)
|
|
56
|
+
t.equal(
|
|
57
|
+
fastURI.equal(
|
|
58
|
+
{ scheme: 'http', host: 'trusted.example', port: '@evil.example' },
|
|
59
|
+
{ scheme: 'http', host: 'trusted.example', port: '@evil.example' }
|
|
60
|
+
),
|
|
61
|
+
false,
|
|
62
|
+
'object equality fails closed for a malformed port'
|
|
63
|
+
)
|
|
64
|
+
t.end()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('port serialization preserves RFC 3986 digit values', (t) => {
|
|
68
|
+
const validPorts = [
|
|
69
|
+
[8080, '8080'],
|
|
70
|
+
['8080', '8080'],
|
|
71
|
+
['00080', '00080'],
|
|
72
|
+
['', '']
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
for (const [port, expected] of validPorts) {
|
|
76
|
+
t.equal(
|
|
77
|
+
fastURI.serialize({ scheme: 'uri', host: 'example.test', port }),
|
|
78
|
+
`uri://example.test:${expected}`,
|
|
79
|
+
JSON.stringify(port)
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
t.end()
|
|
83
|
+
})
|
|
84
|
+
|
|
27
85
|
test('query and fragment serialization cannot inject component delimiters', (t) => {
|
|
28
86
|
const uri = fastURI.serialize({
|
|
29
87
|
scheme: 'x',
|
|
@@ -88,3 +88,67 @@ test('valid IPv6, IPvFuture, embedded IPv4, and zone forms normalize safely', (t
|
|
|
88
88
|
}
|
|
89
89
|
t.end()
|
|
90
90
|
})
|
|
91
|
+
|
|
92
|
+
test('IPv6 zone identifiers are validated correctly', (t) => {
|
|
93
|
+
const valid = [
|
|
94
|
+
'http://[fe80::1%25eth0]/',
|
|
95
|
+
'http://[fe80::a%en1]/',
|
|
96
|
+
'http://[fe80::a%25eth%2D0]/'
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
for (const uri of valid) {
|
|
100
|
+
const parsed = fastURI.parse(uri)
|
|
101
|
+
|
|
102
|
+
t.equal(parsed.error, undefined, `${uri} parses without error`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const malformed = [
|
|
106
|
+
'http://[fe80::1%25]/',
|
|
107
|
+
'http://[fe80::1%25eth 0]/',
|
|
108
|
+
'http://[fe80::1%25eth%ZZ]/',
|
|
109
|
+
'http://[fe80::1%25K]/'
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
for (const uri of malformed) {
|
|
113
|
+
const parsed = fastURI.parse(uri)
|
|
114
|
+
|
|
115
|
+
t.equal(parsed.error, HOST_ERROR, `${uri} is rejected`)
|
|
116
|
+
t.equal(fastURI.normalize(uri), uri, `${uri} is not rewritten`)
|
|
117
|
+
t.equal(fastURI.equal(uri, uri), false, `${uri} is not comparable`)
|
|
118
|
+
}
|
|
119
|
+
t.end()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('hosts with unbalanced or misplaced IP-literal brackets are rejected', (t) => {
|
|
123
|
+
const malformed = [
|
|
124
|
+
'http://[fe80',
|
|
125
|
+
'http://[',
|
|
126
|
+
'http://[not-an-ip',
|
|
127
|
+
'http://[\u65e5\u672c',
|
|
128
|
+
'http://user@[@127.0.0.1:8123/admin',
|
|
129
|
+
'http://user@]127.0.0.1:8123/admin',
|
|
130
|
+
'http://user@prefix[@127.0.0.1:8123/admin',
|
|
131
|
+
'http://user@prefix]@127.0.0.1:8123/admin'
|
|
132
|
+
]
|
|
133
|
+
const modes = [
|
|
134
|
+
['default', undefined],
|
|
135
|
+
['Unicode', { unicodeSupport: true }]
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
for (const [mode, options] of modes) {
|
|
139
|
+
for (const uri of malformed) {
|
|
140
|
+
const parsed = fastURI.parse(uri, options)
|
|
141
|
+
const message = `${mode} mode rejects ${uri}`
|
|
142
|
+
|
|
143
|
+
t.equal(parsed.error, HOST_ERROR, `parse ${message}`)
|
|
144
|
+
t.equal(fastURI.normalize(uri, options), uri, `normalize preserves ${uri} in ${mode} mode`)
|
|
145
|
+
t.equal(fastURI.equal(uri, uri, options), false, `equal ${message}`)
|
|
146
|
+
t.throws(
|
|
147
|
+
() => fastURI.resolve('http://example.com/', uri, options),
|
|
148
|
+
/URI host is malformed\./,
|
|
149
|
+
`resolve ${message}`
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
t.end()
|
|
154
|
+
})
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
function encodeCharacter (value, index, lowercaseHex) {
|
|
7
|
+
let hex = value.charCodeAt(index).toString(16)
|
|
8
|
+
if (!lowercaseHex) hex = hex.toUpperCase()
|
|
9
|
+
return value.slice(0, index) + '%' + hex + value.slice(index + 1)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function mailtoState (component) {
|
|
13
|
+
return {
|
|
14
|
+
to: component.to,
|
|
15
|
+
subject: component.subject,
|
|
16
|
+
body: component.body,
|
|
17
|
+
headers: component.headers && { ...component.headers }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('mailto parsing classifies decoded, case-insensitive reserved field names', (t) => {
|
|
22
|
+
const cases = [
|
|
23
|
+
{ name: 'to', value: 'attacker@evil.test', property: 'to' },
|
|
24
|
+
{ name: 'subject', value: 'injected subject', property: 'subject' },
|
|
25
|
+
{ name: 'body', value: 'injected body', property: 'body' }
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
for (const entry of cases) {
|
|
29
|
+
const variants = new Set([entry.name.toUpperCase()])
|
|
30
|
+
for (let i = 0; i < entry.name.length; i++) {
|
|
31
|
+
variants.add(encodeCharacter(entry.name, i, false))
|
|
32
|
+
variants.add(encodeCharacter(entry.name, i, true))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const name of variants) {
|
|
36
|
+
const parsed = fastURI.parse(`mailto:alice@example.test?${name}=${entry.value}`)
|
|
37
|
+
if (entry.property === 'to') {
|
|
38
|
+
t.deepEqual(parsed.to, ['alice@example.test', entry.value], name + ' is classified as a recipient')
|
|
39
|
+
} else {
|
|
40
|
+
t.equal(parsed[entry.property], entry.value, name + ' is classified as ' + entry.property)
|
|
41
|
+
}
|
|
42
|
+
t.equal(parsed.headers, undefined, name + ' is not exposed as a generic header')
|
|
43
|
+
|
|
44
|
+
const reparsed = fastURI.parse(fastURI.serialize(parsed))
|
|
45
|
+
t.deepEqual(mailtoState(reparsed), mailtoState(parsed), name + ' keeps its meaning after a roundtrip')
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
t.end()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('mailto generic field names are decoded exactly once across roundtrips', (t) => {
|
|
53
|
+
const cases = [
|
|
54
|
+
['%2574o', '%74o'],
|
|
55
|
+
['%2573ubject', '%73ubject'],
|
|
56
|
+
['%2562ody', '%62ody'],
|
|
57
|
+
['x%2520y', 'x%20y'],
|
|
58
|
+
['%26to', '&to'],
|
|
59
|
+
['%25GGto', '%GGto']
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
for (const [encodedName, decodedName] of cases) {
|
|
63
|
+
let serialized = `mailto:alice@example.test?${encodedName}=value`
|
|
64
|
+
for (let round = 0; round < 3; round++) {
|
|
65
|
+
const parsed = fastURI.parse(serialized)
|
|
66
|
+
t.deepEqual({ ...parsed.headers }, { [decodedName]: 'value' }, encodedName + ' stays generic in round ' + round)
|
|
67
|
+
t.deepEqual(parsed.to, ['alice@example.test'], encodedName + ' does not add a recipient in round ' + round)
|
|
68
|
+
t.equal(parsed.subject, undefined, encodedName + ' does not set a subject in round ' + round)
|
|
69
|
+
t.equal(parsed.body, undefined, encodedName + ' does not set a body in round ' + round)
|
|
70
|
+
serialized = fastURI.serialize(parsed)
|
|
71
|
+
}
|
|
72
|
+
t.equal(serialized, `mailto:alice@example.test?${encodedName}=value`, encodedName + ' serializes idempotently')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
t.equal(
|
|
76
|
+
fastURI.serialize({ scheme: 'mailto', to: ['alice@example.test'], headers: { '%74o': 'value' } }),
|
|
77
|
+
'mailto:alice@example.test?%2574o=value',
|
|
78
|
+
'a percent sequence in a decoded component key is encoded as data'
|
|
79
|
+
)
|
|
80
|
+
t.end()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('mailto serialization canonicalizes reserved generic header names', (t) => {
|
|
84
|
+
const serialized = fastURI.serialize({
|
|
85
|
+
scheme: 'mailto',
|
|
86
|
+
to: ['alice@example.test'],
|
|
87
|
+
subject: 'safe subject',
|
|
88
|
+
body: 'safe body',
|
|
89
|
+
headers: {
|
|
90
|
+
TO: 'attacker@evil.test',
|
|
91
|
+
Subject: 'hidden subject',
|
|
92
|
+
BODY: 'hidden body',
|
|
93
|
+
cc: 'copy@example.test'
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
t.equal(
|
|
98
|
+
serialized,
|
|
99
|
+
'mailto:alice@example.test,attacker@evil.test?subject=safe%20subject&body=safe%20body&cc=copy@example.test',
|
|
100
|
+
'dedicated properties override header variants and all recipients are visible in the path'
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const parsed = fastURI.parse(serialized)
|
|
104
|
+
t.deepEqual(parsed.to, ['alice@example.test', 'attacker@evil.test'], 'canonical recipients are classified consistently')
|
|
105
|
+
t.equal(parsed.subject, 'safe subject', 'the dedicated subject wins')
|
|
106
|
+
t.equal(parsed.body, 'safe body', 'the dedicated body wins')
|
|
107
|
+
t.deepEqual({ ...parsed.headers }, { cc: 'copy@example.test' }, 'only generic headers remain generic')
|
|
108
|
+
|
|
109
|
+
const empty = fastURI.serialize({
|
|
110
|
+
scheme: 'mailto',
|
|
111
|
+
to: ['alice@example.test'],
|
|
112
|
+
subject: '',
|
|
113
|
+
body: '',
|
|
114
|
+
headers: { Subject: 'hidden subject', BODY: 'hidden body' }
|
|
115
|
+
})
|
|
116
|
+
t.equal(empty, 'mailto:alice@example.test?subject=&body=', 'explicit empty properties override header variants')
|
|
117
|
+
const emptyParsed = fastURI.parse(empty)
|
|
118
|
+
t.equal(emptyParsed.subject, '', 'an empty subject round-trips')
|
|
119
|
+
t.equal(emptyParsed.body, '', 'an empty body round-trips')
|
|
120
|
+
t.end()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('mailto duplicate reserved fields have deterministic parsing semantics', (t) => {
|
|
124
|
+
const parsed = fastURI.parse(
|
|
125
|
+
'mailto:alice@example.test?subject=first&%53UBJECT=last&body=first&%42ODY=last&to=one@example.test&%54O=two@example.test'
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
t.equal(parsed.subject, 'last', 'the last subject wins')
|
|
129
|
+
t.equal(parsed.body, 'last', 'the last body wins')
|
|
130
|
+
t.deepEqual(
|
|
131
|
+
parsed.to,
|
|
132
|
+
['alice@example.test', 'one@example.test', 'two@example.test'],
|
|
133
|
+
'all recipient fields are accumulated'
|
|
134
|
+
)
|
|
135
|
+
t.equal(parsed.headers, undefined, 'no reserved spelling remains generic')
|
|
136
|
+
t.end()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('encoded reserved field names expose decoded control characters immediately', (t) => {
|
|
140
|
+
const input = 'mailto:alice@example.test?%73ubject=hello%0D%0Aworld'
|
|
141
|
+
const parsed = fastURI.parse(input)
|
|
142
|
+
|
|
143
|
+
t.equal(parsed.subject, 'hello\r\nworld', 'the first parse exposes CRLF in the dedicated property')
|
|
144
|
+
t.equal(parsed.headers, undefined, 'the subject is not hidden as a generic header')
|
|
145
|
+
t.equal(
|
|
146
|
+
fastURI.parse(fastURI.serialize(parsed)).subject,
|
|
147
|
+
parsed.subject,
|
|
148
|
+
'the decoded subject remains stable after a roundtrip'
|
|
149
|
+
)
|
|
150
|
+
t.end()
|
|
151
|
+
})
|
|
@@ -126,6 +126,44 @@ test('hostname normalization decodes only current unreserved escapes', (t) => {
|
|
|
126
126
|
t.end()
|
|
127
127
|
})
|
|
128
128
|
|
|
129
|
+
test('scheme-relative hostname normalization folds decoded ASCII case', (t) => {
|
|
130
|
+
const encodedHost = '//%41.com'
|
|
131
|
+
const normalizedHost = fastURI.normalize(encodedHost)
|
|
132
|
+
const encodedMetadata = '//%4Detadata.internal/private'
|
|
133
|
+
const literalMetadata = '//metadata.internal/private'
|
|
134
|
+
|
|
135
|
+
t.equal(fastURI.parse(encodedHost).host, 'a.com', 'parse folds an encoded uppercase host letter')
|
|
136
|
+
t.equal(normalizedHost, '//a.com', 'normalize emits a lowercase host')
|
|
137
|
+
t.equal(fastURI.normalize(normalizedHost), normalizedHost, 'host normalization is idempotent')
|
|
138
|
+
t.equal(fastURI.equal(encodedHost, '//a.com'), true, 'encoded and literal host spellings compare equal')
|
|
139
|
+
t.equal(fastURI.equal(encodedMetadata, literalMetadata), true, 'encoded metadata host compares equal')
|
|
140
|
+
t.equal(
|
|
141
|
+
fastURI.resolve('x://trusted.example/', encodedMetadata),
|
|
142
|
+
fastURI.resolve('x://trusted.example/', literalMetadata),
|
|
143
|
+
'encoded and literal metadata hosts resolve identically'
|
|
144
|
+
)
|
|
145
|
+
t.equal(
|
|
146
|
+
fastURI.normalize('//example.com%2fpath'),
|
|
147
|
+
'//example.com%2Fpath',
|
|
148
|
+
'reserved host escapes remain encoded with uppercase hex'
|
|
149
|
+
)
|
|
150
|
+
t.equal(fastURI.normalize('//%2541.com'), '//%2541.com', 'nested host escapes are not decoded twice')
|
|
151
|
+
t.equal(fastURI.equal('//User@%41.com/path', '//user@a.com/path'), false, 'userinfo remains case-sensitive')
|
|
152
|
+
t.equal(fastURI.equal('//%41.com/Path', '//a.com/path'), false, 'path remains case-sensitive')
|
|
153
|
+
t.equal(fastURI.equal('//%41.com/?Token=Value', '//a.com/?token=value'), false, 'query remains case-sensitive')
|
|
154
|
+
t.end()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('host normalization applies to schemes that skip path normalization', (t) => {
|
|
158
|
+
const encodedHost = 'mailto://%41.com'
|
|
159
|
+
const literalHost = 'mailto://a.com'
|
|
160
|
+
|
|
161
|
+
t.equal(fastURI.parse(encodedHost).host, fastURI.parse(literalHost).host, 'parse results have consistent hosts')
|
|
162
|
+
t.equal(fastURI.normalize(encodedHost), fastURI.normalize(literalHost), 'normalize results are consistent')
|
|
163
|
+
t.equal(fastURI.equal(encodedHost, literalHost), true, 'encoded and literal hosts compare equal')
|
|
164
|
+
t.end()
|
|
165
|
+
})
|
|
166
|
+
|
|
129
167
|
test('host conversion failures are not treated as comparable URLs', (t) => {
|
|
130
168
|
const malformedHost = 'http://trusted.test%2540evil.test/'
|
|
131
169
|
|