fast-uri 4.1.1 → 4.1.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/ci.yml +9 -9
- package/.github/workflows/lock-threads.yml +1 -1
- package/.github/workflows/ossf-scorecard.yml +45 -0
- package/README.md +1 -0
- package/SECURITY.md +173 -0
- package/benchmark/benchmark.mjs +71 -0
- package/index.js +236 -42
- package/lib/schemes.js +384 -7
- package/lib/utils.js +302 -88
- package/package.json +1 -1
- package/test/component-safe-serialization.test.js +105 -0
- package/test/equal.test.js +43 -3
- package/test/fixtures/uri-js-parse.json +24 -1
- package/test/fixtures/uri-js-serialize.json +23 -1
- package/test/ipv6-canonical.test.js +34 -0
- package/test/ipv6-validation.test.js +90 -0
- package/test/malformed-percent.test.js +77 -0
- package/test/malformed-urn.test.js +61 -0
- package/test/parse.test.js +145 -0
- package/test/reserved-path-normalization.test.js +109 -0
- package/test/scheme-validation.test.js +122 -0
- package/test/security-normalization.test.js +101 -0
- package/test/security-utf8-encoding.test.js +0 -0
- package/test/security.test.js +211 -3
- package/test/serialize.test.js +144 -0
- package/test/uri-js-compatibility.test.js +0 -3
- package/test/uri-js.test.js +3 -3
- package/test/urn-full-input.test.js +29 -0
- package/test/websocket-query-preservation.test.js +24 -0
- package/types/index.d.ts +4 -0
- package/types/index.tst.ts +16 -0
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"host": "[2001:dbz::1]",
|
|
54
54
|
"port": 80,
|
|
55
55
|
"path": "",
|
|
56
|
+
"error": "URI host is malformed.",
|
|
56
57
|
"reference": "relative"
|
|
57
58
|
}
|
|
58
59
|
],
|
|
@@ -242,6 +243,7 @@
|
|
|
242
243
|
{
|
|
243
244
|
"host": "[2606:2800:220:1:248:1893:25c8:1946:43209]",
|
|
244
245
|
"path": "",
|
|
246
|
+
"error": "URI host is malformed.",
|
|
245
247
|
"reference": "relative"
|
|
246
248
|
}
|
|
247
249
|
],
|
|
@@ -497,5 +499,26 @@
|
|
|
497
499
|
"subject": "Test",
|
|
498
500
|
"body": "NATTO"
|
|
499
501
|
}
|
|
502
|
+
],
|
|
503
|
+
[
|
|
504
|
+
"mailto:addr1@an.example,addr2@an.example",
|
|
505
|
+
{
|
|
506
|
+
"scheme": "mailto",
|
|
507
|
+
"reference": "absolute",
|
|
508
|
+
"to": [
|
|
509
|
+
"addr1@an.example",
|
|
510
|
+
"addr2@an.example"
|
|
511
|
+
]
|
|
512
|
+
}
|
|
513
|
+
],
|
|
514
|
+
[
|
|
515
|
+
"mailto:user@example.org?",
|
|
516
|
+
{
|
|
517
|
+
"scheme": "mailto",
|
|
518
|
+
"reference": "absolute",
|
|
519
|
+
"to": [
|
|
520
|
+
"user@example.org"
|
|
521
|
+
]
|
|
522
|
+
}
|
|
500
523
|
]
|
|
501
|
-
]
|
|
524
|
+
]
|
|
@@ -116,5 +116,27 @@
|
|
|
116
116
|
"path": "with:colon"
|
|
117
117
|
},
|
|
118
118
|
"scheme:with:colon"
|
|
119
|
+
],
|
|
120
|
+
[
|
|
121
|
+
{
|
|
122
|
+
"scheme": "mailto",
|
|
123
|
+
"to": [
|
|
124
|
+
"chris@example.com"
|
|
125
|
+
]
|
|
126
|
+
},
|
|
127
|
+
"mailto:chris@example.com"
|
|
128
|
+
],
|
|
129
|
+
[
|
|
130
|
+
{
|
|
131
|
+
"scheme": "mailto",
|
|
132
|
+
"to": [
|
|
133
|
+
"joe@example.com"
|
|
134
|
+
],
|
|
135
|
+
"headers": {
|
|
136
|
+
"cc": "bob@example.com"
|
|
137
|
+
},
|
|
138
|
+
"body": "hello"
|
|
139
|
+
},
|
|
140
|
+
"mailto:joe@example.com?cc=bob@example.com&body=hello"
|
|
119
141
|
]
|
|
120
|
-
]
|
|
142
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
test('IPv6 hosts normalize to the RFC 5952 canonical form', (t) => {
|
|
7
|
+
const cases = [
|
|
8
|
+
['http://[0:0:0:0:0:0:0:1]/', 'http://[::1]/'],
|
|
9
|
+
['http://[0:0:0:0:0:0:0:0]/', 'http://[::]/'],
|
|
10
|
+
['http://[2001:0db8:0000:0000:0000:0000:0000:0001]/', 'http://[2001:db8::1]/'],
|
|
11
|
+
['http://[2001:0:0:0:0:0:0:1]/', 'http://[2001::1]/'],
|
|
12
|
+
['http://[fe80:0:0:0:0:0:0:1]/', 'http://[fe80::1]/'],
|
|
13
|
+
['http://[1:0:0:0:2:0:0:3]/', 'http://[1::2:0:0:3]/']
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
for (const [uri, normalized] of cases) {
|
|
17
|
+
t.equal(fastURI.normalize(uri), normalized, `${uri} normalizes to ${normalized}`)
|
|
18
|
+
}
|
|
19
|
+
t.end()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('IPv6 equal() matches the same address across compressed and expanded forms', (t) => {
|
|
23
|
+
const pairs = [
|
|
24
|
+
['http://[::1]/', 'http://[0:0:0:0:0:0:0:1]/'],
|
|
25
|
+
['http://[::]/', 'http://[0:0:0:0:0:0:0:0]/'],
|
|
26
|
+
['http://[2001:db8::1]/', 'http://[2001:0db8:0000:0000:0000:0000:0000:0001]/'],
|
|
27
|
+
['http://[1::2:0:0:3]/', 'http://[1:0:0:0:2:0:0:3]/']
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
for (const [a, b] of pairs) {
|
|
31
|
+
t.equal(fastURI.equal(a, b), true, `${a} equals ${b}`)
|
|
32
|
+
}
|
|
33
|
+
t.end()
|
|
34
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
const HOST_ERROR = 'URI host is malformed.'
|
|
7
|
+
|
|
8
|
+
const malformedLiterals = [
|
|
9
|
+
'::not-valid',
|
|
10
|
+
'fc00::not-hex',
|
|
11
|
+
'fe80::not-hex',
|
|
12
|
+
'1:2:3',
|
|
13
|
+
'1:2:3:4:5:6:7',
|
|
14
|
+
'1:2:3:4:5:6:7:8:9',
|
|
15
|
+
'1::2::3',
|
|
16
|
+
'1:::2',
|
|
17
|
+
':::1',
|
|
18
|
+
'12345::',
|
|
19
|
+
'1:2:3:4:5:6:7::8',
|
|
20
|
+
'::ffff:192.0.2.999',
|
|
21
|
+
'::ffff:192.0.2',
|
|
22
|
+
'::ffff:192.168.001.1',
|
|
23
|
+
'::192.0.2.1:1',
|
|
24
|
+
'1:2:3:4:5:192.0.2.1::',
|
|
25
|
+
'v.foo',
|
|
26
|
+
'v1.',
|
|
27
|
+
'v1.foo%25bar',
|
|
28
|
+
'v1.K',
|
|
29
|
+
'fe80::1%25',
|
|
30
|
+
'fe80::1%25eth 0',
|
|
31
|
+
'fe80::1%25eth%ZZ',
|
|
32
|
+
'fe80::1%25K',
|
|
33
|
+
'not-an-ip'
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
test('malformed bracketed IP literals fail without being rewritten', (t) => {
|
|
37
|
+
for (const literal of malformedLiterals) {
|
|
38
|
+
const uri = `http://[${literal}]/private`
|
|
39
|
+
const parsed = fastURI.parse(uri)
|
|
40
|
+
|
|
41
|
+
t.equal(parsed.error, HOST_ERROR, `parse rejects ${literal}`)
|
|
42
|
+
t.equal(parsed.host, `[${literal.toLowerCase()}]`, `parse does not truncate ${literal}`)
|
|
43
|
+
t.equal(fastURI.normalize(uri), uri, `normalize preserves ${literal}`)
|
|
44
|
+
t.equal(fastURI.equal(uri, uri), false, `equal rejects ${literal}`)
|
|
45
|
+
}
|
|
46
|
+
t.end()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('resolve throws for malformed bracketed IP literals', (t) => {
|
|
50
|
+
for (const literal of malformedLiterals) {
|
|
51
|
+
const uri = `http://[${literal}]/private`
|
|
52
|
+
|
|
53
|
+
t.throws(
|
|
54
|
+
() => fastURI.resolve(uri, 'child'),
|
|
55
|
+
/URI host is malformed\./,
|
|
56
|
+
`rejects malformed base ${literal}`
|
|
57
|
+
)
|
|
58
|
+
t.throws(
|
|
59
|
+
() => fastURI.resolve('http://example.com/', uri),
|
|
60
|
+
/URI host is malformed\./,
|
|
61
|
+
`rejects malformed relative input ${literal}`
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
t.end()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('valid IPv6, IPvFuture, embedded IPv4, and zone forms normalize safely', (t) => {
|
|
68
|
+
const cases = [
|
|
69
|
+
['http://[::]/', 'http://[::]/', '::'],
|
|
70
|
+
['http://[::1]/', 'http://[::1]/', '::1'],
|
|
71
|
+
['http://[1::]/', 'http://[1::]/', '1::'],
|
|
72
|
+
['http://[2001:0DB8::0001]/', 'http://[2001:db8::1]/', '2001:db8::1'],
|
|
73
|
+
['http://[0:0:0:0:0:0:0:0]/', 'http://[::]/', '::'],
|
|
74
|
+
['http://[::ffff:192.0.2.1]/', 'http://[::ffff:192.0.2.1]/', '::ffff:192.0.2.1'],
|
|
75
|
+
['http://[1:2:3:4:5:6:192.0.2.1]/', 'http://[1:2:3:4:5:6:192.0.2.1]/', '1:2:3:4:5:6:192.0.2.1'],
|
|
76
|
+
['http://[fe80::A%25EN1]/', 'http://[fe80::a%25EN1]/', 'fe80::a%EN1'],
|
|
77
|
+
['http://[fe80::a%en1]/', 'http://[fe80::a%25en1]/', 'fe80::a%en1'],
|
|
78
|
+
['http://[fe80::a%25eth%2D0]/', 'http://[fe80::a%25eth%2D0]/', 'fe80::a%eth%2D0'],
|
|
79
|
+
['http://[v1.example]/', 'http://[v1.example]/', '[v1.example]'],
|
|
80
|
+
['http://[vF.A:b]/', 'http://[vf.a:b]/', '[vf.a:b]']
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
for (const [uri, normalized, host] of cases) {
|
|
84
|
+
const parsed = fastURI.parse(uri)
|
|
85
|
+
t.equal(parsed.error, undefined, `${uri} parses without error`)
|
|
86
|
+
t.equal(parsed.host, host, `${uri} has the expected host`)
|
|
87
|
+
t.equal(fastURI.normalize(uri), normalized, `${uri} normalizes safely`)
|
|
88
|
+
}
|
|
89
|
+
t.end()
|
|
90
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
const MALFORMED_PERCENT_ERROR = 'URI contains malformed percent-encoding.'
|
|
7
|
+
|
|
8
|
+
const malformedComponents = [
|
|
9
|
+
['path', 'x://example.test/a%'],
|
|
10
|
+
['path with one trailing hex digit', 'x://example.test/a%2'],
|
|
11
|
+
['path with non-hex digits', 'x://example.test/a%GG'],
|
|
12
|
+
['host', 'x://exa%mple.test/path'],
|
|
13
|
+
['userinfo', 'x://us%er@example.test/path'],
|
|
14
|
+
['query', 'x://example.test/path?q=%G0'],
|
|
15
|
+
['fragment', 'x://example.test/path#frag%1']
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
test('parse reports malformed percent syntax in generic URI components', (t) => {
|
|
19
|
+
for (const [component, uri] of malformedComponents) {
|
|
20
|
+
t.equal(fastURI.parse(uri).error, MALFORMED_PERCENT_ERROR, component)
|
|
21
|
+
}
|
|
22
|
+
t.end()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('normalize preserves malformed percent input unchanged', (t) => {
|
|
26
|
+
for (const [component, uri] of malformedComponents) {
|
|
27
|
+
t.equal(fastURI.normalize(uri), uri, component)
|
|
28
|
+
}
|
|
29
|
+
t.end()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('equal does not equate malformed input with repaired valid input', (t) => {
|
|
33
|
+
const cases = [
|
|
34
|
+
['x://example.test/a%', 'x://example.test/a%25'],
|
|
35
|
+
['x://exa%mple.test/path', 'x://exa%25mple.test/path'],
|
|
36
|
+
['x://us%er@example.test/path', 'x://us%25er@example.test/path'],
|
|
37
|
+
['x://example.test/path?q=%', 'x://example.test/path?q=%25'],
|
|
38
|
+
['x://example.test/path#%', 'x://example.test/path#%25']
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
for (const [malformed, repaired] of cases) {
|
|
42
|
+
t.equal(fastURI.equal(malformed, repaired, {}), false, malformed)
|
|
43
|
+
}
|
|
44
|
+
t.end()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('resolve rejects malformed percent syntax in either input', (t) => {
|
|
48
|
+
t.throws(
|
|
49
|
+
() => fastURI.resolve('x://example.test/base%', 'child'),
|
|
50
|
+
/URI contains malformed percent-encoding\./,
|
|
51
|
+
'malformed base'
|
|
52
|
+
)
|
|
53
|
+
t.throws(
|
|
54
|
+
() => fastURI.resolve('x://example.test/base', 'child%'),
|
|
55
|
+
/URI contains malformed percent-encoding\./,
|
|
56
|
+
'malformed relative reference'
|
|
57
|
+
)
|
|
58
|
+
t.end()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('valid percent octets remain valid without UTF-8 validation', (t) => {
|
|
62
|
+
const uri = '/%ff?q=%80#%fe'
|
|
63
|
+
const parsed = fastURI.parse(uri)
|
|
64
|
+
|
|
65
|
+
t.equal(parsed.error, undefined, 'non-UTF-8 octets are valid percent syntax')
|
|
66
|
+
t.equal(parsed.path, '/%FF', 'path octet is preserved and hex is uppercased')
|
|
67
|
+
t.equal(parsed.query, 'q=%80', 'query octet is preserved')
|
|
68
|
+
t.equal(parsed.fragment, '%FE', 'fragment octet is preserved and hex is uppercased')
|
|
69
|
+
t.equal(fastURI.normalize(uri), '/%FF?q=%80#%FE', 'normalization preserves the octets')
|
|
70
|
+
|
|
71
|
+
const allGenericComponents = fastURI.parse('x://u%2f@exa%2fmple.test/%2f?q=%2f#%2f')
|
|
72
|
+
t.equal(allGenericComponents.error, undefined, 'valid escapes are accepted in every generic component')
|
|
73
|
+
|
|
74
|
+
const ipv6Zone = fastURI.parse('//[2001:db8::7%en0]')
|
|
75
|
+
t.equal(ipv6Zone.error, undefined, 'historically accepted raw IPv6 zone separator is unchanged')
|
|
76
|
+
t.end()
|
|
77
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
const malformedURNs = [
|
|
7
|
+
'urn:',
|
|
8
|
+
'URN:',
|
|
9
|
+
'urn:foo',
|
|
10
|
+
'urn::foo',
|
|
11
|
+
'urn:foo:',
|
|
12
|
+
'urn:%66oo:bar'
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
test('parse reports malformed ordinary URNs', (t) => {
|
|
16
|
+
for (const uri of malformedURNs) {
|
|
17
|
+
t.match(fastURI.parse(uri).error, /^URN can not be parsed\.?$/, uri)
|
|
18
|
+
}
|
|
19
|
+
t.end()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('normalize preserves malformed ordinary URNs without throwing', (t) => {
|
|
23
|
+
for (const uri of malformedURNs) {
|
|
24
|
+
t.doesNotThrow(() => fastURI.normalize(uri), `${uri} does not throw`)
|
|
25
|
+
t.equal(fastURI.normalize(uri), uri, `${uri} is preserved`)
|
|
26
|
+
}
|
|
27
|
+
t.equal(
|
|
28
|
+
fastURI.normalize('urn:foo', { reference: 'relative' }),
|
|
29
|
+
'urn:foo',
|
|
30
|
+
'an earlier parse error does not hide the missing URN nid'
|
|
31
|
+
)
|
|
32
|
+
t.end()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('equal returns false for malformed ordinary URNs', (t) => {
|
|
36
|
+
for (const uri of malformedURNs) {
|
|
37
|
+
t.equal(fastURI.equal(uri, uri, {}), false, `${uri} is not equal to itself as malformed input`)
|
|
38
|
+
t.equal(fastURI.equal(uri, 'urn:foo:bar', {}), false, `${uri} is not equal to a valid URN`)
|
|
39
|
+
}
|
|
40
|
+
t.end()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('resolve handles malformed ordinary URNs without throwing', (t) => {
|
|
44
|
+
for (const uri of malformedURNs) {
|
|
45
|
+
t.doesNotThrow(() => fastURI.resolve('uri://base/', uri), `${uri} does not throw as a relative reference`)
|
|
46
|
+
t.doesNotThrow(() => fastURI.resolve(uri, ''), `${uri} does not throw as a base URI`)
|
|
47
|
+
}
|
|
48
|
+
// resolve preserves the malformed scheme-specific input rather than surfacing
|
|
49
|
+
// an uncaught 'URN without nid cannot be serialized' error (matches upstream uri-js)
|
|
50
|
+
t.equal(fastURI.resolve('uri://base/', 'urn:'), 'urn:', 'malformed relative URN is preserved')
|
|
51
|
+
t.equal(fastURI.resolve('URN:', ''), 'urn:', 'scheme case is normalized')
|
|
52
|
+
t.equal(fastURI.resolve('uri://base/', 'urn:%66oo:bar'), 'urn:foo:bar', 'percent-encoding is decoded')
|
|
53
|
+
t.end()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('valid URNs retain their existing behavior', (t) => {
|
|
57
|
+
t.equal(fastURI.normalize('URN:FOO:a123,456'), 'urn:foo:a123,456')
|
|
58
|
+
t.equal(fastURI.equal('urn:foo:a123,456', 'URN:FOO:a123,456', {}), true)
|
|
59
|
+
t.equal(fastURI.resolve('uri://base/', 'urn:'), 'urn:', 'default resolve behavior is unchanged')
|
|
60
|
+
t.end()
|
|
61
|
+
})
|
package/test/parse.test.js
CHANGED
|
@@ -214,6 +214,7 @@ test('URI parse', (t) => {
|
|
|
214
214
|
// invalid IPv6
|
|
215
215
|
components = fastURI.parse('//[2001:dbZ::7]')
|
|
216
216
|
t.equal(components.host, '[2001:dbz::7]')
|
|
217
|
+
t.equal(components.error, 'URI host is malformed.')
|
|
217
218
|
|
|
218
219
|
// mixed IPv4address & IPv6address
|
|
219
220
|
components = fastURI.parse('//[::ffff:129.144.52.38]')
|
|
@@ -318,8 +319,152 @@ test('URI parse', (t) => {
|
|
|
318
319
|
|
|
319
320
|
components = fastURI.parse('//[2606:2800:220:1:248:1893:25c8:1946:43209]')
|
|
320
321
|
t.equal(components.host, '[2606:2800:220:1:248:1893:25c8:1946:43209]')
|
|
322
|
+
t.equal(components.error, 'URI host is malformed.')
|
|
321
323
|
|
|
322
324
|
components = fastURI.parse('urn:foo:|\\24fpl')
|
|
323
325
|
t.equal(components.error, 'URN can not be parsed.')
|
|
324
326
|
t.end()
|
|
325
327
|
})
|
|
328
|
+
|
|
329
|
+
test('Mailto parsing normalizes Unicode domains according to options', (t) => {
|
|
330
|
+
t.deepEqual(
|
|
331
|
+
fastURI.parse('mailto:user@納豆.example.org').to,
|
|
332
|
+
['user@xn--99zt52a.example.org'],
|
|
333
|
+
'Unicode domain defaults to ASCII'
|
|
334
|
+
)
|
|
335
|
+
t.deepEqual(
|
|
336
|
+
fastURI.parse('mailto:user@納豆.example.org', { unicodeSupport: true }).to,
|
|
337
|
+
['user@納豆.example.org'],
|
|
338
|
+
'Unicode domain is preserved when requested'
|
|
339
|
+
)
|
|
340
|
+
t.deepEqual(
|
|
341
|
+
fastURI.parse('mailto:user@xn--99zt52a.example.org', { unicodeSupport: true }).to,
|
|
342
|
+
['user@xn--99zt52a.example.org'],
|
|
343
|
+
'existing punycode remains unchanged in Unicode mode'
|
|
344
|
+
)
|
|
345
|
+
t.end()
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
test('Mailto parsing preserves and reports malformed recipient domains', (t) => {
|
|
349
|
+
const cases = [
|
|
350
|
+
['mailto:user@example.org:25', 'user@example.org:25'],
|
|
351
|
+
['mailto:user@example.org/path', 'user@example.org/path'],
|
|
352
|
+
['mailto:user@example.org%40evil.test', 'user@example.org@evil.test'],
|
|
353
|
+
['mailto:user@example.org%3Fquery', 'user@example.org?query'],
|
|
354
|
+
['mailto:user@example.org%23fragment', 'user@example.org#fragment'],
|
|
355
|
+
['mailto:user@[broken', 'user@[broken'],
|
|
356
|
+
['mailto:user', 'user']
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
for (const [uri, recipient] of cases) {
|
|
360
|
+
const parsed = fastURI.parse(uri)
|
|
361
|
+
t.equal(parsed.error, 'URI mailto has an invalid recipient domain.', 'error for ' + uri)
|
|
362
|
+
t.deepEqual(parsed.to, [recipient], 'recipient is preserved for ' + uri)
|
|
363
|
+
}
|
|
364
|
+
t.end()
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('Mailto parsing handles domain literals, empty queries, and malformed headers', (t) => {
|
|
368
|
+
const literal = fastURI.parse('mailto:user@[IPv6:2001:db8::1]')
|
|
369
|
+
t.equal(literal.error, undefined, 'valid domain literal has no error')
|
|
370
|
+
t.deepEqual(literal.to, ['user@[ipv6:2001:db8::1]'], 'domain literal is preserved')
|
|
371
|
+
|
|
372
|
+
const emptyQuery = fastURI.parse('mailto:user@example.org?')
|
|
373
|
+
t.equal(emptyQuery.query, undefined, 'empty query is cleared')
|
|
374
|
+
t.equal(emptyQuery.headers, undefined, 'empty query does not create a header')
|
|
375
|
+
|
|
376
|
+
const malformedHeaders = fastURI.parse('mailto:joe@example.com?cc=bob@example.com?body=hello')
|
|
377
|
+
t.equal(malformedHeaders.error, 'URI mailto has malformed header fields.', 'malformed fields set an error')
|
|
378
|
+
t.deepEqual(malformedHeaders.to, ['joe@example.com'], 'path recipient is retained')
|
|
379
|
+
t.end()
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
test('Mailto parsing folds lone surrogates in raw input', (t) => {
|
|
383
|
+
// The handler sets `skipNormalize`, so it must fold lone surrogates itself --
|
|
384
|
+
// the generic query/path normalizers no longer run to do it.
|
|
385
|
+
const lone = '\uD800'
|
|
386
|
+
const trailing = '\uDC00'
|
|
387
|
+
|
|
388
|
+
t.deepEqual(
|
|
389
|
+
// spread: `headers` has a null prototype, which t.deepEqual compares strictly
|
|
390
|
+
{ ...fastURI.parse('mailto:a@b.test?x=' + lone).headers },
|
|
391
|
+
{ x: '�' },
|
|
392
|
+
'lone high surrogate in a header value becomes U+FFFD'
|
|
393
|
+
)
|
|
394
|
+
t.equal(
|
|
395
|
+
fastURI.parse('mailto:a@b.test?subject=' + trailing).subject,
|
|
396
|
+
'�',
|
|
397
|
+
'lone low surrogate in a subject becomes U+FFFD'
|
|
398
|
+
)
|
|
399
|
+
t.deepEqual(
|
|
400
|
+
fastURI.parse('mailto:' + lone + '@b.test').to,
|
|
401
|
+
['�@b.test'],
|
|
402
|
+
'lone surrogate in a local part becomes U+FFFD'
|
|
403
|
+
)
|
|
404
|
+
t.deepEqual(
|
|
405
|
+
fastURI.parse('mailto:a@b.test?subject=😀').subject,
|
|
406
|
+
'😀',
|
|
407
|
+
'a valid surrogate pair is left intact'
|
|
408
|
+
)
|
|
409
|
+
t.end()
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
test('Mailto domain fast path agrees with the WHATWG parser', (t) => {
|
|
413
|
+
// `mailtoNormalizeDomain` skips `new URL` when `nonSimpleMailtoDomain` is
|
|
414
|
+
// false. Every domain here must come out the same either way -- especially the
|
|
415
|
+
// all-numeric last labels, which `new URL` reads as IPv4 shorthand.
|
|
416
|
+
const domains = [
|
|
417
|
+
'example.com',
|
|
418
|
+
'example.org',
|
|
419
|
+
'mail2.example.org',
|
|
420
|
+
's3.amazonaws.com',
|
|
421
|
+
'ex4mple.com',
|
|
422
|
+
'_dmarc.example.com',
|
|
423
|
+
'-a.com',
|
|
424
|
+
'a..b',
|
|
425
|
+
'a.com.',
|
|
426
|
+
'1.2.3',
|
|
427
|
+
'127.1',
|
|
428
|
+
'12',
|
|
429
|
+
'0x7f.1',
|
|
430
|
+
'9.9.9.9',
|
|
431
|
+
'1.2.3.4'
|
|
432
|
+
]
|
|
433
|
+
|
|
434
|
+
for (const domain of domains) {
|
|
435
|
+
const parsed = fastURI.parse('mailto:user@' + domain)
|
|
436
|
+
let expected
|
|
437
|
+
try {
|
|
438
|
+
const url = new URL('http://' + domain)
|
|
439
|
+
const invalid = url.username || url.password || url.port ||
|
|
440
|
+
url.pathname !== '/' || url.search || url.hash || !url.hostname
|
|
441
|
+
expected = invalid ? domain : url.hostname
|
|
442
|
+
} catch {
|
|
443
|
+
expected = domain
|
|
444
|
+
}
|
|
445
|
+
t.deepEqual(parsed.to, ['user@' + expected], 'matches new URL for ' + domain)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// The fast path must not be taken for a domain whose last label is numeric.
|
|
449
|
+
t.deepEqual(fastURI.parse('mailto:user@1.2.3').to, ['user@1.2.0.3'], 'IPv4 shorthand is still applied')
|
|
450
|
+
t.deepEqual(fastURI.parse('mailto:user@mail2.example.org').to, ['user@mail2.example.org'], 'digits stay on the fast path')
|
|
451
|
+
t.end()
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
test('Mailto headers use a null prototype', (t) => {
|
|
455
|
+
// Header names come from untrusted input, so a lookup must not resolve to an
|
|
456
|
+
// inherited member of Object.prototype.
|
|
457
|
+
const parsed = fastURI.parse('mailto:a@b.test?blat=foop')
|
|
458
|
+
t.equal(Object.getPrototypeOf(parsed.headers), null, 'headers has no prototype')
|
|
459
|
+
t.equal(parsed.headers.toString, undefined, 'inherited members do not leak')
|
|
460
|
+
t.equal(parsed.headers.constructor, undefined, 'constructor does not leak')
|
|
461
|
+
|
|
462
|
+
// With a null prototype "__proto__" is an ordinary key rather than a setter,
|
|
463
|
+
// so it round-trips instead of being silently discarded.
|
|
464
|
+
const polluted = fastURI.parse('mailto:a@b.test?__proto__=x')
|
|
465
|
+
t.deepEqual(Object.keys(polluted.headers), ['__proto__'], '__proto__ is kept as an own key')
|
|
466
|
+
t.equal(fastURI.serialize(polluted), 'mailto:a@b.test?__proto__=x', '__proto__ round-trips')
|
|
467
|
+
t.equal({}.x, undefined, 'Object.prototype is not polluted')
|
|
468
|
+
t.equal(Object.getPrototypeOf({}), Object.prototype, 'Object.prototype is intact')
|
|
469
|
+
t.end()
|
|
470
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
const PATH_RESERVED = "!$&'()*+,;=:@/"
|
|
7
|
+
|
|
8
|
+
function percentEncode (character, lowerCase) {
|
|
9
|
+
const hex = character.charCodeAt(0).toString(16).padStart(2, '0')
|
|
10
|
+
return '%' + (lowerCase ? hex : hex.toUpperCase())
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test('normalize preserves literal and escaped reserved path characters', (t) => {
|
|
14
|
+
t.equal(
|
|
15
|
+
fastURI.normalize('http://example.com/a;b'),
|
|
16
|
+
'http://example.com/a;b',
|
|
17
|
+
'literal semicolon remains literal'
|
|
18
|
+
)
|
|
19
|
+
t.equal(
|
|
20
|
+
fastURI.normalize('http://example.com/a%3ab'),
|
|
21
|
+
'http://example.com/a%3Ab',
|
|
22
|
+
'escaped colon remains escaped and its hex is uppercased'
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
for (const character of PATH_RESERVED) {
|
|
26
|
+
const literal = `http://example.com/a${character}b`
|
|
27
|
+
const escaped = `http://example.com/a${percentEncode(character, true)}b`
|
|
28
|
+
const normalizedEscape = `http://example.com/a${percentEncode(character, false)}b`
|
|
29
|
+
|
|
30
|
+
t.equal(fastURI.normalize(literal), literal, `preserves literal ${character}`)
|
|
31
|
+
t.equal(fastURI.normalize(escaped), normalizedEscape, `preserves escaped ${character}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
t.equal(
|
|
35
|
+
fastURI.normalize('http://example.com/a/./café'),
|
|
36
|
+
'http://example.com/a/caf%C3%A9',
|
|
37
|
+
'removes real dot segments and UTF-8 encodes raw non-ASCII'
|
|
38
|
+
)
|
|
39
|
+
t.equal(
|
|
40
|
+
fastURI.normalize('http://example.com/a/%2e%2e/b'),
|
|
41
|
+
'http://example.com/a/%2E%2E/b',
|
|
42
|
+
'preserves escaped dots as path data'
|
|
43
|
+
)
|
|
44
|
+
t.equal(
|
|
45
|
+
fastURI.normalize('http://example.com/Kſ'),
|
|
46
|
+
'http://example.com/%E2%84%AA%C5%BF',
|
|
47
|
+
'UTF-8 encodes Unicode characters that case-fold to ASCII'
|
|
48
|
+
)
|
|
49
|
+
t.end()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('serialize uses the RFC 3986 path character set without opening escapes', (t) => {
|
|
53
|
+
const rawPath = `/a${PATH_RESERVED}b`
|
|
54
|
+
t.equal(
|
|
55
|
+
fastURI.serialize({ scheme: 'http', host: 'example.com', path: rawPath }),
|
|
56
|
+
`http://example.com${rawPath}`,
|
|
57
|
+
'keeps all legal raw path characters'
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const escapedPath = Array.from(PATH_RESERVED, (character) => percentEncode(character, true)).join('')
|
|
61
|
+
const normalizedEscapedPath = Array.from(PATH_RESERVED, (character) => percentEncode(character, false)).join('')
|
|
62
|
+
t.equal(
|
|
63
|
+
fastURI.serialize({ scheme: 'http', host: 'example.com', path: '/' + escapedPath }),
|
|
64
|
+
'http://example.com/' + normalizedEscapedPath,
|
|
65
|
+
'uppercases valid escapes without decoding reserved characters'
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
t.equal(
|
|
69
|
+
fastURI.serialize({ scheme: 'http', host: 'example.com', path: '/a?b#c[d]' }),
|
|
70
|
+
'http://example.com/a%3Fb%23c%5Bd%5D',
|
|
71
|
+
'encodes characters that would leave the path component'
|
|
72
|
+
)
|
|
73
|
+
t.equal(
|
|
74
|
+
fastURI.serialize({ path: 'a:b/c:d' }),
|
|
75
|
+
'a%3Ab/c:d',
|
|
76
|
+
'only escapes a colon where path-noscheme requires it'
|
|
77
|
+
)
|
|
78
|
+
t.equal(
|
|
79
|
+
fastURI.serialize({ path: './a:b/c:d' }),
|
|
80
|
+
'a%3Ab/c:d',
|
|
81
|
+
'escapes a first-segment colon exposed by dot-segment removal'
|
|
82
|
+
)
|
|
83
|
+
t.equal(
|
|
84
|
+
fastURI.normalize('./a:b'),
|
|
85
|
+
'a%3Ab',
|
|
86
|
+
'normalization keeps a relative path from becoming a scheme'
|
|
87
|
+
)
|
|
88
|
+
t.equal(
|
|
89
|
+
fastURI.serialize({ path: '/Kſ' }),
|
|
90
|
+
'/%E2%84%AA%C5%BF',
|
|
91
|
+
'UTF-8 encodes Unicode characters that case-fold to ASCII'
|
|
92
|
+
)
|
|
93
|
+
t.end()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('equal distinguishes escaped reserved path data from literal syntax', (t) => {
|
|
97
|
+
for (const character of PATH_RESERVED) {
|
|
98
|
+
const literal = `http://example.com/a${character}b`
|
|
99
|
+
const escaped = `http://example.com/a${percentEncode(character, false)}b`
|
|
100
|
+
t.equal(fastURI.equal(literal, escaped, {}), false, `distinguishes literal and escaped ${character}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
t.equal(
|
|
104
|
+
fastURI.equal('./a:b', 'a:b', {}),
|
|
105
|
+
false,
|
|
106
|
+
'does not equate a relative path with an absolute URI after removing dot segments'
|
|
107
|
+
)
|
|
108
|
+
t.end()
|
|
109
|
+
})
|