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
package/lib/utils.js
CHANGED
|
@@ -13,10 +13,13 @@ const isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu)
|
|
|
13
13
|
const isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu)
|
|
14
14
|
|
|
15
15
|
/** @type {(value: string) => boolean} */
|
|
16
|
-
const isPathCharacter = RegExp.prototype.test.bind(/^[
|
|
16
|
+
const isPathCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:@/]$/u)
|
|
17
17
|
|
|
18
18
|
/** @type {(value: string) => boolean} */
|
|
19
|
-
const isQueryFragmentCharacter = RegExp.prototype.test.bind(/^[
|
|
19
|
+
const isQueryFragmentCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:@/?]$/u)
|
|
20
|
+
|
|
21
|
+
/** @type {(value: string) => boolean} */
|
|
22
|
+
const isUserinfoCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:]$/u)
|
|
20
23
|
|
|
21
24
|
/**
|
|
22
25
|
* @param {Array<string>} input
|
|
@@ -49,12 +52,14 @@ function stringArrayToHexStripped (input) {
|
|
|
49
52
|
return acc
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
/** @type {(value: string) => boolean} */
|
|
56
|
+
const isHextet = RegExp.prototype.test.bind(/^[\dA-Fa-f]{1,4}$/)
|
|
57
|
+
|
|
58
|
+
/** @type {(value: string) => boolean} */
|
|
59
|
+
const isIPvFuture = RegExp.prototype.test.bind(/^[vV][\dA-Fa-f]+\.[A-Za-z\d\-._~!$&'()*+,;=:]+$/)
|
|
60
|
+
|
|
61
|
+
/** @type {(value: string) => boolean} */
|
|
62
|
+
const isZoneCharacter = RegExp.prototype.test.bind(/^[A-Za-z\d\-._~]$/)
|
|
58
63
|
|
|
59
64
|
/**
|
|
60
65
|
* @param {string} value
|
|
@@ -62,89 +67,129 @@ function stringArrayToHexStripped (input) {
|
|
|
62
67
|
*/
|
|
63
68
|
const nonSimpleDomain = RegExp.prototype.test.bind(/[^!"$&'()*+,\-.;=_`a-z{}~]/u)
|
|
64
69
|
|
|
70
|
+
/** @type {(value: string) => boolean} */
|
|
71
|
+
const nonSimpleDomainChars = RegExp.prototype.test.bind(/[^\d!"$&'()*+,\-.;=_`a-z{}~]/u)
|
|
72
|
+
|
|
73
|
+
/** @type {(value: string) => boolean} */
|
|
74
|
+
const numericLastLabel = RegExp.prototype.test.bind(/(?:^|\.)\d+$/u)
|
|
75
|
+
|
|
65
76
|
/**
|
|
66
|
-
*
|
|
77
|
+
* True when `new URL('http://' + domain).hostname` may differ from `domain`,
|
|
78
|
+
* i.e. when the caller must fall back to the WHATWG parser.
|
|
79
|
+
*
|
|
80
|
+
* Same purpose as `nonSimpleDomain`, but digits are allowed: for this character
|
|
81
|
+
* set `hostname` is the identity, *except* that an all-numeric final label is
|
|
82
|
+
* read as IPv4 shorthand ("1.2.3" -> "1.2.0.3", "127.1" -> "127.0.0.1",
|
|
83
|
+
* "a.b.1" throws), which is why `nonSimpleDomain` excludes digits outright.
|
|
84
|
+
* Rejecting that one shape lets the common `mail2.example.org` case stay on the
|
|
85
|
+
* fast path.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} domain - must already be lowercased
|
|
67
88
|
* @returns {boolean}
|
|
68
89
|
*/
|
|
69
|
-
function
|
|
70
|
-
|
|
71
|
-
return true
|
|
90
|
+
function nonSimpleMailtoDomain (domain) {
|
|
91
|
+
return nonSimpleDomainChars(domain) || numericLastLabel(domain)
|
|
72
92
|
}
|
|
73
93
|
|
|
74
94
|
/**
|
|
75
|
-
* @param {
|
|
76
|
-
* @param {Array<string>} address
|
|
77
|
-
* @param {GetIPV6Result} output
|
|
95
|
+
* @param {string} zone
|
|
78
96
|
* @returns {boolean}
|
|
79
97
|
*/
|
|
80
|
-
function
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
98
|
+
function isZoneIdentifier (zone) {
|
|
99
|
+
if (zone.length === 0) return false
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < zone.length; i++) {
|
|
102
|
+
if (isZoneCharacter(zone[i])) continue
|
|
103
|
+
if (zone[i] === '%' && i + 2 < zone.length && isHexPair(zone.slice(i + 1, i + 3))) {
|
|
104
|
+
i += 2
|
|
105
|
+
continue
|
|
88
106
|
}
|
|
89
|
-
|
|
107
|
+
return false
|
|
90
108
|
}
|
|
109
|
+
|
|
91
110
|
return true
|
|
92
111
|
}
|
|
93
112
|
|
|
94
113
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
114
|
+
* Compresses the longest run of zero hextets to "::" per RFC 5952. A run of a
|
|
115
|
+
* single zero hextet is left uncompressed. On ties the leftmost run wins.
|
|
116
|
+
*
|
|
117
|
+
* @param {string[]} hextets
|
|
118
|
+
* @returns {string}
|
|
97
119
|
*/
|
|
98
|
-
function
|
|
99
|
-
let
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
for (let i = 0; i < input.length; i++) {
|
|
111
|
-
const cursor = input[i]
|
|
112
|
-
if (cursor === '[' || cursor === ']') { continue }
|
|
113
|
-
if (cursor === ':') {
|
|
114
|
-
if (endipv6Encountered === true) {
|
|
115
|
-
endIpv6 = true
|
|
116
|
-
}
|
|
117
|
-
if (!consume(buffer, address, output)) { break }
|
|
118
|
-
if (++tokenCount > 7) {
|
|
119
|
-
// not valid
|
|
120
|
-
output.error = true
|
|
121
|
-
break
|
|
122
|
-
}
|
|
123
|
-
if (i > 0 && input[i - 1] === ':') {
|
|
124
|
-
endipv6Encountered = true
|
|
120
|
+
function compressIPv6ZeroRun (hextets) {
|
|
121
|
+
let bestStart = -1
|
|
122
|
+
let bestLength = 0
|
|
123
|
+
let runStart = -1
|
|
124
|
+
let runLength = 0
|
|
125
|
+
for (let i = 0; i < hextets.length; i++) {
|
|
126
|
+
if (hextets[i] === '0') {
|
|
127
|
+
if (runStart === -1) runStart = i
|
|
128
|
+
runLength++
|
|
129
|
+
if (runLength > bestLength) {
|
|
130
|
+
bestLength = runLength
|
|
131
|
+
bestStart = runStart
|
|
125
132
|
}
|
|
126
|
-
address.push(':')
|
|
127
|
-
continue
|
|
128
|
-
} else if (cursor === '%') {
|
|
129
|
-
if (!consume(buffer, address, output)) { break }
|
|
130
|
-
// switch to zone detection
|
|
131
|
-
consume = consumeIsZone
|
|
132
133
|
} else {
|
|
133
|
-
|
|
134
|
-
|
|
134
|
+
runStart = -1
|
|
135
|
+
runLength = 0
|
|
135
136
|
}
|
|
136
137
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
138
|
+
|
|
139
|
+
if (bestLength < 2) return hextets.join(':')
|
|
140
|
+
|
|
141
|
+
const head = hextets.slice(0, bestStart).join(':')
|
|
142
|
+
const tail = hextets.slice(bestStart + bestLength).join(':')
|
|
143
|
+
return head + '::' + tail
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Validates an IPv6 address against the alternatives in RFC 3986 section
|
|
148
|
+
* 3.2.2 and returns the same address with leading hextet zeroes removed.
|
|
149
|
+
* An embedded IPv4 address counts as two hextets and is only valid at the end.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} input
|
|
152
|
+
* @returns {string|undefined}
|
|
153
|
+
*/
|
|
154
|
+
function normalizeIPv6Address (input) {
|
|
155
|
+
const compression = input.indexOf('::')
|
|
156
|
+
if (compression !== -1 && input.indexOf('::', compression + 1) !== -1) return undefined
|
|
157
|
+
|
|
158
|
+
const left = compression === -1 ? input.split(':') : input.slice(0, compression).split(':')
|
|
159
|
+
const right = compression === -1 ? [] : input.slice(compression + 2).split(':')
|
|
160
|
+
if (compression !== -1) {
|
|
161
|
+
if (left.length === 1 && left[0] === '') left.length = 0
|
|
162
|
+
if (right.length === 1 && right[0] === '') right.length = 0
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const parts = left.concat(right)
|
|
166
|
+
let hextetCount = 0
|
|
167
|
+
for (let i = 0; i < parts.length; i++) {
|
|
168
|
+
const part = parts[i]
|
|
169
|
+
if (part === '') return undefined
|
|
170
|
+
|
|
171
|
+
if (part.indexOf('.') !== -1) {
|
|
172
|
+
if (i !== parts.length - 1 || (compression !== -1 && right.length === 0) || !isIPv4(part)) return undefined
|
|
173
|
+
hextetCount += 2
|
|
174
|
+
continue
|
|
144
175
|
}
|
|
176
|
+
|
|
177
|
+
if (!isHextet(part)) return undefined
|
|
178
|
+
parts[i] = parseInt(part, 16).toString(16)
|
|
179
|
+
hextetCount++
|
|
145
180
|
}
|
|
146
|
-
|
|
147
|
-
|
|
181
|
+
|
|
182
|
+
if (compression === -1) {
|
|
183
|
+
if (hextetCount !== 8) return undefined
|
|
184
|
+
return compressIPv6ZeroRun(parts)
|
|
185
|
+
}
|
|
186
|
+
if (hextetCount >= 8) return undefined
|
|
187
|
+
|
|
188
|
+
// expand "::" then re-compress the longest run for a canonical result
|
|
189
|
+
const expanded = parts.slice(0, left.length)
|
|
190
|
+
for (let i = hextetCount; i < 8; i++) expanded.push('0')
|
|
191
|
+
for (let i = left.length; i < parts.length; i++) expanded.push(parts[i])
|
|
192
|
+
return compressIPv6ZeroRun(expanded)
|
|
148
193
|
}
|
|
149
194
|
|
|
150
195
|
/**
|
|
@@ -152,26 +197,49 @@ function getIPV6 (input) {
|
|
|
152
197
|
* @property {string} host - The normalized host.
|
|
153
198
|
* @property {string} [escapedHost] - The escaped host.
|
|
154
199
|
* @property {boolean} isIPV6 - Indicates if the host is an IPv6 address.
|
|
200
|
+
* @property {boolean} [isIPVFuture] - Indicates if the host is an IPvFuture literal.
|
|
201
|
+
* @property {boolean} [error] - Indicates if a bracketed IP literal is malformed.
|
|
155
202
|
*/
|
|
156
203
|
|
|
157
204
|
/**
|
|
205
|
+
* Validates and normalizes a bracketed IP literal. Raw zone separators remain
|
|
206
|
+
* accepted for backwards compatibility, while encoded separators and zone
|
|
207
|
+
* contents follow RFC 6874.
|
|
208
|
+
*
|
|
158
209
|
* @param {string} host
|
|
159
210
|
* @returns {NormalizeIPv6Result}
|
|
160
211
|
*/
|
|
161
212
|
function normalizeIPv6 (host) {
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
213
|
+
const bracketed = host[0] === '[' && host[host.length - 1] === ']'
|
|
214
|
+
const hasBracket = host[0] === '[' || host[host.length - 1] === ']'
|
|
215
|
+
if (hasBracket && !bracketed) return { host, isIPV6: false, error: true }
|
|
216
|
+
|
|
217
|
+
let input = bracketed ? host.slice(1, -1) : host
|
|
218
|
+
if (bracketed && isIPvFuture(input)) {
|
|
219
|
+
input = input.toLowerCase()
|
|
220
|
+
return { host: `[${input}]`, escapedHost: input, isIPV6: false, isIPVFuture: true }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (findToken(input, ':') < 2) {
|
|
224
|
+
return { host, isIPV6: false, error: bracketed }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let zoneIdentifier = ''
|
|
228
|
+
const zoneSeparator = input.indexOf('%')
|
|
229
|
+
if (zoneSeparator !== -1) {
|
|
230
|
+
const separatorLength = input.slice(zoneSeparator, zoneSeparator + 3).toLowerCase() === '%25' ? 3 : 1
|
|
231
|
+
zoneIdentifier = input.slice(zoneSeparator + separatorLength)
|
|
232
|
+
if (!isZoneIdentifier(zoneIdentifier)) return { host, isIPV6: false, error: true }
|
|
233
|
+
input = input.slice(0, zoneSeparator)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const address = normalizeIPv6Address(input)
|
|
237
|
+
if (address === undefined) return { host, isIPV6: false, error: true }
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
host: address + (zoneIdentifier ? '%' + zoneIdentifier : ''),
|
|
241
|
+
escapedHost: address + (zoneIdentifier ? '%25' + zoneIdentifier : ''),
|
|
242
|
+
isIPV6: true
|
|
175
243
|
}
|
|
176
244
|
}
|
|
177
245
|
|
|
@@ -297,7 +365,7 @@ function reescapeHostDelimiters (host, isIP) {
|
|
|
297
365
|
|
|
298
366
|
/**
|
|
299
367
|
* Normalizes percent escapes and optionally decodes only unreserved ASCII bytes.
|
|
300
|
-
* Reserved delimiters such as `%2F`
|
|
368
|
+
* Reserved delimiters such as `%2F` stay escaped; `%2E` is unreserved.
|
|
301
369
|
*
|
|
302
370
|
* @param {string} input
|
|
303
371
|
* @param {boolean} [decodeUnreserved=false]
|
|
@@ -438,6 +506,60 @@ function normalizePathEncoding (input) {
|
|
|
438
506
|
return output
|
|
439
507
|
}
|
|
440
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Serializes a path without rewriting reserved data. Raw RFC 3986 path
|
|
511
|
+
* characters remain literal, valid escapes are preserved and uppercased, and
|
|
512
|
+
* everything else is UTF-8 percent-encoded. In a path-noscheme, a colon in the
|
|
513
|
+
* first segment must be escaped so the result cannot be parsed as a scheme.
|
|
514
|
+
*
|
|
515
|
+
* @param {string} input
|
|
516
|
+
* @param {boolean} [pathNoScheme=false]
|
|
517
|
+
* @returns {string}
|
|
518
|
+
*/
|
|
519
|
+
function serializePathEncoding (input, pathNoScheme = false) {
|
|
520
|
+
let output = ''
|
|
521
|
+
let firstSegment = pathNoScheme && input[0] !== '/'
|
|
522
|
+
|
|
523
|
+
for (let i = 0; i < input.length; i++) {
|
|
524
|
+
const ch = input[i]
|
|
525
|
+
if (ch === '%' && i + 2 < input.length) {
|
|
526
|
+
const hex = input.slice(i + 1, i + 3)
|
|
527
|
+
if (isHexPair(hex)) {
|
|
528
|
+
output += '%' + hex.toUpperCase()
|
|
529
|
+
i += 2
|
|
530
|
+
continue
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (ch === '/') {
|
|
535
|
+
firstSegment = false
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (isPathCharacter(ch) && (ch !== ':' || !firstSegment)) {
|
|
539
|
+
output += ch
|
|
540
|
+
} else {
|
|
541
|
+
const code = input.charCodeAt(i)
|
|
542
|
+
if (code < 0x80) {
|
|
543
|
+
output += BYTE_HEX[code]
|
|
544
|
+
} else if (code < 0xD800 || code > 0xDFFF) {
|
|
545
|
+
output += percentEncodeNonAscii(code)
|
|
546
|
+
} else if (code <= 0xDBFF && i + 1 < input.length) {
|
|
547
|
+
const low = input.charCodeAt(i + 1)
|
|
548
|
+
if (low >= 0xDC00 && low <= 0xDFFF) {
|
|
549
|
+
output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
|
|
550
|
+
i++
|
|
551
|
+
} else {
|
|
552
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
553
|
+
}
|
|
554
|
+
} else {
|
|
555
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return output
|
|
561
|
+
}
|
|
562
|
+
|
|
441
563
|
/**
|
|
442
564
|
* Normalizes the percent-encoding of a query or fragment component.
|
|
443
565
|
*
|
|
@@ -495,6 +617,85 @@ function normalizeQueryFragmentEncoding (input) {
|
|
|
495
617
|
return output
|
|
496
618
|
}
|
|
497
619
|
|
|
620
|
+
/**
|
|
621
|
+
* Percent-encodes a URI component using its RFC 3986 literal character set.
|
|
622
|
+
* Existing valid escapes are preserved and normalized to uppercase hex.
|
|
623
|
+
*
|
|
624
|
+
* @param {string} input
|
|
625
|
+
* @param {(value: string) => boolean} isAllowed
|
|
626
|
+
* @returns {string}
|
|
627
|
+
*/
|
|
628
|
+
function encodeComponent (input, isAllowed) {
|
|
629
|
+
let output = ''
|
|
630
|
+
|
|
631
|
+
for (let i = 0; i < input.length; i++) {
|
|
632
|
+
const ch = input[i]
|
|
633
|
+
if (ch === '%' && i + 2 < input.length) {
|
|
634
|
+
const hex = input.slice(i + 1, i + 3)
|
|
635
|
+
if (isHexPair(hex)) {
|
|
636
|
+
output += '%' + hex.toUpperCase()
|
|
637
|
+
i += 2
|
|
638
|
+
continue
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (isAllowed(ch)) {
|
|
643
|
+
output += ch
|
|
644
|
+
} else {
|
|
645
|
+
const code = input.charCodeAt(i)
|
|
646
|
+
if (code < 0x80) {
|
|
647
|
+
output += BYTE_HEX[code]
|
|
648
|
+
} else if (code < 0xD800 || code > 0xDFFF) {
|
|
649
|
+
output += percentEncodeNonAscii(code)
|
|
650
|
+
} else if (code <= 0xDBFF && i + 1 < input.length) {
|
|
651
|
+
const low = input.charCodeAt(i + 1)
|
|
652
|
+
if (low >= 0xDC00 && low <= 0xDFFF) {
|
|
653
|
+
output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
|
|
654
|
+
i++
|
|
655
|
+
} else {
|
|
656
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
657
|
+
}
|
|
658
|
+
} else {
|
|
659
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return output
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Encodes userinfo while preserving its RFC 3986 §3.2.1 literal characters.
|
|
669
|
+
* In particular, authority delimiters such as `@`, `/`, `?`, and `#` are data.
|
|
670
|
+
*
|
|
671
|
+
* @param {string} input
|
|
672
|
+
* @returns {string}
|
|
673
|
+
*/
|
|
674
|
+
function encodeUserinfo (input) {
|
|
675
|
+
return encodeComponent(input, isUserinfoCharacter)
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Encodes query data using the RFC 3986 §3.4 grammar. A literal `#` must be
|
|
680
|
+
* escaped because it would otherwise begin the fragment component.
|
|
681
|
+
*
|
|
682
|
+
* @param {string} input
|
|
683
|
+
* @returns {string}
|
|
684
|
+
*/
|
|
685
|
+
function encodeQuery (input) {
|
|
686
|
+
return encodeComponent(input, isQueryFragmentCharacter)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Encodes fragment data using the RFC 3986 §3.5 grammar.
|
|
691
|
+
*
|
|
692
|
+
* @param {string} input
|
|
693
|
+
* @returns {string}
|
|
694
|
+
*/
|
|
695
|
+
function encodeFragment (input) {
|
|
696
|
+
return encodeComponent(input, isQueryFragmentCharacter)
|
|
697
|
+
}
|
|
698
|
+
|
|
498
699
|
/**
|
|
499
700
|
* Escapes a component while preserving existing valid percent escapes.
|
|
500
701
|
*
|
|
@@ -544,15 +745,21 @@ function recomposeAuthority (component) {
|
|
|
544
745
|
const uriTokens = []
|
|
545
746
|
|
|
546
747
|
if (component.userinfo !== undefined) {
|
|
547
|
-
uriTokens.push(component.userinfo)
|
|
748
|
+
uriTokens.push(encodeUserinfo(component.userinfo))
|
|
548
749
|
uriTokens.push('@')
|
|
549
750
|
}
|
|
550
751
|
|
|
551
752
|
if (component.host !== undefined) {
|
|
552
|
-
let host =
|
|
753
|
+
let host = component.host
|
|
553
754
|
if (!isIPv4(host)) {
|
|
554
|
-
|
|
555
|
-
if (ipV6res.isIPV6
|
|
755
|
+
let ipV6res = normalizeIPv6(host)
|
|
756
|
+
if (ipV6res.isIPV6 !== true && ipV6res.isIPVFuture !== true) {
|
|
757
|
+
// Decode only unreserved bytes, once. In particular, keep %25 encoded
|
|
758
|
+
// so it cannot introduce a second escape during recomposition.
|
|
759
|
+
host = normalizePercentEncoding(host, true)
|
|
760
|
+
ipV6res = normalizeIPv6(host)
|
|
761
|
+
}
|
|
762
|
+
if (ipV6res.isIPV6 === true || ipV6res.isIPVFuture === true) {
|
|
556
763
|
host = `[${ipV6res.escapedHost}]`
|
|
557
764
|
} else {
|
|
558
765
|
host = reescapeHostDelimiters(host, false)
|
|
@@ -570,12 +777,19 @@ function recomposeAuthority (component) {
|
|
|
570
777
|
};
|
|
571
778
|
|
|
572
779
|
module.exports = {
|
|
780
|
+
BYTE_HEX,
|
|
781
|
+
percentEncodeNonAscii,
|
|
573
782
|
nonSimpleDomain,
|
|
783
|
+
nonSimpleMailtoDomain,
|
|
574
784
|
recomposeAuthority,
|
|
575
785
|
reescapeHostDelimiters,
|
|
576
786
|
normalizePercentEncoding,
|
|
577
787
|
normalizePathEncoding,
|
|
788
|
+
serializePathEncoding,
|
|
578
789
|
normalizeQueryFragmentEncoding,
|
|
790
|
+
encodeUserinfo,
|
|
791
|
+
encodeQuery,
|
|
792
|
+
encodeFragment,
|
|
579
793
|
escapePreservingEscapes,
|
|
580
794
|
removeDotSegments,
|
|
581
795
|
isIPv4,
|
package/package.json
CHANGED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
test('userinfo serialization cannot terminate the authority', (t) => {
|
|
7
|
+
const uri = fastURI.serialize({
|
|
8
|
+
scheme: 'http',
|
|
9
|
+
userinfo: 'attacker.example/',
|
|
10
|
+
host: 'trusted.example',
|
|
11
|
+
path: '/'
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
t.equal(uri, 'http://attacker.example%2F@trusted.example/', 'slash is encoded as userinfo data')
|
|
15
|
+
|
|
16
|
+
const reparsed = fastURI.parse(uri)
|
|
17
|
+
t.equal(reparsed.host, 'trusted.example', 'reparsing retains the supplied host')
|
|
18
|
+
t.equal(reparsed.userinfo, 'attacker.example%2F', 'encoded slash remains in userinfo')
|
|
19
|
+
t.equal(
|
|
20
|
+
fastURI.serialize({ userinfo: 'user@/?:#[]\\', host: 'example.test' }),
|
|
21
|
+
'//user%40%2F%3F:%23%5B%5D%5C@example.test',
|
|
22
|
+
'all userinfo component delimiters are encoded as data'
|
|
23
|
+
)
|
|
24
|
+
t.end()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('query and fragment serialization cannot inject component delimiters', (t) => {
|
|
28
|
+
const uri = fastURI.serialize({
|
|
29
|
+
scheme: 'x',
|
|
30
|
+
path: '/resource',
|
|
31
|
+
query: 'key=value#still-query',
|
|
32
|
+
fragment: 'first#still-fragment'
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
t.equal(
|
|
36
|
+
uri,
|
|
37
|
+
'x:/resource?key=value%23still-query#first%23still-fragment',
|
|
38
|
+
'raw hashes are encoded within their supplied components'
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const reparsed = fastURI.parse(uri)
|
|
42
|
+
t.equal(reparsed.query, 'key=value%23still-query', 'hash remains query data')
|
|
43
|
+
t.equal(reparsed.fragment, 'first%23still-fragment', 'hash remains fragment data')
|
|
44
|
+
t.end()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('component serializers preserve each RFC 3986 literal character set', (t) => {
|
|
48
|
+
const unreservedAndSubDelims = "AZaz09-._~!$&'()*+,;="
|
|
49
|
+
const userinfo = unreservedAndSubDelims + ':'
|
|
50
|
+
const queryOrFragment = unreservedAndSubDelims + ':@/?'
|
|
51
|
+
|
|
52
|
+
t.equal(
|
|
53
|
+
fastURI.serialize({ userinfo, host: 'example.test' }),
|
|
54
|
+
'//' + userinfo + '@example.test',
|
|
55
|
+
'userinfo literals are preserved'
|
|
56
|
+
)
|
|
57
|
+
t.equal(
|
|
58
|
+
fastURI.serialize({ query: queryOrFragment }),
|
|
59
|
+
'?' + queryOrFragment,
|
|
60
|
+
'query literals are preserved'
|
|
61
|
+
)
|
|
62
|
+
t.equal(
|
|
63
|
+
fastURI.serialize({ fragment: queryOrFragment }),
|
|
64
|
+
'#' + queryOrFragment,
|
|
65
|
+
'fragment literals are preserved'
|
|
66
|
+
)
|
|
67
|
+
t.end()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('component serializers preserve escapes and encode Unicode as UTF-8', (t) => {
|
|
71
|
+
t.equal(
|
|
72
|
+
fastURI.serialize({ userinfo: 'café/%2f%GG', host: 'example.test' }),
|
|
73
|
+
'//caf%C3%A9%2F%2F%25GG@example.test',
|
|
74
|
+
'userinfo preserves valid escapes, uppercases hex, and escapes malformed percent data'
|
|
75
|
+
)
|
|
76
|
+
t.equal(
|
|
77
|
+
fastURI.serialize({ query: '日本/%2f#%GG' }),
|
|
78
|
+
'?%E6%97%A5%E6%9C%AC/%2F%23%25GG',
|
|
79
|
+
'query uses UTF-8 and does not double encode valid escapes'
|
|
80
|
+
)
|
|
81
|
+
t.equal(
|
|
82
|
+
fastURI.serialize({ fragment: '😀/%3f#%GG' }),
|
|
83
|
+
'#%F0%9F%98%80/%3F%23%25GG',
|
|
84
|
+
'fragment uses UTF-8 and does not double encode valid escapes'
|
|
85
|
+
)
|
|
86
|
+
t.equal(
|
|
87
|
+
fastURI.serialize({ query: 'Kſ' }),
|
|
88
|
+
'?%E2%84%AA%C5%BF',
|
|
89
|
+
'Unicode characters that case-fold to ASCII are still UTF-8 encoded'
|
|
90
|
+
)
|
|
91
|
+
t.end()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('parsed URI serialization remains stable', (t) => {
|
|
95
|
+
const input = 'x://user:pass@example.test/a%2Fb?x=a/b?c&y=%23#frag/a?b%23'
|
|
96
|
+
const serialized = fastURI.serialize(fastURI.parse(input))
|
|
97
|
+
|
|
98
|
+
t.equal(serialized, input, 'an already normalized parsed URI round-trips unchanged')
|
|
99
|
+
t.equal(
|
|
100
|
+
fastURI.serialize(fastURI.parse(serialized)),
|
|
101
|
+
serialized,
|
|
102
|
+
'repeated parse and serialize cycles are stable'
|
|
103
|
+
)
|
|
104
|
+
t.end()
|
|
105
|
+
})
|
package/test/equal.test.js
CHANGED
|
@@ -21,6 +21,36 @@ test('URI Equals', (t) => {
|
|
|
21
21
|
t.end()
|
|
22
22
|
})
|
|
23
23
|
|
|
24
|
+
test('URI Equals rejects malformed object input even when identical', (t) => {
|
|
25
|
+
const malformed = { scheme: 'http', host: 'example.com', port: 99999, path: '/x' }
|
|
26
|
+
t.equal(fn(malformed, malformed), false)
|
|
27
|
+
t.end()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('URI Equals preserves case-sensitive components', (t) => {
|
|
31
|
+
const suite = [
|
|
32
|
+
{ pair: ['http://example.com/Admin', 'http://example.com/admin'], result: false },
|
|
33
|
+
{ pair: ['http://example.com/?token=SECRET', 'http://example.com/?token=secret'], result: false },
|
|
34
|
+
{ pair: ['http://example.com/#Section', 'http://example.com/#section'], result: false },
|
|
35
|
+
{ pair: ['http://User@example.com/', 'http://user@example.com/'], result: false },
|
|
36
|
+
{ pair: ['urn:foo:CaseSensitive', 'urn:foo:casesensitive'], result: false },
|
|
37
|
+
{ pair: ['ws://example.com/Chat', 'ws://example.com/chat'], result: false },
|
|
38
|
+
{ pair: ['ws://example.com/?token=SECRET', 'ws://example.com/?token=secret'], result: false },
|
|
39
|
+
{ pair: [{ scheme: 'http', host: 'example.com', path: '/Admin' }, { scheme: 'http', host: 'example.com', path: '/admin' }], result: false }
|
|
40
|
+
]
|
|
41
|
+
runTest(t, suite)
|
|
42
|
+
t.end()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('URI Equals folds normalized scheme and host case', (t) => {
|
|
46
|
+
const suite = [
|
|
47
|
+
{ pair: ['HTTP://EXAMPLE.COM/resource', 'http://example.com/resource'], result: true },
|
|
48
|
+
{ pair: [{ scheme: 'HTTP', host: 'EXAMPLE.COM', path: '/resource' }, { scheme: 'http', host: 'example.com', path: '/resource' }], result: true }
|
|
49
|
+
]
|
|
50
|
+
runTest(t, suite)
|
|
51
|
+
t.end()
|
|
52
|
+
})
|
|
53
|
+
|
|
24
54
|
// test('IRI Equals', (t) => {
|
|
25
55
|
// // example from RFC 3987
|
|
26
56
|
// t.equal(URI.equal('example://a/b/c/%7Bfoo%7D/ros\xE9', 'eXAMPLE://a/./b/../b/%63/%7bfoo%7d/ros%C3%A9', IRI_OPTION), true)
|
|
@@ -66,9 +96,7 @@ test('URN Equals', (t) => {
|
|
|
66
96
|
|
|
67
97
|
runTest(t, suite)
|
|
68
98
|
|
|
69
|
-
t.
|
|
70
|
-
fn('urn:', 'urn:FOO:a123,456')
|
|
71
|
-
}, 'URN without nid cannot be serialized')
|
|
99
|
+
t.equal(fn('urn:', 'urn:FOO:a123,456'), false, 'malformed URN fails equality safely')
|
|
72
100
|
|
|
73
101
|
t.end()
|
|
74
102
|
})
|
|
@@ -107,6 +135,18 @@ test('WSS Equal', (t) => {
|
|
|
107
135
|
t.end()
|
|
108
136
|
})
|
|
109
137
|
|
|
138
|
+
test('Mailto Equal normalizes domains without folding message data', (t) => {
|
|
139
|
+
const suite = [
|
|
140
|
+
{ pair: ['mailto:user@EXAMPLE.ORG', 'mailto:user@example.org'], result: true },
|
|
141
|
+
{ pair: ['mailto:User@example.org', 'mailto:user@example.org'], result: false },
|
|
142
|
+
{ pair: ['mailto:user@example.org?subject=Hello', 'mailto:user@example.org?subject=hello'], result: false },
|
|
143
|
+
{ pair: ['mailto:user@example.org?body=Hello', 'mailto:user@example.org?body=hello'], result: false },
|
|
144
|
+
{ pair: ['mailto:user@example.org?x=Hello', 'mailto:user@example.org?x=hello'], result: false }
|
|
145
|
+
]
|
|
146
|
+
runTest(t, suite)
|
|
147
|
+
t.end()
|
|
148
|
+
})
|
|
149
|
+
|
|
110
150
|
test('URI Equals tolerates malformed fragments', (t) => {
|
|
111
151
|
t.equal(
|
|
112
152
|
fastURI.equal('http://example.com/#%E0%A4A', 'http://example.com/#%E0%A4A'),
|