fast-uri 2.4.4 → 2.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +202 -63
- package/lib/schemes.js +9 -4
- package/lib/utils.js +424 -116
- package/package.json +2 -1
- package/test/compatibility.test.js +12 -0
- package/test/component-safe-serialization.test.js +105 -0
- package/test/equal.test.js +32 -0
- package/test/malformed-percent.test.js +77 -0
- package/test/malformed-urn.test.js +61 -0
- package/test/parse.test.js +3 -3
- package/test/query-fragment-normalization.test.js +33 -0
- package/test/reserved-path-normalization.test.js +109 -0
- package/test/scheme-validation.test.js +124 -0
- package/test/security-normalization.test.js +102 -2
- package/test/security.test.js +78 -7
- package/test/urn-full-input.test.js +29 -0
- package/test/websocket-query-preservation.test.js +24 -0
package/lib/utils.js
CHANGED
|
@@ -2,16 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const { HEX } = require('./scopedChars')
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const matches = host.match(/^(\b[01]?\d{1,2}|\b2[0-4]\d|\b25[0-5])(\.([01]?\d{1,2}|2[0-4]\d|25[0-5])){3}$/u) || []
|
|
8
|
-
const [address] = matches
|
|
9
|
-
if (address) {
|
|
10
|
-
return { host: stripLeadingZeros(address, '.'), isIPV4: true }
|
|
11
|
-
} else {
|
|
12
|
-
return { host, isIPV4: false }
|
|
13
|
-
}
|
|
14
|
-
}
|
|
5
|
+
/** @type {(value: string) => boolean} */
|
|
6
|
+
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)
|
|
15
7
|
|
|
16
8
|
/**
|
|
17
9
|
* @param {string[]} input
|
|
@@ -30,110 +22,172 @@ function stringArrayToHexStripped (input, keepZero = false) {
|
|
|
30
22
|
return acc
|
|
31
23
|
}
|
|
32
24
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const output = { error: false, address: '', zone: '' }
|
|
36
|
-
const address = []
|
|
37
|
-
const buffer = []
|
|
38
|
-
let isZone = false
|
|
39
|
-
let endipv6Encountered = false
|
|
40
|
-
let endIpv6 = false
|
|
41
|
-
|
|
42
|
-
function consume () {
|
|
43
|
-
if (buffer.length) {
|
|
44
|
-
if (isZone === false) {
|
|
45
|
-
const hex = stringArrayToHexStripped(buffer)
|
|
46
|
-
if (hex !== undefined) {
|
|
47
|
-
address.push(hex)
|
|
48
|
-
} else {
|
|
49
|
-
output.error = true
|
|
50
|
-
return false
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
buffer.length = 0
|
|
54
|
-
}
|
|
55
|
-
return true
|
|
56
|
-
}
|
|
25
|
+
/** @type {(value: string) => boolean} */
|
|
26
|
+
const isHextet = RegExp.prototype.test.bind(/^[\dA-Fa-f]{1,4}$/)
|
|
57
27
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
buffer.push(cursor)
|
|
28
|
+
/** @type {(value: string) => boolean} */
|
|
29
|
+
const isIPvFuture = RegExp.prototype.test.bind(/^[vV][\dA-Fa-f]+\.[A-Za-z\d\-._~!$&'()*+,;=:]+$/)
|
|
30
|
+
|
|
31
|
+
/** @type {(value: string) => boolean} */
|
|
32
|
+
const isZoneCharacter = RegExp.prototype.test.bind(/^[A-Za-z\d\-._~]$/)
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} value
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
/** @type {(value: string) => boolean} */
|
|
39
|
+
const nonSimpleDomain = RegExp.prototype.test.bind(/[^!"$&'()*+,\-.;=_`a-z{}~]/u)
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} zone
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
function isZoneIdentifier (zone) {
|
|
46
|
+
if (zone.length === 0) return false
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < zone.length; i++) {
|
|
49
|
+
if (isZoneCharacter(zone[i])) continue
|
|
50
|
+
if (zone[i] === '%' && i + 2 < zone.length && isHexPair(zone.slice(i + 1, i + 3))) {
|
|
51
|
+
i += 2
|
|
83
52
|
continue
|
|
84
53
|
}
|
|
54
|
+
return false
|
|
85
55
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
56
|
+
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compresses the longest run of zero hextets to "::" per RFC 5952. A run of a
|
|
62
|
+
* single zero hextet is left uncompressed. On ties the leftmost run wins.
|
|
63
|
+
*
|
|
64
|
+
* @param {string[]} hextets
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
function compressIPv6ZeroRun (hextets) {
|
|
68
|
+
let bestStart = -1
|
|
69
|
+
let bestLength = 0
|
|
70
|
+
let runStart = -1
|
|
71
|
+
let runLength = 0
|
|
72
|
+
for (let i = 0; i < hextets.length; i++) {
|
|
73
|
+
if (hextets[i] === '0') {
|
|
74
|
+
if (runStart === -1) runStart = i
|
|
75
|
+
runLength++
|
|
76
|
+
if (runLength > bestLength) {
|
|
77
|
+
bestLength = runLength
|
|
78
|
+
bestStart = runStart
|
|
79
|
+
}
|
|
91
80
|
} else {
|
|
92
|
-
|
|
81
|
+
runStart = -1
|
|
82
|
+
runLength = 0
|
|
93
83
|
}
|
|
94
84
|
}
|
|
95
|
-
|
|
96
|
-
return
|
|
85
|
+
|
|
86
|
+
if (bestLength < 2) return hextets.join(':')
|
|
87
|
+
|
|
88
|
+
const head = hextets.slice(0, bestStart).join(':')
|
|
89
|
+
const tail = hextets.slice(bestStart + bestLength).join(':')
|
|
90
|
+
return head + '::' + tail
|
|
97
91
|
}
|
|
98
92
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Validates an IPv6 address against the alternatives in RFC 3986 section
|
|
95
|
+
* 3.2.2 and returns the same address with leading hextet zeroes removed.
|
|
96
|
+
* An embedded IPv4 address counts as two hextets and is only valid at the end.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} input
|
|
99
|
+
* @returns {string|undefined}
|
|
100
|
+
*/
|
|
101
|
+
function normalizeIPv6Address (input) {
|
|
102
|
+
const compression = input.indexOf('::')
|
|
103
|
+
if (compression !== -1 && input.indexOf('::', compression + 1) !== -1) return undefined
|
|
104
|
+
|
|
105
|
+
const left = compression === -1 ? input.split(':') : input.slice(0, compression).split(':')
|
|
106
|
+
const right = compression === -1 ? [] : input.slice(compression + 2).split(':')
|
|
107
|
+
if (compression !== -1) {
|
|
108
|
+
if (left.length === 1 && left[0] === '') left.length = 0
|
|
109
|
+
if (right.length === 1 && right[0] === '') right.length = 0
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const parts = left.concat(right)
|
|
113
|
+
let hextetCount = 0
|
|
114
|
+
for (let i = 0; i < parts.length; i++) {
|
|
115
|
+
const part = parts[i]
|
|
116
|
+
if (part === '') return undefined
|
|
102
117
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
newHost += '%' + ipv6.zone
|
|
108
|
-
escapedHost += '%25' + ipv6.zone
|
|
118
|
+
if (part.indexOf('.') !== -1) {
|
|
119
|
+
if (i !== parts.length - 1 || (compression !== -1 && right.length === 0) || !isIPv4(part)) return undefined
|
|
120
|
+
hextetCount += 2
|
|
121
|
+
continue
|
|
109
122
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
123
|
+
|
|
124
|
+
if (!isHextet(part)) return undefined
|
|
125
|
+
parts[i] = parseInt(part, 16).toString(16)
|
|
126
|
+
hextetCount++
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (compression === -1) {
|
|
130
|
+
if (hextetCount !== 8) return undefined
|
|
131
|
+
return compressIPv6ZeroRun(parts)
|
|
113
132
|
}
|
|
133
|
+
if (hextetCount >= 8) return undefined
|
|
134
|
+
|
|
135
|
+
// expand "::" then re-compress the longest run for a canonical result
|
|
136
|
+
const expanded = parts.slice(0, left.length)
|
|
137
|
+
for (let i = hextetCount; i < 8; i++) expanded.push('0')
|
|
138
|
+
for (let i = left.length; i < parts.length; i++) expanded.push(parts[i])
|
|
139
|
+
return compressIPv6ZeroRun(expanded)
|
|
114
140
|
}
|
|
115
141
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
142
|
+
/**
|
|
143
|
+
* @typedef {Object} NormalizeIPv6Result
|
|
144
|
+
* @property {string} host - The normalized host.
|
|
145
|
+
* @property {string} [escapedHost] - The escaped host.
|
|
146
|
+
* @property {boolean} isIPV6 - Indicates if the host is an IPv6 address.
|
|
147
|
+
* @property {boolean} [isIPVFuture] - Indicates if the host is an IPvFuture literal.
|
|
148
|
+
* @property {boolean} [error] - Indicates if a bracketed IP literal is malformed.
|
|
149
|
+
*/
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Validates and normalizes a bracketed IP literal. Raw zone separators remain
|
|
153
|
+
* accepted for backwards compatibility, while encoded separators and zone
|
|
154
|
+
* contents follow RFC 6874.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} host
|
|
157
|
+
* @returns {NormalizeIPv6Result}
|
|
158
|
+
*/
|
|
159
|
+
function normalizeIPv6 (host) {
|
|
160
|
+
const bracketed = host[0] === '[' && host[host.length - 1] === ']'
|
|
161
|
+
const hasBracket = host[0] === '[' || host[host.length - 1] === ']'
|
|
162
|
+
if (hasBracket && !bracketed) return { host, isIPV6: false, error: true }
|
|
163
|
+
|
|
164
|
+
let input = bracketed ? host.slice(1, -1) : host
|
|
165
|
+
if (bracketed && isIPvFuture(input)) {
|
|
166
|
+
input = input.toLowerCase()
|
|
167
|
+
return { host: `[${input}]`, escapedHost: input, isIPV6: false, isIPVFuture: true }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (findToken(input, ':') < 2) {
|
|
171
|
+
return { host, isIPV6: false, error: bracketed }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let zoneIdentifier = ''
|
|
175
|
+
const zoneSeparator = input.indexOf('%')
|
|
176
|
+
if (zoneSeparator !== -1) {
|
|
177
|
+
const separatorLength = input.slice(zoneSeparator, zoneSeparator + 3).toLowerCase() === '%25' ? 3 : 1
|
|
178
|
+
zoneIdentifier = input.slice(zoneSeparator + separatorLength)
|
|
179
|
+
if (!isZoneIdentifier(zoneIdentifier)) return { host, isIPV6: false, error: true }
|
|
180
|
+
input = input.slice(0, zoneSeparator)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const address = normalizeIPv6Address(input)
|
|
184
|
+
if (address === undefined) return { host, isIPV6: false, error: true }
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
host: address + (zoneIdentifier ? '%' + zoneIdentifier : ''),
|
|
188
|
+
escapedHost: address + (zoneIdentifier ? '%25' + zoneIdentifier : ''),
|
|
189
|
+
isIPV6: true
|
|
135
190
|
}
|
|
136
|
-
return out
|
|
137
191
|
}
|
|
138
192
|
|
|
139
193
|
function findToken (str, token) {
|
|
@@ -198,11 +252,40 @@ function reescapeHostDelimiters (host, isIP) {
|
|
|
198
252
|
|
|
199
253
|
const isHexPair = RegExp.prototype.test.bind(/^[\da-f]{2}$/iu)
|
|
200
254
|
const isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu)
|
|
201
|
-
const isPathCharacter = RegExp.prototype.test.bind(/^[
|
|
255
|
+
const isPathCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:@/]$/u)
|
|
256
|
+
|
|
257
|
+
/** @type {(value: string) => boolean} */
|
|
258
|
+
const isQueryFragmentCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:@/?]$/u)
|
|
259
|
+
|
|
260
|
+
/** @type {(value: string) => boolean} */
|
|
261
|
+
const isUserinfoCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:]$/u)
|
|
262
|
+
|
|
263
|
+
const BYTE_HEX = new Array(256)
|
|
264
|
+
{
|
|
265
|
+
const HEX_DIGITS = '0123456789ABCDEF'
|
|
266
|
+
for (let i = 0; i < 256; i++) {
|
|
267
|
+
BYTE_HEX[i] = '%' + HEX_DIGITS[i >> 4] + HEX_DIGITS[i & 0xF]
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function percentEncodeNonAscii (cp) {
|
|
271
|
+
if (cp < 0x800) {
|
|
272
|
+
return BYTE_HEX[0xC0 | (cp >> 6)] +
|
|
273
|
+
BYTE_HEX[0x80 | (cp & 0x3F)]
|
|
274
|
+
}
|
|
275
|
+
if (cp < 0x10000) {
|
|
276
|
+
return BYTE_HEX[0xE0 | (cp >> 12)] +
|
|
277
|
+
BYTE_HEX[0x80 | ((cp >> 6) & 0x3F)] +
|
|
278
|
+
BYTE_HEX[0x80 | (cp & 0x3F)]
|
|
279
|
+
}
|
|
280
|
+
return BYTE_HEX[0xF0 | (cp >> 18)] +
|
|
281
|
+
BYTE_HEX[0x80 | ((cp >> 12) & 0x3F)] +
|
|
282
|
+
BYTE_HEX[0x80 | ((cp >> 6) & 0x3F)] +
|
|
283
|
+
BYTE_HEX[0x80 | (cp & 0x3F)]
|
|
284
|
+
}
|
|
202
285
|
|
|
203
286
|
/**
|
|
204
287
|
* Normalizes percent escapes and optionally decodes only unreserved ASCII bytes.
|
|
205
|
-
* Reserved delimiters such as `%2F`
|
|
288
|
+
* Reserved delimiters such as `%2F` stay escaped; `%2E` is unreserved.
|
|
206
289
|
*
|
|
207
290
|
* @param {string} input
|
|
208
291
|
* @param {boolean} [decodeUnreserved=false]
|
|
@@ -251,7 +334,8 @@ function normalizePathEncoding (input) {
|
|
|
251
334
|
let output = ''
|
|
252
335
|
|
|
253
336
|
for (let i = 0; i < input.length; i++) {
|
|
254
|
-
|
|
337
|
+
const ch = input[i]
|
|
338
|
+
if (ch === '%' && i + 2 < input.length) {
|
|
255
339
|
const hex = input.slice(i + 1, i + 3)
|
|
256
340
|
if (isHexPair(hex)) {
|
|
257
341
|
const normalizedHex = hex.toUpperCase()
|
|
@@ -268,10 +352,225 @@ function normalizePathEncoding (input) {
|
|
|
268
352
|
}
|
|
269
353
|
}
|
|
270
354
|
|
|
271
|
-
if (isPathCharacter(
|
|
272
|
-
output +=
|
|
355
|
+
if (isPathCharacter(ch)) {
|
|
356
|
+
output += ch
|
|
273
357
|
} else {
|
|
274
|
-
|
|
358
|
+
const code = input.charCodeAt(i)
|
|
359
|
+
if (code < 0x80) {
|
|
360
|
+
output += isEscapeSafe(code) ? ch : BYTE_HEX[code]
|
|
361
|
+
} else if (code < 0xD800 || code > 0xDFFF) {
|
|
362
|
+
output += percentEncodeNonAscii(code)
|
|
363
|
+
} else if (code <= 0xDBFF && i + 1 < input.length) {
|
|
364
|
+
const low = input.charCodeAt(i + 1)
|
|
365
|
+
if (low >= 0xDC00 && low <= 0xDFFF) {
|
|
366
|
+
output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
|
|
367
|
+
i++
|
|
368
|
+
} else {
|
|
369
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return output
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Serializes a path without rewriting reserved data. Raw RFC 3986 path
|
|
382
|
+
* characters remain literal, valid escapes are preserved and uppercased, and
|
|
383
|
+
* everything else is UTF-8 percent-encoded. In a path-noscheme, a colon in the
|
|
384
|
+
* first segment must be escaped so the result cannot be parsed as a scheme.
|
|
385
|
+
*
|
|
386
|
+
* @param {string} input
|
|
387
|
+
* @param {boolean} [pathNoScheme=false]
|
|
388
|
+
* @returns {string}
|
|
389
|
+
*/
|
|
390
|
+
function serializePathEncoding (input, pathNoScheme = false) {
|
|
391
|
+
let output = ''
|
|
392
|
+
let firstSegment = pathNoScheme && input[0] !== '/'
|
|
393
|
+
|
|
394
|
+
for (let i = 0; i < input.length; i++) {
|
|
395
|
+
const ch = input[i]
|
|
396
|
+
if (ch === '%' && i + 2 < input.length) {
|
|
397
|
+
const hex = input.slice(i + 1, i + 3)
|
|
398
|
+
if (isHexPair(hex)) {
|
|
399
|
+
output += '%' + hex.toUpperCase()
|
|
400
|
+
i += 2
|
|
401
|
+
continue
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (ch === '/') {
|
|
406
|
+
firstSegment = false
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (isPathCharacter(ch) && (ch !== ':' || !firstSegment)) {
|
|
410
|
+
output += ch
|
|
411
|
+
} else {
|
|
412
|
+
const code = input.charCodeAt(i)
|
|
413
|
+
if (code < 0x80) {
|
|
414
|
+
output += BYTE_HEX[code]
|
|
415
|
+
} else if (code < 0xD800 || code > 0xDFFF) {
|
|
416
|
+
output += percentEncodeNonAscii(code)
|
|
417
|
+
} else if (code <= 0xDBFF && i + 1 < input.length) {
|
|
418
|
+
const low = input.charCodeAt(i + 1)
|
|
419
|
+
if (low >= 0xDC00 && low <= 0xDFFF) {
|
|
420
|
+
output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
|
|
421
|
+
i++
|
|
422
|
+
} else {
|
|
423
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
424
|
+
}
|
|
425
|
+
} else {
|
|
426
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return output
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Percent-encodes a URI component using its RFC 3986 literal character set.
|
|
436
|
+
* Existing valid escapes are preserved and normalized to uppercase hex.
|
|
437
|
+
*
|
|
438
|
+
* @param {string} input
|
|
439
|
+
* @param {(value: string) => boolean} isAllowed
|
|
440
|
+
* @returns {string}
|
|
441
|
+
*/
|
|
442
|
+
function encodeComponent (input, isAllowed) {
|
|
443
|
+
let output = ''
|
|
444
|
+
|
|
445
|
+
for (let i = 0; i < input.length; i++) {
|
|
446
|
+
const ch = input[i]
|
|
447
|
+
if (ch === '%' && i + 2 < input.length) {
|
|
448
|
+
const hex = input.slice(i + 1, i + 3)
|
|
449
|
+
if (isHexPair(hex)) {
|
|
450
|
+
output += '%' + hex.toUpperCase()
|
|
451
|
+
i += 2
|
|
452
|
+
continue
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (isAllowed(ch)) {
|
|
457
|
+
output += ch
|
|
458
|
+
} else {
|
|
459
|
+
const code = input.charCodeAt(i)
|
|
460
|
+
if (code < 0x80) {
|
|
461
|
+
output += BYTE_HEX[code]
|
|
462
|
+
} else if (code < 0xD800 || code > 0xDFFF) {
|
|
463
|
+
output += percentEncodeNonAscii(code)
|
|
464
|
+
} else if (code <= 0xDBFF && i + 1 < input.length) {
|
|
465
|
+
const low = input.charCodeAt(i + 1)
|
|
466
|
+
if (low >= 0xDC00 && low <= 0xDFFF) {
|
|
467
|
+
output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
|
|
468
|
+
i++
|
|
469
|
+
} else {
|
|
470
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
471
|
+
}
|
|
472
|
+
} else {
|
|
473
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return output
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Encodes userinfo while preserving its RFC 3986 §3.2.1 literal characters.
|
|
483
|
+
* In particular, authority delimiters such as `@`, `/`, `?`, and `#` are data.
|
|
484
|
+
*
|
|
485
|
+
* @param {string} input
|
|
486
|
+
* @returns {string}
|
|
487
|
+
*/
|
|
488
|
+
function encodeUserinfo (input) {
|
|
489
|
+
return encodeComponent(input, isUserinfoCharacter)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Encodes query data using the RFC 3986 §3.4 grammar. A literal `#` must be
|
|
494
|
+
* escaped because it would otherwise begin the fragment component.
|
|
495
|
+
*
|
|
496
|
+
* @param {string} input
|
|
497
|
+
* @returns {string}
|
|
498
|
+
*/
|
|
499
|
+
function encodeQuery (input) {
|
|
500
|
+
return encodeComponent(input, isQueryFragmentCharacter)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Encodes fragment data using the RFC 3986 §3.5 grammar.
|
|
505
|
+
*
|
|
506
|
+
* @param {string} input
|
|
507
|
+
* @returns {string}
|
|
508
|
+
*/
|
|
509
|
+
function encodeFragment (input) {
|
|
510
|
+
return encodeComponent(input, isQueryFragmentCharacter)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function isEscapeSafe (cp) {
|
|
514
|
+
return (
|
|
515
|
+
(cp >= 0x30 && cp <= 0x39) ||
|
|
516
|
+
(cp >= 0x41 && cp <= 0x5A) ||
|
|
517
|
+
(cp >= 0x61 && cp <= 0x7A) ||
|
|
518
|
+
cp === 0x2A || cp === 0x2B || cp === 0x2D || cp === 0x2E ||
|
|
519
|
+
cp === 0x2F || cp === 0x40 || cp === 0x5F
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Normalizes the percent-encoding of a query or fragment component.
|
|
525
|
+
*
|
|
526
|
+
* Like `normalizePathEncoding`, but uses the query/fragment character set
|
|
527
|
+
* (which additionally allows `?`) and decodes `.` since it has no dot-segment
|
|
528
|
+
* meaning outside of a path.
|
|
529
|
+
*
|
|
530
|
+
* @param {string} input
|
|
531
|
+
* @returns {string}
|
|
532
|
+
*/
|
|
533
|
+
function normalizeQueryFragmentEncoding (input) {
|
|
534
|
+
let output = ''
|
|
535
|
+
|
|
536
|
+
for (let i = 0; i < input.length; i++) {
|
|
537
|
+
const ch = input[i]
|
|
538
|
+
if (ch === '%' && i + 2 < input.length) {
|
|
539
|
+
const hex = input.slice(i + 1, i + 3)
|
|
540
|
+
if (isHexPair(hex)) {
|
|
541
|
+
const normalizedHex = hex.toUpperCase()
|
|
542
|
+
const decoded = String.fromCharCode(parseInt(normalizedHex, 16))
|
|
543
|
+
|
|
544
|
+
if (isUnreserved(decoded)) {
|
|
545
|
+
output += decoded
|
|
546
|
+
} else {
|
|
547
|
+
output += '%' + normalizedHex
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
i += 2
|
|
551
|
+
continue
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (isQueryFragmentCharacter(ch)) {
|
|
556
|
+
output += ch
|
|
557
|
+
} else {
|
|
558
|
+
const code = input.charCodeAt(i)
|
|
559
|
+
if (code < 0x80) {
|
|
560
|
+
output += isEscapeSafe(code) ? ch : BYTE_HEX[code]
|
|
561
|
+
} else if (code < 0xD800 || code > 0xDFFF) {
|
|
562
|
+
output += percentEncodeNonAscii(code)
|
|
563
|
+
} else if (code <= 0xDBFF && i + 1 < input.length) {
|
|
564
|
+
const low = input.charCodeAt(i + 1)
|
|
565
|
+
if (low >= 0xDC00 && low <= 0xDFFF) {
|
|
566
|
+
output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
|
|
567
|
+
i++
|
|
568
|
+
} else {
|
|
569
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
570
|
+
}
|
|
571
|
+
} else {
|
|
572
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
573
|
+
}
|
|
275
574
|
}
|
|
276
575
|
}
|
|
277
576
|
|
|
@@ -307,19 +606,21 @@ function recomposeAuthority (components, options) {
|
|
|
307
606
|
const uriTokens = []
|
|
308
607
|
|
|
309
608
|
if (components.userinfo !== undefined) {
|
|
310
|
-
uriTokens.push(components.userinfo)
|
|
609
|
+
uriTokens.push(encodeUserinfo(components.userinfo))
|
|
311
610
|
uriTokens.push('@')
|
|
312
611
|
}
|
|
313
612
|
|
|
314
613
|
if (components.host !== undefined) {
|
|
315
|
-
let host =
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
614
|
+
let host = components.host
|
|
615
|
+
if (!isIPv4(host)) {
|
|
616
|
+
let ipV6res = normalizeIPv6(host)
|
|
617
|
+
if (ipV6res.isIPV6 !== true && ipV6res.isIPVFuture !== true) {
|
|
618
|
+
// Decode only unreserved bytes, once. In particular, keep %25 encoded
|
|
619
|
+
// so it cannot introduce a second escape during recomposition.
|
|
620
|
+
host = normalizePercentEncoding(host, true)
|
|
621
|
+
ipV6res = normalizeIPv6(host)
|
|
622
|
+
}
|
|
623
|
+
if (ipV6res.isIPV6 === true || ipV6res.isIPVFuture === true) {
|
|
323
624
|
host = `[${ipV6res.escapedHost}]`
|
|
324
625
|
} else {
|
|
325
626
|
host = reescapeHostDelimiters(host, false)
|
|
@@ -337,13 +638,20 @@ function recomposeAuthority (components, options) {
|
|
|
337
638
|
};
|
|
338
639
|
|
|
339
640
|
module.exports = {
|
|
641
|
+
nonSimpleDomain,
|
|
340
642
|
recomposeAuthority,
|
|
341
643
|
reescapeHostDelimiters,
|
|
342
644
|
normalizePercentEncoding,
|
|
343
645
|
normalizePathEncoding,
|
|
646
|
+
serializePathEncoding,
|
|
647
|
+
normalizeQueryFragmentEncoding,
|
|
648
|
+
encodeUserinfo,
|
|
649
|
+
encodeQuery,
|
|
650
|
+
encodeFragment,
|
|
344
651
|
escapePreservingEscapes,
|
|
345
652
|
removeDotSegments,
|
|
346
|
-
|
|
653
|
+
isIPv4,
|
|
347
654
|
normalizeIPv6,
|
|
348
|
-
stringArrayToHexStripped
|
|
655
|
+
stringArrayToHexStripped,
|
|
656
|
+
isIPvFuture
|
|
349
657
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fast-uri",
|
|
3
3
|
"description": "Dependency free RFC 3986 URI toolbox",
|
|
4
|
-
"version": "2.4.
|
|
4
|
+
"version": "2.4.5",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "commonjs",
|
|
7
7
|
"types": "types/index.d.ts",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"snazzy": "^9.0.0",
|
|
32
32
|
"standard": "^17.0.0",
|
|
33
33
|
"tap": "^16.0.0",
|
|
34
|
+
"tape": "^5.8.1",
|
|
34
35
|
"tsd": "^0.31.0",
|
|
35
36
|
"uri-js": "^4.4.1"
|
|
36
37
|
}
|
|
@@ -57,6 +57,18 @@ test('compatibility Parse', (t) => {
|
|
|
57
57
|
// 'mailto:user@%E7%B4%8D%E8%B1%86.example.org?subject=Test&body=NATTO'
|
|
58
58
|
]
|
|
59
59
|
toParse.forEach((x) => {
|
|
60
|
+
if (x === '//10.10.000.10') {
|
|
61
|
+
t.skip('Skipping //10.10.000.10 as it is not a valid URI per URI spec: https://datatracker.ietf.org/doc/html/rfc5954#section-4.1')
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
// Malformed bracketed IP literals are rejected by fast-uri without being
|
|
65
|
+
// rewritten; uri-js silently accepts them, so pin the expected output.
|
|
66
|
+
if (x === '//[2001:dbZ::1]:80' || x === '//[2606:2800:220:1:248:1893:25c8:1946:43209]') {
|
|
67
|
+
const parsed = fastifyURI.parse(x)
|
|
68
|
+
t.equal(parsed.error, 'URI host is malformed.', 'Compatibility parse: ' + x)
|
|
69
|
+
t.equal(parsed.host, x.slice(2, x.indexOf(']') + 1).toLowerCase(), 'Compatibility parse host: ' + x)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
60
72
|
t.same(fastifyURI.parse(x), urijs.parse(x), 'Compatibility parse: ' + x)
|
|
61
73
|
})
|
|
62
74
|
t.end()
|