@openclaw/lobster 2026.6.33 → 2026.6.34
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/node_modules/fast-uri/.github/dependabot.yml +42 -2
- package/node_modules/fast-uri/.github/workflows/ci.yml +54 -6
- package/node_modules/fast-uri/.github/workflows/lock-threads.yml +1 -1
- package/node_modules/fast-uri/.github/workflows/ossf-scorecard.yml +45 -0
- package/node_modules/fast-uri/README.md +10 -9
- package/node_modules/fast-uri/SECURITY.md +168 -0
- package/node_modules/fast-uri/benchmark/encoder.mjs +54 -0
- package/node_modules/fast-uri/index.js +61 -9
- package/node_modules/fast-uri/lib/utils.js +149 -7
- package/node_modules/fast-uri/package.json +4 -4
- package/node_modules/fast-uri/test/equal.test.js +51 -0
- package/node_modules/fast-uri/test/parse.test.js +5 -3
- package/node_modules/fast-uri/test/query-fragment-normalization.test.js +33 -0
- package/node_modules/fast-uri/test/scheme-normalization.test.js +16 -0
- package/node_modules/fast-uri/test/security-utf8-encoding.test.js +0 -0
- package/node_modules/fast-uri/test/security.test.js +226 -0
- package/node_modules/fast-uri/test/uri-js.test.js +1 -1
- package/node_modules/fast-uri/test/util.test.js +9 -0
- package/node_modules/fast-uri/tsconfig.json +8 -7
- package/node_modules/fast-uri/types/index.d.ts +0 -9
- package/node_modules/fast-uri/types/index.tst.ts +15 -0
- package/npm-shrinkwrap.json +5 -5
- package/package.json +4 -4
- package/node_modules/fast-uri/.github/workflows/package-manager-ci.yml +0 -24
- package/node_modules/fast-uri/types/index.test-d.ts +0 -17
|
@@ -15,6 +15,9 @@ const isUnreserved = RegExp.prototype.test.bind(/^[\da-z\-._~]$/iu)
|
|
|
15
15
|
/** @type {(value: string) => boolean} */
|
|
16
16
|
const isPathCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/]$/iu)
|
|
17
17
|
|
|
18
|
+
/** @type {(value: string) => boolean} */
|
|
19
|
+
const isQueryFragmentCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/?]$/iu)
|
|
20
|
+
|
|
18
21
|
/**
|
|
19
22
|
* @param {Array<string>} input
|
|
20
23
|
* @returns {string}
|
|
@@ -218,7 +221,7 @@ function removeDotSegments (path) {
|
|
|
218
221
|
continue
|
|
219
222
|
}
|
|
220
223
|
} else if (input[0] === '/') {
|
|
221
|
-
if (input[1] === '.'
|
|
224
|
+
if (input[1] === '.') {
|
|
222
225
|
output.push('/')
|
|
223
226
|
break
|
|
224
227
|
}
|
|
@@ -331,6 +334,55 @@ function normalizePercentEncoding (input, decodeUnreserved = false) {
|
|
|
331
334
|
return output
|
|
332
335
|
}
|
|
333
336
|
|
|
337
|
+
/** Pre-built `%XX` strings for every byte. Avoids per-call hex math/concat. */
|
|
338
|
+
const BYTE_HEX = new Array(256)
|
|
339
|
+
{
|
|
340
|
+
const HEX_DIGITS = '0123456789ABCDEF'
|
|
341
|
+
for (let i = 0; i < 256; i++) {
|
|
342
|
+
BYTE_HEX[i] = '%' + HEX_DIGITS[i >> 4] + HEX_DIGITS[i & 0xF]
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Mirrors the pass-through set of the deprecated `escape()` global:
|
|
348
|
+
* `A-Z a-z 0-9 * + - . / @ _`. Keeps drop-in semantics for ASCII input.
|
|
349
|
+
*
|
|
350
|
+
* @param {number} cp
|
|
351
|
+
* @returns {boolean}
|
|
352
|
+
*/
|
|
353
|
+
function isEscapeSafe (cp) {
|
|
354
|
+
return (
|
|
355
|
+
(cp >= 0x30 && cp <= 0x39) ||
|
|
356
|
+
(cp >= 0x41 && cp <= 0x5A) ||
|
|
357
|
+
(cp >= 0x61 && cp <= 0x7A) ||
|
|
358
|
+
cp === 0x2A || cp === 0x2B || cp === 0x2D || cp === 0x2E ||
|
|
359
|
+
cp === 0x2F || cp === 0x40 || cp === 0x5F
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* RFC 3986 percent-encode a non-ASCII Unicode code point as UTF-8 bytes.
|
|
365
|
+
* Caller handles the ASCII branch inline for speed.
|
|
366
|
+
*
|
|
367
|
+
* @param {number} cp - Unicode code point (>= 0x80, <= 0x10FFFF)
|
|
368
|
+
* @returns {string}
|
|
369
|
+
*/
|
|
370
|
+
function percentEncodeNonAscii (cp) {
|
|
371
|
+
if (cp < 0x800) {
|
|
372
|
+
return BYTE_HEX[0xC0 | (cp >> 6)] +
|
|
373
|
+
BYTE_HEX[0x80 | (cp & 0x3F)]
|
|
374
|
+
}
|
|
375
|
+
if (cp < 0x10000) {
|
|
376
|
+
return BYTE_HEX[0xE0 | (cp >> 12)] +
|
|
377
|
+
BYTE_HEX[0x80 | ((cp >> 6) & 0x3F)] +
|
|
378
|
+
BYTE_HEX[0x80 | (cp & 0x3F)]
|
|
379
|
+
}
|
|
380
|
+
return BYTE_HEX[0xF0 | (cp >> 18)] +
|
|
381
|
+
BYTE_HEX[0x80 | ((cp >> 12) & 0x3F)] +
|
|
382
|
+
BYTE_HEX[0x80 | ((cp >> 6) & 0x3F)] +
|
|
383
|
+
BYTE_HEX[0x80 | (cp & 0x3F)]
|
|
384
|
+
}
|
|
385
|
+
|
|
334
386
|
/**
|
|
335
387
|
* Normalizes path data without turning reserved escapes into live path syntax.
|
|
336
388
|
* Valid escapes are uppercased, raw unsafe characters are escaped, and only
|
|
@@ -343,7 +395,8 @@ function normalizePathEncoding (input) {
|
|
|
343
395
|
let output = ''
|
|
344
396
|
|
|
345
397
|
for (let i = 0; i < input.length; i++) {
|
|
346
|
-
|
|
398
|
+
const ch = input[i]
|
|
399
|
+
if (ch === '%' && i + 2 < input.length) {
|
|
347
400
|
const hex = input.slice(i + 1, i + 3)
|
|
348
401
|
if (isHexPair(hex)) {
|
|
349
402
|
const normalizedHex = hex.toUpperCase()
|
|
@@ -360,10 +413,82 @@ function normalizePathEncoding (input) {
|
|
|
360
413
|
}
|
|
361
414
|
}
|
|
362
415
|
|
|
363
|
-
if (isPathCharacter(
|
|
364
|
-
output +=
|
|
416
|
+
if (isPathCharacter(ch)) {
|
|
417
|
+
output += ch
|
|
365
418
|
} else {
|
|
366
|
-
|
|
419
|
+
const code = input.charCodeAt(i)
|
|
420
|
+
if (code < 0x80) {
|
|
421
|
+
output += isEscapeSafe(code) ? ch : BYTE_HEX[code]
|
|
422
|
+
} else if (code < 0xD800 || code > 0xDFFF) {
|
|
423
|
+
output += percentEncodeNonAscii(code)
|
|
424
|
+
} else if (code <= 0xDBFF && i + 1 < input.length) {
|
|
425
|
+
const low = input.charCodeAt(i + 1)
|
|
426
|
+
if (low >= 0xDC00 && low <= 0xDFFF) {
|
|
427
|
+
output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
|
|
428
|
+
i++
|
|
429
|
+
} else {
|
|
430
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
431
|
+
}
|
|
432
|
+
} else {
|
|
433
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return output
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Normalizes the percent-encoding of a query or fragment component.
|
|
443
|
+
*
|
|
444
|
+
* Like `normalizePathEncoding`, but uses the query/fragment character set
|
|
445
|
+
* (which additionally allows `?`) and decodes `.` since it has no dot-segment
|
|
446
|
+
* meaning outside of a path.
|
|
447
|
+
*
|
|
448
|
+
* @param {string} input
|
|
449
|
+
* @returns {string}
|
|
450
|
+
*/
|
|
451
|
+
function normalizeQueryFragmentEncoding (input) {
|
|
452
|
+
let output = ''
|
|
453
|
+
|
|
454
|
+
for (let i = 0; i < input.length; i++) {
|
|
455
|
+
const ch = input[i]
|
|
456
|
+
if (ch === '%' && i + 2 < input.length) {
|
|
457
|
+
const hex = input.slice(i + 1, i + 3)
|
|
458
|
+
if (isHexPair(hex)) {
|
|
459
|
+
const normalizedHex = hex.toUpperCase()
|
|
460
|
+
const decoded = String.fromCharCode(parseInt(normalizedHex, 16))
|
|
461
|
+
|
|
462
|
+
if (isUnreserved(decoded)) {
|
|
463
|
+
output += decoded
|
|
464
|
+
} else {
|
|
465
|
+
output += '%' + normalizedHex
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
i += 2
|
|
469
|
+
continue
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (isQueryFragmentCharacter(ch)) {
|
|
474
|
+
output += ch
|
|
475
|
+
} else {
|
|
476
|
+
const code = input.charCodeAt(i)
|
|
477
|
+
if (code < 0x80) {
|
|
478
|
+
output += isEscapeSafe(code) ? ch : BYTE_HEX[code]
|
|
479
|
+
} else if (code < 0xD800 || code > 0xDFFF) {
|
|
480
|
+
output += percentEncodeNonAscii(code)
|
|
481
|
+
} else if (code <= 0xDBFF && i + 1 < input.length) {
|
|
482
|
+
const low = input.charCodeAt(i + 1)
|
|
483
|
+
if (low >= 0xDC00 && low <= 0xDFFF) {
|
|
484
|
+
output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
|
|
485
|
+
i++
|
|
486
|
+
} else {
|
|
487
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
488
|
+
}
|
|
489
|
+
} else {
|
|
490
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
491
|
+
}
|
|
367
492
|
}
|
|
368
493
|
}
|
|
369
494
|
|
|
@@ -380,7 +505,8 @@ function escapePreservingEscapes (input) {
|
|
|
380
505
|
let output = ''
|
|
381
506
|
|
|
382
507
|
for (let i = 0; i < input.length; i++) {
|
|
383
|
-
|
|
508
|
+
const ch = input[i]
|
|
509
|
+
if (ch === '%' && i + 2 < input.length) {
|
|
384
510
|
const hex = input.slice(i + 1, i + 3)
|
|
385
511
|
if (isHexPair(hex)) {
|
|
386
512
|
output += '%' + hex.toUpperCase()
|
|
@@ -389,7 +515,22 @@ function escapePreservingEscapes (input) {
|
|
|
389
515
|
}
|
|
390
516
|
}
|
|
391
517
|
|
|
392
|
-
|
|
518
|
+
const code = input.charCodeAt(i)
|
|
519
|
+
if (code < 0x80) {
|
|
520
|
+
output += isEscapeSafe(code) ? ch : BYTE_HEX[code]
|
|
521
|
+
} else if (code < 0xD800 || code > 0xDFFF) {
|
|
522
|
+
output += percentEncodeNonAscii(code)
|
|
523
|
+
} else if (code <= 0xDBFF && i + 1 < input.length) {
|
|
524
|
+
const low = input.charCodeAt(i + 1)
|
|
525
|
+
if (low >= 0xDC00 && low <= 0xDFFF) {
|
|
526
|
+
output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
|
|
527
|
+
i++
|
|
528
|
+
} else {
|
|
529
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
530
|
+
}
|
|
531
|
+
} else {
|
|
532
|
+
output += percentEncodeNonAscii(0xFFFD)
|
|
533
|
+
}
|
|
393
534
|
}
|
|
394
535
|
|
|
395
536
|
return output
|
|
@@ -434,6 +575,7 @@ module.exports = {
|
|
|
434
575
|
reescapeHostDelimiters,
|
|
435
576
|
normalizePercentEncoding,
|
|
436
577
|
normalizePathEncoding,
|
|
578
|
+
normalizeQueryFragmentEncoding,
|
|
437
579
|
escapePreservingEscapes,
|
|
438
580
|
removeDotSegments,
|
|
439
581
|
isIPv4,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fast-uri",
|
|
3
3
|
"description": "Dependency-free RFC 3986 URI toolbox",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "4.1.2",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "commonjs",
|
|
7
7
|
"types": "types/index.d.ts",
|
|
@@ -48,14 +48,14 @@
|
|
|
48
48
|
"scripts": {
|
|
49
49
|
"lint": "eslint",
|
|
50
50
|
"lint:fix": "eslint --fix",
|
|
51
|
-
"test": "npm run test:unit
|
|
51
|
+
"test": "npm run test:unit",
|
|
52
52
|
"test:browser:chromium": "playwright-test ./test/* --runner tape --browser=chromium",
|
|
53
53
|
"test:browser:firefox": "playwright-test ./test/* --runner tape --browser=firefox",
|
|
54
54
|
"test:browser:webkit": "playwright-test ./test/* --runner tape --browser=webkit",
|
|
55
55
|
"test:browser": "npm run test:browser:chromium && npm run test:browser:firefox && npm run test:browser:webkit",
|
|
56
56
|
"test:unit": "tape test/**/*.js",
|
|
57
57
|
"test:unit:dev": "npm run test:unit -- --coverage-report=html",
|
|
58
|
-
"test:typescript": "
|
|
58
|
+
"test:typescript": "tstyche"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"ajv": "^8.16.0",
|
|
@@ -63,6 +63,6 @@
|
|
|
63
63
|
"neostandard": "^0.13.0",
|
|
64
64
|
"playwright-test": "^14.1.12",
|
|
65
65
|
"tape": "^5.8.1",
|
|
66
|
-
"
|
|
66
|
+
"tstyche": "^7.0.0"
|
|
67
67
|
}
|
|
68
68
|
}
|
|
@@ -115,3 +115,54 @@ test('URI Equals tolerates malformed fragments', (t) => {
|
|
|
115
115
|
)
|
|
116
116
|
t.end()
|
|
117
117
|
})
|
|
118
|
+
|
|
119
|
+
test('URI Equals — raw UTF-8 char equals its percent-encoded UTF-8 form', (t) => {
|
|
120
|
+
const suite = [
|
|
121
|
+
// BMP characters
|
|
122
|
+
{ pair: ['http://example.com/日本', 'http://example.com/%E6%97%A5%E6%9C%AC'], result: true },
|
|
123
|
+
{ pair: ['http://example.com/café', 'http://example.com/caf%C3%A9'], result: true },
|
|
124
|
+
{ pair: ['http://example.com/€', 'http://example.com/%E2%82%AC'], result: true },
|
|
125
|
+
{ pair: ['http://example.com/中文', 'http://example.com/%E4%B8%AD%E6%96%87'], result: true },
|
|
126
|
+
// supplementary plane (emoji, surrogate pairs)
|
|
127
|
+
{ pair: ['http://example.com/🚀', 'http://example.com/%F0%9F%9A%80'], result: true },
|
|
128
|
+
{ pair: ['http://example.com/😀', 'http://example.com/%F0%9F%98%80'], result: true },
|
|
129
|
+
// mixed encoded + raw in same path
|
|
130
|
+
{ pair: ['http://example.com/users/日本/profile', 'http://example.com/users/%E6%97%A5%E6%9C%AC/profile'], result: true },
|
|
131
|
+
{ pair: ['http://example.com/a/€/b', 'http://example.com/a/%E2%82%AC/b'], result: true }
|
|
132
|
+
]
|
|
133
|
+
runTest(t, suite)
|
|
134
|
+
t.end()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('URI Equals — percent-encoded UTF-8 is case-insensitive in hex digits', (t) => {
|
|
138
|
+
const suite = [
|
|
139
|
+
{ pair: ['http://example.com/caf%C3%A9', 'http://example.com/caf%c3%a9'], result: true },
|
|
140
|
+
{ pair: ['http://example.com/%E6%97%A5', 'http://example.com/%e6%97%a5'], result: true },
|
|
141
|
+
{ pair: ['http://example.com/%F0%9F%9A%80', 'http://example.com/%f0%9f%9a%80'], result: true }
|
|
142
|
+
]
|
|
143
|
+
runTest(t, suite)
|
|
144
|
+
t.end()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('URI Equals — different Unicode code points are not equal', (t) => {
|
|
148
|
+
const suite = [
|
|
149
|
+
{ pair: ['http://example.com/日本', 'http://example.com/中文'], result: false },
|
|
150
|
+
{ pair: ['http://example.com/café', 'http://example.com/cafe'], result: false },
|
|
151
|
+
{ pair: ['http://example.com/🚀', 'http://example.com/🎉'], result: false }
|
|
152
|
+
]
|
|
153
|
+
runTest(t, suite)
|
|
154
|
+
t.end()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('URI Equals — Latin-1 byte is not equal to UTF-8 multi-byte encoding', (t) => {
|
|
158
|
+
// Parser-differential guard: %C3%A9 (UTF-8 for "é") must NOT equal %E9
|
|
159
|
+
// (raw Latin-1 byte). The old escape() emitted single Latin-1 bytes for
|
|
160
|
+
// non-ASCII, which would have made these incorrectly compare equal in a
|
|
161
|
+
// round-tripped URI.
|
|
162
|
+
const suite = [
|
|
163
|
+
{ pair: ['http://example.com/caf%C3%A9', 'http://example.com/caf%E9'], result: false },
|
|
164
|
+
{ pair: ['http://example.com/%E6%97%A5', 'http://example.com/%E6'], result: false }
|
|
165
|
+
]
|
|
166
|
+
runTest(t, suite)
|
|
167
|
+
t.end()
|
|
168
|
+
})
|
|
@@ -150,10 +150,12 @@ test('URI parse', (t) => {
|
|
|
150
150
|
t.equal(components.query, undefined, 'query')
|
|
151
151
|
t.equal(components.fragment, '%0D', 'fragment')
|
|
152
152
|
|
|
153
|
-
//
|
|
153
|
+
// a fragment whose decoded bytes are not valid UTF-8 is still valid
|
|
154
|
+
// percent-encoding (RFC 3986 §2.1 is byte-level), so it is preserved as-is
|
|
155
|
+
// rather than being flagged as malformed
|
|
154
156
|
components = fastURI.parse('http://example.com/#%E0%A4A')
|
|
155
|
-
t.equal(components.error,
|
|
156
|
-
t.equal(components.fragment, '%E0%A4A', '
|
|
157
|
+
t.equal(components.error, undefined, 'valid percent-encoding is not flagged as malformed')
|
|
158
|
+
t.equal(components.fragment, '%E0%A4A', 'fragment is preserved')
|
|
157
159
|
|
|
158
160
|
// all
|
|
159
161
|
components = fastURI.parse('uri://user:pass@example.com:123/one/two.three?q1=a1&q2=a2#body')
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
test('query percent-encoding is normalized (RFC 3986 §6.2.2)', (t) => {
|
|
7
|
+
// hex digits are uppercased, like the path component already does
|
|
8
|
+
t.equal(fastURI.normalize('x://h/p?a=%2a'), 'x://h/p?a=%2A', 'uppercases hex in query')
|
|
9
|
+
// unreserved characters are decoded
|
|
10
|
+
t.equal(fastURI.normalize('x://h/p?a=%7e%2e'), 'x://h/p?a=~.', 'decodes unreserved in query')
|
|
11
|
+
// reserved characters stay percent-encoded
|
|
12
|
+
t.equal(fastURI.normalize('x://h/p?a=%2F'), 'x://h/p?a=%2F', 'keeps reserved encoded in query')
|
|
13
|
+
// raw characters that are not allowed are percent-encoded
|
|
14
|
+
t.equal(fastURI.normalize('x://h/p?a b'), 'x://h/p?a%20b', 'encodes a raw space in query')
|
|
15
|
+
t.equal(fastURI.normalize('x://h/p?café'), 'x://h/p?caf%C3%A9', 'encodes raw non-ASCII in query')
|
|
16
|
+
// `?` is allowed unencoded in a query
|
|
17
|
+
t.equal(fastURI.normalize('x://h/p?a?b'), 'x://h/p?a?b', 'keeps `?` raw in query')
|
|
18
|
+
|
|
19
|
+
t.end()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('fragment percent-encoding is normalized without decoding reserved characters', (t) => {
|
|
23
|
+
// reserved characters must NOT be decoded — `%2F` is not `/`
|
|
24
|
+
t.equal(fastURI.normalize('x://h/p#a%2Fb%2A'), 'x://h/p#a%2Fb%2A', 'keeps reserved encoded in fragment')
|
|
25
|
+
// hex is uppercased and unreserved is decoded
|
|
26
|
+
t.equal(fastURI.normalize('x://h/p#a%2a%7e'), 'x://h/p#a%2A~', 'uppercases hex and decodes unreserved in fragment')
|
|
27
|
+
// raw characters are still encoded
|
|
28
|
+
t.equal(fastURI.normalize('x://h/p#a b'), 'x://h/p#a%20b', 'encodes a raw space in fragment')
|
|
29
|
+
// `?` and `/` are allowed unencoded in a fragment
|
|
30
|
+
t.equal(fastURI.normalize('x://h/p#f?x/y'), 'x://h/p#f?x/y', 'keeps `?` and `/` raw in fragment')
|
|
31
|
+
|
|
32
|
+
t.end()
|
|
33
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
test('scheme is normalized to lowercase (RFC 3986 §6.2.2.1)', (t) => {
|
|
7
|
+
// parse lowercases the scheme, consistent with how it already lowercases the host
|
|
8
|
+
t.equal(fastURI.parse('HTTP://EXAMPLE.com/Path').scheme, 'http', 'parse lowercases an uppercase scheme')
|
|
9
|
+
t.equal(fastURI.parse('HtTpS://example.com').scheme, 'https', 'parse lowercases a mixed-case scheme')
|
|
10
|
+
|
|
11
|
+
// normalize emits a lowercase scheme (matches the already-lowercased host)
|
|
12
|
+
t.equal(fastURI.normalize('HTTP://EXAMPLE.com/p'), 'http://example.com/p', 'normalize lowercases the scheme')
|
|
13
|
+
t.equal(fastURI.normalize('HtTp://Example.COM/'), 'http://example.com/', 'normalize lowercases a mixed-case scheme')
|
|
14
|
+
|
|
15
|
+
t.end()
|
|
16
|
+
})
|
|
Binary file
|
|
@@ -131,3 +131,229 @@ test('normalize does not double-decode %2540 into a live @', (t) => {
|
|
|
131
131
|
t.plan(1)
|
|
132
132
|
t.notEqual(parsed.host, 'trusted.com@evil.com', 'http://trusted.com%2540evil.com/')
|
|
133
133
|
})
|
|
134
|
+
|
|
135
|
+
test('parse canonicalises IDN / Unicode hosts to their ASCII form', (t) => {
|
|
136
|
+
const cases = [
|
|
137
|
+
{
|
|
138
|
+
input: 'http://127。0。0。1/',
|
|
139
|
+
expectedHost: '127.0.0.1',
|
|
140
|
+
description: 'full-width ideographic stops as octet separators'
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
input: 'http://example.com/',
|
|
144
|
+
expectedHost: 'example.com',
|
|
145
|
+
description: 'fullwidth e as first letter'
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
input: 'http://納豆.example.org/',
|
|
149
|
+
expectedHost: 'xn--99zt52a.example.org',
|
|
150
|
+
description: 'CJK label requiring punycode'
|
|
151
|
+
}
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
t.plan(cases.length * 2)
|
|
155
|
+
|
|
156
|
+
cases.forEach(({ input, expectedHost, description }) => {
|
|
157
|
+
const parsed = fastURI.parse(input)
|
|
158
|
+
t.notOk(parsed.error, `parse should not set error: ${description}`)
|
|
159
|
+
t.equal(parsed.host, expectedHost, `host canonicalised to ASCII: ${description}`)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('parse rejects a literal backslash in the authority as malformed (RFC 3986)', (t) => {
|
|
164
|
+
// Regression for the host-confusion bypass: a literal "\" is invalid RFC 3986
|
|
165
|
+
// syntax and must be flagged malformed, not silently rewritten. Otherwise "\"
|
|
166
|
+
// acts as a host delimiter here while Node's native URL parses a different
|
|
167
|
+
// host, defeating a host-based SSRF/redirect/origin allowlist.
|
|
168
|
+
const cases = [
|
|
169
|
+
'http://evil.com\\@allowed.com',
|
|
170
|
+
'https://169.254.169.254\\@trusted.example.com',
|
|
171
|
+
'http://127.0.0.1\\@public.example.com',
|
|
172
|
+
'https://attacker.com\\@api.internal',
|
|
173
|
+
'http://a\\@b',
|
|
174
|
+
'ws://evil.com\\@allowed.com/chat',
|
|
175
|
+
'wss://evil.com\\@allowed.com/chat',
|
|
176
|
+
'http://evil.com\\%40allowed.com',
|
|
177
|
+
'//evil.com\\@allowed.com'
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
t.plan(cases.length)
|
|
181
|
+
|
|
182
|
+
cases.forEach((input) => {
|
|
183
|
+
t.equal(
|
|
184
|
+
fastURI.parse(input).error,
|
|
185
|
+
'URI authority must not contain a literal backslash.',
|
|
186
|
+
input
|
|
187
|
+
)
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('normalize does not canonicalize a literal-backslash URI into a different valid URL', (t) => {
|
|
192
|
+
const cases = [
|
|
193
|
+
'http://evil.com\\@allowed.com',
|
|
194
|
+
'https://attacker.com\\@api.internal'
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
t.plan(cases.length)
|
|
198
|
+
|
|
199
|
+
cases.forEach((input) => {
|
|
200
|
+
t.equal(fastURI.normalize(input), input, input)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('parse leaves percent-encoded %5C untouched as encoded data (not rejected)', (t) => {
|
|
205
|
+
// Only the literal "\" byte is rejected; %5C stays valid encoded data and
|
|
206
|
+
// does not diverge from the native URL parser, so it must not be flagged.
|
|
207
|
+
const input = 'http://evil.com%5C@allowed.com'
|
|
208
|
+
const parsed = fastURI.parse(input)
|
|
209
|
+
|
|
210
|
+
t.plan(2)
|
|
211
|
+
t.notOk(parsed.error, '%5C is valid encoded data, not malformed')
|
|
212
|
+
t.equal(parsed.host, new URL(input).hostname, '%5C host matches native URL (no divergence)')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('parse does not reject a literal backslash in the query or fragment', (t) => {
|
|
216
|
+
// The rejection is scoped to the authority/path (the host-confusion surface);
|
|
217
|
+
// a backslash after "?"/"#" is normalized as encoded data as before.
|
|
218
|
+
const parsed = fastURI.parse('http://host.example.com/?x=\\y#z\\w')
|
|
219
|
+
|
|
220
|
+
t.plan(2)
|
|
221
|
+
t.notOk(parsed.error, 'backslash in query/fragment does not mark the URI malformed')
|
|
222
|
+
t.equal(parsed.host, 'host.example.com', 'host parsed normally')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('parse rejects a malformed authority introducer (\\\\, /\\, \\/) in place of //', (t) => {
|
|
226
|
+
// Regression: "\\", "/\\", "\\/" after the scheme colon are not valid authority
|
|
227
|
+
// introducers. Node's URL treats "\\" as interchangeable with "/" on special
|
|
228
|
+
// schemes, so "http:\\\\evil.com/path" would be parsed as host "evil.com" by
|
|
229
|
+
// Node, but fast-uri must reject it as malformed to prevent SSRF/redirect bypass.
|
|
230
|
+
const cases = [
|
|
231
|
+
'http:\\\\evil.com/path',
|
|
232
|
+
'http:/\\evil.com/path',
|
|
233
|
+
'http:\\/evil.com/path',
|
|
234
|
+
'ws:\\\\evil.com/chat',
|
|
235
|
+
'wss:\\\\evil.com/chat',
|
|
236
|
+
'ftp:\\\\evil.com/',
|
|
237
|
+
'\\\\evil.com/path'
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
t.plan(cases.length)
|
|
241
|
+
|
|
242
|
+
cases.forEach((input) => {
|
|
243
|
+
t.equal(
|
|
244
|
+
fastURI.parse(input).error,
|
|
245
|
+
'URI authority must not contain a literal backslash.',
|
|
246
|
+
input
|
|
247
|
+
)
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('normalize does not canonicalize a malformed-authority-introducer URI', (t) => {
|
|
252
|
+
const cases = [
|
|
253
|
+
'http:\\\\evil.com/path',
|
|
254
|
+
'http:/\\evil.com/path'
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
t.plan(cases.length)
|
|
258
|
+
|
|
259
|
+
cases.forEach((input) => {
|
|
260
|
+
t.equal(fastURI.normalize(input), input, input)
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('equal returns false for malformed-authority-introducer URIs', (t) => {
|
|
265
|
+
const pairs = [
|
|
266
|
+
['http:\\\\evil.com/path', 'http://evil.com/path'],
|
|
267
|
+
['http:/\\evil.com/path', 'http://evil.com/path']
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
t.plan(pairs.length)
|
|
271
|
+
|
|
272
|
+
pairs.forEach(([left, right]) => {
|
|
273
|
+
t.equal(fastURI.equal(left, right), false, `${left} != ${right}`)
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('resolve throws on malformed authority introducer', (t) => {
|
|
278
|
+
// resolve() returns a plain string with no error field, so the only safe
|
|
279
|
+
// behavior is to throw when either component has a malformed authority.
|
|
280
|
+
const pairs = [
|
|
281
|
+
['https://allowed.com/', '\\\\evil.com/path'],
|
|
282
|
+
['\\\\evil.com/path', 'https://allowed.com/'],
|
|
283
|
+
['https://allowed.com/', 'http:/\\evil.com/path'],
|
|
284
|
+
['https://allowed.com/', 'http:\\/evil.com/path']
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
t.plan(pairs.length)
|
|
288
|
+
|
|
289
|
+
pairs.forEach(([base, rel]) => {
|
|
290
|
+
t.throws(
|
|
291
|
+
() => fastURI.resolve(base, rel),
|
|
292
|
+
/URI authority must not contain a literal backslash/,
|
|
293
|
+
`${base} + ${rel}`
|
|
294
|
+
)
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test('parse rejects a whitespace-split authority introducer (TAB, LF, CR)', (t) => {
|
|
299
|
+
// The WHATWG URL parser removes TAB (U+0009), LF (U+000A) and CR (U+000D) from
|
|
300
|
+
// the input before parsing, so a stripped character wedged into the introducer
|
|
301
|
+
// ("/<TAB>\\", "/<TAB>/", or a leading "<TAB>//") reaches an authority in Node
|
|
302
|
+
// while fast-uri would otherwise fold it into the path. These must be rejected
|
|
303
|
+
// like the adjacent "\\", "/\\", "\\/" forms.
|
|
304
|
+
const cases = [
|
|
305
|
+
{ input: '/\t\\evil.com/path', expectedError: 'URI authority must not contain a literal backslash.' },
|
|
306
|
+
{ input: '/\t/evil.com/path', expectedError: 'URI authority introducer must not contain whitespace.' },
|
|
307
|
+
{ input: '/\n\\evil.com/path', expectedError: 'URI authority must not contain a literal backslash.' },
|
|
308
|
+
{ input: '/\r\\evil.com/path', expectedError: 'URI authority must not contain a literal backslash.' },
|
|
309
|
+
{ input: '\t//evil.com/path', expectedError: 'URI authority introducer must not contain whitespace.' },
|
|
310
|
+
{ input: '\t/\\evil.com/path', expectedError: 'URI authority must not contain a literal backslash.' },
|
|
311
|
+
{ input: 'https:/\t/evil.com/path', expectedError: 'URI authority introducer must not contain whitespace.' }
|
|
312
|
+
]
|
|
313
|
+
|
|
314
|
+
t.plan(cases.length)
|
|
315
|
+
|
|
316
|
+
cases.forEach(({ input, expectedError }) => {
|
|
317
|
+
t.equal(fastURI.parse(input).error, expectedError, JSON.stringify(input))
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
test('resolve throws on a whitespace-split authority introducer', (t) => {
|
|
322
|
+
const pairs = [
|
|
323
|
+
['https://allowed.com/', '/\t\\evil.com/path'],
|
|
324
|
+
['https://allowed.com/', '/\t/evil.com/path'],
|
|
325
|
+
['https://allowed.com/', '/\n\\evil.com/path'],
|
|
326
|
+
['/\t/evil.com/path', 'https://allowed.com/']
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
t.plan(pairs.length)
|
|
330
|
+
|
|
331
|
+
pairs.forEach(([base, rel]) => {
|
|
332
|
+
t.throws(
|
|
333
|
+
() => fastURI.resolve(base, rel),
|
|
334
|
+
/URI authority (must not contain a literal backslash|introducer must not contain whitespace)/,
|
|
335
|
+
`${JSON.stringify(base)} + ${JSON.stringify(rel)}`
|
|
336
|
+
)
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test('parse does not reject valid authority introducer patterns', (t) => {
|
|
341
|
+
// No false positives: "//" introducer and scheme-less "//" must be valid.
|
|
342
|
+
const cases = [
|
|
343
|
+
'http://good.com/',
|
|
344
|
+
'https://good.com/',
|
|
345
|
+
'ws://good.com/chat',
|
|
346
|
+
'wss://good.com/chat',
|
|
347
|
+
'ftp://good.com/',
|
|
348
|
+
'//good.com/path',
|
|
349
|
+
'/absolute/path',
|
|
350
|
+
'relative/path'
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
t.plan(cases.length)
|
|
354
|
+
|
|
355
|
+
cases.forEach((input) => {
|
|
356
|
+
const parsed = fastURI.parse(input)
|
|
357
|
+
t.notOk(parsed.error, input)
|
|
358
|
+
})
|
|
359
|
+
})
|
|
@@ -8,7 +8,7 @@ const fastURI = require('..')
|
|
|
8
8
|
*
|
|
9
9
|
* @fileoverview An RFC 3986 compliant, scheme extendable URI parsing/normalizing/resolving/serializing library for JavaScript.
|
|
10
10
|
* @author <a href="mailto:gary.court@gmail.com">Gary Court</a>
|
|
11
|
-
* @see
|
|
11
|
+
* @see https://github.com/garycourt/uri-js
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -29,6 +29,15 @@ test('removeDotSegments', (t) => {
|
|
|
29
29
|
// https://github.com/fastify/fast-uri/issues/139
|
|
30
30
|
testCases.push(['WS:/WS://1305G130505:1&%0D:1&C(XXXXX*)))))))XXX130505:UUVUaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa$aaaaaaaaaaaa13a',
|
|
31
31
|
'WS:/WS://1305G130505:1&%0D:1&C(XXXXX*)))))))XXX130505:UUVUaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa$aaaaaaaaaaaa13a'])
|
|
32
|
+
// trailing empty segments must be preserved: RFC 3986 5.2.4 only removes
|
|
33
|
+
// complete "." / ".." segments, never an empty segment
|
|
34
|
+
testCases.push(['//', '//'])
|
|
35
|
+
testCases.push(['/foo//', '/foo//'])
|
|
36
|
+
testCases.push(['/a/b//', '/a/b//'])
|
|
37
|
+
testCases.push(['/x//y//z', '/x//y//z'])
|
|
38
|
+
// "." / ".." segments still collapse
|
|
39
|
+
testCases.push(['/a/b/c/./../../g', '/a/g'])
|
|
40
|
+
testCases.push(['/.', '/'])
|
|
32
41
|
|
|
33
42
|
t.plan(testCases.length)
|
|
34
43
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"allowJs": true,
|
|
4
|
+
"checkJs": true,
|
|
5
|
+
"strict": true,
|
|
6
|
+
"noImplicitAny": true,
|
|
7
|
+
"target": "es2022",
|
|
8
|
+
"noEmit": true
|
|
9
|
+
}
|
|
9
10
|
}
|
|
@@ -28,15 +28,6 @@ declare namespace fastUri {
|
|
|
28
28
|
nid?: string;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
* @deprecated Use Options instead
|
|
33
|
-
*/
|
|
34
|
-
export type options = Options
|
|
35
|
-
/**
|
|
36
|
-
* @deprecated Use URIComponent instead
|
|
37
|
-
*/
|
|
38
|
-
export type URIComponents = URIComponent
|
|
39
|
-
|
|
40
31
|
export function normalize (uri: string, opts?: Options): string
|
|
41
32
|
export function normalize (uri: URIComponent, opts?: Options): URIComponent
|
|
42
33
|
export function normalize (uri: any, opts?: Options): any
|