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,137 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
const EDGE_ERROR = 'URI must not contain leading or trailing C0 controls or spaces.'
|
|
7
|
+
const C0_OR_SPACE = Array.from({ length: 0x21 }, (_, code) => String.fromCharCode(code))
|
|
8
|
+
|
|
9
|
+
test('parse rejects leading and trailing C0 controls or spaces', (t) => {
|
|
10
|
+
t.plan(C0_OR_SPACE.length * 2)
|
|
11
|
+
|
|
12
|
+
for (const character of C0_OR_SPACE) {
|
|
13
|
+
t.equal(fastURI.parse(character + 'relative').error, EDGE_ERROR, `leading U+${hex(character)}`)
|
|
14
|
+
t.equal(fastURI.parse('relative' + character).error, EDGE_ERROR, `trailing U+${hex(character)}`)
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('normalize preserves references with leading or trailing C0 controls or spaces', (t) => {
|
|
19
|
+
t.plan(C0_OR_SPACE.length * 2)
|
|
20
|
+
|
|
21
|
+
for (const character of C0_OR_SPACE) {
|
|
22
|
+
const leading = character + '//evil.example/path'
|
|
23
|
+
const trailing = '//evil.example/path' + character
|
|
24
|
+
t.equal(fastURI.normalize(leading), leading, `leading U+${hex(character)}`)
|
|
25
|
+
t.equal(fastURI.normalize(trailing), trailing, `trailing U+${hex(character)}`)
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('equal rejects references with leading or trailing C0 controls or spaces', (t) => {
|
|
30
|
+
t.plan(C0_OR_SPACE.length * 2)
|
|
31
|
+
|
|
32
|
+
for (const character of C0_OR_SPACE) {
|
|
33
|
+
const leading = character + 'relative'
|
|
34
|
+
const trailing = 'relative' + character
|
|
35
|
+
t.equal(fastURI.equal(leading, leading), false, `leading U+${hex(character)}`)
|
|
36
|
+
t.equal(fastURI.equal(trailing, trailing), false, `trailing U+${hex(character)}`)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('resolve rejects leading and trailing C0 controls or spaces in either input', (t) => {
|
|
41
|
+
t.plan(C0_OR_SPACE.length * 4)
|
|
42
|
+
|
|
43
|
+
for (const character of C0_OR_SPACE) {
|
|
44
|
+
t.throws(
|
|
45
|
+
() => fastURI.resolve(character + 'http://base.example/', 'relative'),
|
|
46
|
+
/leading or trailing C0 controls or spaces/,
|
|
47
|
+
`leading base U+${hex(character)}`
|
|
48
|
+
)
|
|
49
|
+
t.throws(
|
|
50
|
+
() => fastURI.resolve('http://base.example/' + character, 'relative'),
|
|
51
|
+
/leading or trailing C0 controls or spaces/,
|
|
52
|
+
`trailing base U+${hex(character)}`
|
|
53
|
+
)
|
|
54
|
+
t.throws(
|
|
55
|
+
() => fastURI.resolve('http://base.example/', character + '//evil.example/path'),
|
|
56
|
+
/URI (authority introducer must not contain whitespace|must not contain leading or trailing C0 controls or spaces)/,
|
|
57
|
+
`leading relative U+${hex(character)}`
|
|
58
|
+
)
|
|
59
|
+
t.throws(
|
|
60
|
+
() => fastURI.resolve('http://base.example/', '//evil.example/path' + character),
|
|
61
|
+
/leading or trailing C0 controls or spaces/,
|
|
62
|
+
`trailing relative U+${hex(character)}`
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('suffix parsing checks the original reference edges', (t) => {
|
|
68
|
+
const options = { reference: 'suffix', scheme: 'http' }
|
|
69
|
+
|
|
70
|
+
t.equal(fastURI.parse('\u0000example', options).error, EDGE_ERROR, 'leading edge before suffix prefixing')
|
|
71
|
+
t.equal(fastURI.parse('example\u0020', options).error, EDGE_ERROR, 'trailing edge before suffix prefixing')
|
|
72
|
+
t.equal(fastURI.normalize('\u0000example', options), '\u0000example', 'normalize preserves a malformed suffix')
|
|
73
|
+
t.equal(fastURI.equal('example\u0020', 'example\u0020', options), false, 'equal rejects a malformed suffix')
|
|
74
|
+
t.end()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('interior and percent-encoded C0 controls or spaces remain data', (t) => {
|
|
78
|
+
t.plan(C0_OR_SPACE.length * 6)
|
|
79
|
+
|
|
80
|
+
for (const character of C0_OR_SPACE) {
|
|
81
|
+
const encoded = percentEncode(character)
|
|
82
|
+
const interior = 'a' + character + 'b'
|
|
83
|
+
const leadingEncoded = encoded + '//evil.example/path'
|
|
84
|
+
const trailingEncoded = '//evil.example/path' + encoded
|
|
85
|
+
|
|
86
|
+
t.equal(fastURI.parse(interior).error, undefined, `interior U+${hex(character)}`)
|
|
87
|
+
t.equal(fastURI.normalize(interior), 'a' + encoded + 'b', `normalize interior U+${hex(character)}`)
|
|
88
|
+
t.equal(fastURI.parse(leadingEncoded).error, undefined, `encoded leading U+${hex(character)}`)
|
|
89
|
+
t.equal(fastURI.normalize(leadingEncoded), leadingEncoded, `normalize encoded leading U+${hex(character)}`)
|
|
90
|
+
t.equal(fastURI.parse(trailingEncoded).error, undefined, `encoded trailing U+${hex(character)}`)
|
|
91
|
+
t.equal(fastURI.normalize(trailingEncoded), trailingEncoded, `normalize encoded trailing U+${hex(character)}`)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('structured component data remains trusted and is encoded', (t) => {
|
|
96
|
+
t.equal(fastURI.serialize({ path: '\u0000' }), '%00', 'serialize encodes a path-edge control')
|
|
97
|
+
t.equal(fastURI.normalize({ path: '\u0020' }).path, '%20', 'object normalization encodes a path-edge space')
|
|
98
|
+
t.end()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('resolve rejects an empty host for HTTP and HTTPS results', (t) => {
|
|
102
|
+
const cases = [
|
|
103
|
+
['http://base.example/', '///evil.example'],
|
|
104
|
+
['https://base.example/', '////evil.example'],
|
|
105
|
+
['http://base.example/', '//'],
|
|
106
|
+
['http:///evil.example', ''],
|
|
107
|
+
['http://base.example/', '///evil.example', { scheme: 'null' }]
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
t.plan(cases.length + 2)
|
|
111
|
+
for (const [base, relative, options] of cases) {
|
|
112
|
+
t.throws(
|
|
113
|
+
() => fastURI.resolve(base, relative, options),
|
|
114
|
+
/HTTP URIs must have a host/,
|
|
115
|
+
`${base} + ${relative}`
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
t.equal(
|
|
120
|
+
fastURI.resolve('http://base.example/', '//other.example/path'),
|
|
121
|
+
'http://other.example/path',
|
|
122
|
+
'a non-empty HTTP authority remains valid'
|
|
123
|
+
)
|
|
124
|
+
t.equal(
|
|
125
|
+
fastURI.resolve('uri://base.example/', '///path'),
|
|
126
|
+
'uri:///path',
|
|
127
|
+
'generic schemes may retain an empty authority'
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
function hex (character) {
|
|
132
|
+
return character.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function percentEncode (character) {
|
|
136
|
+
return '%' + character.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')
|
|
137
|
+
}
|
|
@@ -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,60 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
const C0_OR_SPACE = Array.from({ length: 0x21 }, (_, code) => String.fromCharCode(code))
|
|
7
|
+
|
|
8
|
+
function percentEncode (character) {
|
|
9
|
+
return '%' + character.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
test('host normalization percent-encodes interior C0 controls and spaces', (t) => {
|
|
13
|
+
t.plan(C0_OR_SPACE.length * 3)
|
|
14
|
+
|
|
15
|
+
for (const character of C0_OR_SPACE) {
|
|
16
|
+
const input = `custom://a${character}b.example/path`
|
|
17
|
+
const encodedHost = `a${percentEncode(character)}b.example`
|
|
18
|
+
const expected = `custom://${encodedHost}/path`
|
|
19
|
+
|
|
20
|
+
t.equal(fastURI.parse(input).host, encodedHost, `parse U+${percentEncode(character).slice(1)}`)
|
|
21
|
+
t.equal(fastURI.normalize(input), expected, `normalize U+${percentEncode(character).slice(1)}`)
|
|
22
|
+
t.equal(
|
|
23
|
+
fastURI.resolve('custom://base.example/', `//a${character}b.example/path`),
|
|
24
|
+
expected,
|
|
25
|
+
`resolve U+${percentEncode(character).slice(1)}`
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('host serialization enforces the RFC 3986 reg-name grammar', (t) => {
|
|
31
|
+
const forbidden = '"<>[\\]^`{|}\x7F:/?#@%'
|
|
32
|
+
const expected = '%22%3C%3E%5B%5C%5D%5E%60%7B%7C%7D%7F%3A%2F%3F%23%40%25'
|
|
33
|
+
|
|
34
|
+
t.equal(
|
|
35
|
+
fastURI.serialize({ scheme: 'custom', host: forbidden, path: '/path' }),
|
|
36
|
+
`custom://${expected}/path`,
|
|
37
|
+
'forbidden ASCII host characters are percent-encoded'
|
|
38
|
+
)
|
|
39
|
+
t.equal(
|
|
40
|
+
fastURI.serialize({ scheme: 'custom', host: "AZaz09-._~!$&'()*+,;=%2f" }),
|
|
41
|
+
"custom://AZaz09-._~!$&'()*+,;=%2F",
|
|
42
|
+
'unreserved characters, sub-delimiters, and valid escapes are preserved'
|
|
43
|
+
)
|
|
44
|
+
t.end()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('host encoding preserves URI and IRI behavior', (t) => {
|
|
48
|
+
const input = 'custom://résumé.example/path'
|
|
49
|
+
const encoded = 'custom://r%C3%A9sum%C3%A9.example/path'
|
|
50
|
+
|
|
51
|
+
t.equal(fastURI.normalize(input), encoded, 'URI host uses UTF-8 percent-encoding')
|
|
52
|
+
t.equal(fastURI.normalize(input, { unicodeSupport: true }), input, 'IRI host preserves non-ASCII characters')
|
|
53
|
+
t.equal(fastURI.normalize(encoded), encoded, 'encoded host normalization is idempotent')
|
|
54
|
+
t.equal(
|
|
55
|
+
fastURI.normalize('custom://safe\r\nevil.example/path'),
|
|
56
|
+
'custom://safe%0D%0Aevil.example/path',
|
|
57
|
+
'CRLF is not emitted literally'
|
|
58
|
+
)
|
|
59
|
+
t.end()
|
|
60
|
+
})
|
package/test/parse.test.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
const test = require('tape')
|
|
4
4
|
const fastURI = require('..')
|
|
5
5
|
|
|
6
|
+
const EDGE_ERROR = 'URI must not contain leading or trailing C0 controls or spaces.'
|
|
7
|
+
|
|
6
8
|
test('URI parse', (t) => {
|
|
7
9
|
let components
|
|
8
10
|
|
|
@@ -92,7 +94,7 @@ test('URI parse', (t) => {
|
|
|
92
94
|
|
|
93
95
|
// fragment with character tabulation
|
|
94
96
|
components = fastURI.parse('#\t')
|
|
95
|
-
t.equal(components.error,
|
|
97
|
+
t.equal(components.error, EDGE_ERROR, 'fragment with trailing tab is malformed')
|
|
96
98
|
t.equal(components.scheme, undefined, 'scheme')
|
|
97
99
|
// t.equal(components.authority, undefined, "authority");
|
|
98
100
|
t.equal(components.userinfo, undefined, 'userinfo')
|
|
@@ -104,7 +106,7 @@ test('URI parse', (t) => {
|
|
|
104
106
|
|
|
105
107
|
// fragment with line feed
|
|
106
108
|
components = fastURI.parse('#\n')
|
|
107
|
-
t.equal(components.error,
|
|
109
|
+
t.equal(components.error, EDGE_ERROR, 'fragment with trailing line feed is malformed')
|
|
108
110
|
t.equal(components.scheme, undefined, 'scheme')
|
|
109
111
|
// t.equal(components.authority, undefined, "authority");
|
|
110
112
|
t.equal(components.userinfo, undefined, 'userinfo')
|
|
@@ -116,7 +118,7 @@ test('URI parse', (t) => {
|
|
|
116
118
|
|
|
117
119
|
// fragment with line tabulation
|
|
118
120
|
components = fastURI.parse('#\v')
|
|
119
|
-
t.equal(components.error,
|
|
121
|
+
t.equal(components.error, EDGE_ERROR, 'fragment with trailing vertical tab is malformed')
|
|
120
122
|
t.equal(components.scheme, undefined, 'scheme')
|
|
121
123
|
// t.equal(components.authority, undefined, "authority");
|
|
122
124
|
t.equal(components.userinfo, undefined, 'userinfo')
|
|
@@ -128,7 +130,7 @@ test('URI parse', (t) => {
|
|
|
128
130
|
|
|
129
131
|
// fragment with form feed
|
|
130
132
|
components = fastURI.parse('#\f')
|
|
131
|
-
t.equal(components.error,
|
|
133
|
+
t.equal(components.error, EDGE_ERROR, 'fragment with trailing form feed is malformed')
|
|
132
134
|
t.equal(components.scheme, undefined, 'scheme')
|
|
133
135
|
// t.equal(components.authority, undefined, "authority");
|
|
134
136
|
t.equal(components.userinfo, undefined, 'userinfo')
|
|
@@ -140,7 +142,7 @@ test('URI parse', (t) => {
|
|
|
140
142
|
|
|
141
143
|
// fragment with carriage return
|
|
142
144
|
components = fastURI.parse('#\r')
|
|
143
|
-
t.equal(components.error,
|
|
145
|
+
t.equal(components.error, EDGE_ERROR, 'fragment with trailing carriage return is malformed')
|
|
144
146
|
t.equal(components.scheme, undefined, 'scheme')
|
|
145
147
|
// t.equal(components.authority, undefined, "authority");
|
|
146
148
|
t.equal(components.userinfo, undefined, 'userinfo')
|
|
@@ -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' }),
|
package/test/security.test.js
CHANGED
|
@@ -202,8 +202,8 @@ test('resolve applies domain canonicalisation only when the effective scheme opt
|
|
|
202
202
|
t.plan(2)
|
|
203
203
|
t.equal(
|
|
204
204
|
fastURI.resolve('uri://trusted.example/', `//${host}/`),
|
|
205
|
-
|
|
206
|
-
'
|
|
205
|
+
'uri://ex%C2%ADample.com/',
|
|
206
|
+
'a generic URI encodes the host without applying domain canonicalisation'
|
|
207
207
|
)
|
|
208
208
|
t.equal(
|
|
209
209
|
fastURI.resolve('http://trusted.example/', `//${host}/`, { unicodeSupport: true }),
|