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