@openclaw/lobster 2026.6.33 → 2026.6.35
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 +173 -0
- package/node_modules/fast-uri/benchmark/benchmark.mjs +71 -0
- package/node_modules/fast-uri/benchmark/encoder.mjs +54 -0
- package/node_modules/fast-uri/index.js +277 -45
- package/node_modules/fast-uri/lib/schemes.js +384 -7
- package/node_modules/fast-uri/lib/utils.js +458 -95
- package/node_modules/fast-uri/package.json +4 -4
- package/node_modules/fast-uri/test/component-safe-serialization.test.js +163 -0
- package/node_modules/fast-uri/test/equal.test.js +94 -3
- package/node_modules/fast-uri/test/fixtures/uri-js-parse.json +24 -1
- package/node_modules/fast-uri/test/fixtures/uri-js-serialize.json +23 -1
- package/node_modules/fast-uri/test/ipv6-canonical.test.js +34 -0
- package/node_modules/fast-uri/test/ipv6-validation.test.js +154 -0
- package/node_modules/fast-uri/test/malformed-percent.test.js +77 -0
- package/node_modules/fast-uri/test/malformed-urn.test.js +61 -0
- package/node_modules/fast-uri/test/parse.test.js +150 -3
- package/node_modules/fast-uri/test/query-fragment-normalization.test.js +33 -0
- package/node_modules/fast-uri/test/reserved-path-normalization.test.js +109 -0
- package/node_modules/fast-uri/test/scheme-normalization.test.js +16 -0
- package/node_modules/fast-uri/test/scheme-validation.test.js +122 -0
- package/node_modules/fast-uri/test/security-normalization.test.js +101 -0
- package/node_modules/fast-uri/test/security-utf8-encoding.test.js +0 -0
- package/node_modules/fast-uri/test/security.test.js +301 -3
- package/node_modules/fast-uri/test/serialize.test.js +144 -0
- package/node_modules/fast-uri/test/uri-js-compatibility.test.js +0 -3
- package/node_modules/fast-uri/test/uri-js.test.js +4 -4
- package/node_modules/fast-uri/test/urn-full-input.test.js +29 -0
- package/node_modules/fast-uri/test/util.test.js +9 -0
- package/node_modules/fast-uri/test/websocket-query-preservation.test.js +24 -0
- package/node_modules/fast-uri/tsconfig.json +8 -7
- package/node_modules/fast-uri/types/index.d.ts +4 -9
- package/node_modules/fast-uri/types/index.tst.ts +31 -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
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
const malformedURNs = [
|
|
7
|
+
'urn:',
|
|
8
|
+
'URN:',
|
|
9
|
+
'urn:foo',
|
|
10
|
+
'urn::foo',
|
|
11
|
+
'urn:foo:',
|
|
12
|
+
'urn:%66oo:bar'
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
test('parse reports malformed ordinary URNs', (t) => {
|
|
16
|
+
for (const uri of malformedURNs) {
|
|
17
|
+
t.match(fastURI.parse(uri).error, /^URN can not be parsed\.?$/, uri)
|
|
18
|
+
}
|
|
19
|
+
t.end()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('normalize preserves malformed ordinary URNs without throwing', (t) => {
|
|
23
|
+
for (const uri of malformedURNs) {
|
|
24
|
+
t.doesNotThrow(() => fastURI.normalize(uri), `${uri} does not throw`)
|
|
25
|
+
t.equal(fastURI.normalize(uri), uri, `${uri} is preserved`)
|
|
26
|
+
}
|
|
27
|
+
t.equal(
|
|
28
|
+
fastURI.normalize('urn:foo', { reference: 'relative' }),
|
|
29
|
+
'urn:foo',
|
|
30
|
+
'an earlier parse error does not hide the missing URN nid'
|
|
31
|
+
)
|
|
32
|
+
t.end()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('equal returns false for malformed ordinary URNs', (t) => {
|
|
36
|
+
for (const uri of malformedURNs) {
|
|
37
|
+
t.equal(fastURI.equal(uri, uri, {}), false, `${uri} is not equal to itself as malformed input`)
|
|
38
|
+
t.equal(fastURI.equal(uri, 'urn:foo:bar', {}), false, `${uri} is not equal to a valid URN`)
|
|
39
|
+
}
|
|
40
|
+
t.end()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('resolve handles malformed ordinary URNs without throwing', (t) => {
|
|
44
|
+
for (const uri of malformedURNs) {
|
|
45
|
+
t.doesNotThrow(() => fastURI.resolve('uri://base/', uri), `${uri} does not throw as a relative reference`)
|
|
46
|
+
t.doesNotThrow(() => fastURI.resolve(uri, ''), `${uri} does not throw as a base URI`)
|
|
47
|
+
}
|
|
48
|
+
// resolve preserves the malformed scheme-specific input rather than surfacing
|
|
49
|
+
// an uncaught 'URN without nid cannot be serialized' error (matches upstream uri-js)
|
|
50
|
+
t.equal(fastURI.resolve('uri://base/', 'urn:'), 'urn:', 'malformed relative URN is preserved')
|
|
51
|
+
t.equal(fastURI.resolve('URN:', ''), 'urn:', 'scheme case is normalized')
|
|
52
|
+
t.equal(fastURI.resolve('uri://base/', 'urn:%66oo:bar'), 'urn:foo:bar', 'percent-encoding is decoded')
|
|
53
|
+
t.end()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('valid URNs retain their existing behavior', (t) => {
|
|
57
|
+
t.equal(fastURI.normalize('URN:FOO:a123,456'), 'urn:foo:a123,456')
|
|
58
|
+
t.equal(fastURI.equal('urn:foo:a123,456', 'URN:FOO:a123,456', {}), true)
|
|
59
|
+
t.equal(fastURI.resolve('uri://base/', 'urn:'), 'urn:', 'default resolve behavior is unchanged')
|
|
60
|
+
t.end()
|
|
61
|
+
})
|
|
@@ -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')
|
|
@@ -212,6 +214,7 @@ test('URI parse', (t) => {
|
|
|
212
214
|
// invalid IPv6
|
|
213
215
|
components = fastURI.parse('//[2001:dbZ::7]')
|
|
214
216
|
t.equal(components.host, '[2001:dbz::7]')
|
|
217
|
+
t.equal(components.error, 'URI host is malformed.')
|
|
215
218
|
|
|
216
219
|
// mixed IPv4address & IPv6address
|
|
217
220
|
components = fastURI.parse('//[::ffff:129.144.52.38]')
|
|
@@ -316,8 +319,152 @@ test('URI parse', (t) => {
|
|
|
316
319
|
|
|
317
320
|
components = fastURI.parse('//[2606:2800:220:1:248:1893:25c8:1946:43209]')
|
|
318
321
|
t.equal(components.host, '[2606:2800:220:1:248:1893:25c8:1946:43209]')
|
|
322
|
+
t.equal(components.error, 'URI host is malformed.')
|
|
319
323
|
|
|
320
324
|
components = fastURI.parse('urn:foo:|\\24fpl')
|
|
321
325
|
t.equal(components.error, 'URN can not be parsed.')
|
|
322
326
|
t.end()
|
|
323
327
|
})
|
|
328
|
+
|
|
329
|
+
test('Mailto parsing normalizes Unicode domains according to options', (t) => {
|
|
330
|
+
t.deepEqual(
|
|
331
|
+
fastURI.parse('mailto:user@納豆.example.org').to,
|
|
332
|
+
['user@xn--99zt52a.example.org'],
|
|
333
|
+
'Unicode domain defaults to ASCII'
|
|
334
|
+
)
|
|
335
|
+
t.deepEqual(
|
|
336
|
+
fastURI.parse('mailto:user@納豆.example.org', { unicodeSupport: true }).to,
|
|
337
|
+
['user@納豆.example.org'],
|
|
338
|
+
'Unicode domain is preserved when requested'
|
|
339
|
+
)
|
|
340
|
+
t.deepEqual(
|
|
341
|
+
fastURI.parse('mailto:user@xn--99zt52a.example.org', { unicodeSupport: true }).to,
|
|
342
|
+
['user@xn--99zt52a.example.org'],
|
|
343
|
+
'existing punycode remains unchanged in Unicode mode'
|
|
344
|
+
)
|
|
345
|
+
t.end()
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
test('Mailto parsing preserves and reports malformed recipient domains', (t) => {
|
|
349
|
+
const cases = [
|
|
350
|
+
['mailto:user@example.org:25', 'user@example.org:25'],
|
|
351
|
+
['mailto:user@example.org/path', 'user@example.org/path'],
|
|
352
|
+
['mailto:user@example.org%40evil.test', 'user@example.org@evil.test'],
|
|
353
|
+
['mailto:user@example.org%3Fquery', 'user@example.org?query'],
|
|
354
|
+
['mailto:user@example.org%23fragment', 'user@example.org#fragment'],
|
|
355
|
+
['mailto:user@[broken', 'user@[broken'],
|
|
356
|
+
['mailto:user', 'user']
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
for (const [uri, recipient] of cases) {
|
|
360
|
+
const parsed = fastURI.parse(uri)
|
|
361
|
+
t.equal(parsed.error, 'URI mailto has an invalid recipient domain.', 'error for ' + uri)
|
|
362
|
+
t.deepEqual(parsed.to, [recipient], 'recipient is preserved for ' + uri)
|
|
363
|
+
}
|
|
364
|
+
t.end()
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('Mailto parsing handles domain literals, empty queries, and malformed headers', (t) => {
|
|
368
|
+
const literal = fastURI.parse('mailto:user@[IPv6:2001:db8::1]')
|
|
369
|
+
t.equal(literal.error, undefined, 'valid domain literal has no error')
|
|
370
|
+
t.deepEqual(literal.to, ['user@[ipv6:2001:db8::1]'], 'domain literal is preserved')
|
|
371
|
+
|
|
372
|
+
const emptyQuery = fastURI.parse('mailto:user@example.org?')
|
|
373
|
+
t.equal(emptyQuery.query, undefined, 'empty query is cleared')
|
|
374
|
+
t.equal(emptyQuery.headers, undefined, 'empty query does not create a header')
|
|
375
|
+
|
|
376
|
+
const malformedHeaders = fastURI.parse('mailto:joe@example.com?cc=bob@example.com?body=hello')
|
|
377
|
+
t.equal(malformedHeaders.error, 'URI mailto has malformed header fields.', 'malformed fields set an error')
|
|
378
|
+
t.deepEqual(malformedHeaders.to, ['joe@example.com'], 'path recipient is retained')
|
|
379
|
+
t.end()
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
test('Mailto parsing folds lone surrogates in raw input', (t) => {
|
|
383
|
+
// The handler sets `skipNormalize`, so it must fold lone surrogates itself --
|
|
384
|
+
// the generic query/path normalizers no longer run to do it.
|
|
385
|
+
const lone = '\uD800'
|
|
386
|
+
const trailing = '\uDC00'
|
|
387
|
+
|
|
388
|
+
t.deepEqual(
|
|
389
|
+
// spread: `headers` has a null prototype, which t.deepEqual compares strictly
|
|
390
|
+
{ ...fastURI.parse('mailto:a@b.test?x=' + lone).headers },
|
|
391
|
+
{ x: '�' },
|
|
392
|
+
'lone high surrogate in a header value becomes U+FFFD'
|
|
393
|
+
)
|
|
394
|
+
t.equal(
|
|
395
|
+
fastURI.parse('mailto:a@b.test?subject=' + trailing).subject,
|
|
396
|
+
'�',
|
|
397
|
+
'lone low surrogate in a subject becomes U+FFFD'
|
|
398
|
+
)
|
|
399
|
+
t.deepEqual(
|
|
400
|
+
fastURI.parse('mailto:' + lone + '@b.test').to,
|
|
401
|
+
['�@b.test'],
|
|
402
|
+
'lone surrogate in a local part becomes U+FFFD'
|
|
403
|
+
)
|
|
404
|
+
t.deepEqual(
|
|
405
|
+
fastURI.parse('mailto:a@b.test?subject=😀').subject,
|
|
406
|
+
'😀',
|
|
407
|
+
'a valid surrogate pair is left intact'
|
|
408
|
+
)
|
|
409
|
+
t.end()
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
test('Mailto domain fast path agrees with the WHATWG parser', (t) => {
|
|
413
|
+
// `mailtoNormalizeDomain` skips `new URL` when `nonSimpleMailtoDomain` is
|
|
414
|
+
// false. Every domain here must come out the same either way -- especially the
|
|
415
|
+
// all-numeric last labels, which `new URL` reads as IPv4 shorthand.
|
|
416
|
+
const domains = [
|
|
417
|
+
'example.com',
|
|
418
|
+
'example.org',
|
|
419
|
+
'mail2.example.org',
|
|
420
|
+
's3.amazonaws.com',
|
|
421
|
+
'ex4mple.com',
|
|
422
|
+
'_dmarc.example.com',
|
|
423
|
+
'-a.com',
|
|
424
|
+
'a..b',
|
|
425
|
+
'a.com.',
|
|
426
|
+
'1.2.3',
|
|
427
|
+
'127.1',
|
|
428
|
+
'12',
|
|
429
|
+
'0x7f.1',
|
|
430
|
+
'9.9.9.9',
|
|
431
|
+
'1.2.3.4'
|
|
432
|
+
]
|
|
433
|
+
|
|
434
|
+
for (const domain of domains) {
|
|
435
|
+
const parsed = fastURI.parse('mailto:user@' + domain)
|
|
436
|
+
let expected
|
|
437
|
+
try {
|
|
438
|
+
const url = new URL('http://' + domain)
|
|
439
|
+
const invalid = url.username || url.password || url.port ||
|
|
440
|
+
url.pathname !== '/' || url.search || url.hash || !url.hostname
|
|
441
|
+
expected = invalid ? domain : url.hostname
|
|
442
|
+
} catch {
|
|
443
|
+
expected = domain
|
|
444
|
+
}
|
|
445
|
+
t.deepEqual(parsed.to, ['user@' + expected], 'matches new URL for ' + domain)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// The fast path must not be taken for a domain whose last label is numeric.
|
|
449
|
+
t.deepEqual(fastURI.parse('mailto:user@1.2.3').to, ['user@1.2.0.3'], 'IPv4 shorthand is still applied')
|
|
450
|
+
t.deepEqual(fastURI.parse('mailto:user@mail2.example.org').to, ['user@mail2.example.org'], 'digits stay on the fast path')
|
|
451
|
+
t.end()
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
test('Mailto headers use a null prototype', (t) => {
|
|
455
|
+
// Header names come from untrusted input, so a lookup must not resolve to an
|
|
456
|
+
// inherited member of Object.prototype.
|
|
457
|
+
const parsed = fastURI.parse('mailto:a@b.test?blat=foop')
|
|
458
|
+
t.equal(Object.getPrototypeOf(parsed.headers), null, 'headers has no prototype')
|
|
459
|
+
t.equal(parsed.headers.toString, undefined, 'inherited members do not leak')
|
|
460
|
+
t.equal(parsed.headers.constructor, undefined, 'constructor does not leak')
|
|
461
|
+
|
|
462
|
+
// With a null prototype "__proto__" is an ordinary key rather than a setter,
|
|
463
|
+
// so it round-trips instead of being silently discarded.
|
|
464
|
+
const polluted = fastURI.parse('mailto:a@b.test?__proto__=x')
|
|
465
|
+
t.deepEqual(Object.keys(polluted.headers), ['__proto__'], '__proto__ is kept as an own key')
|
|
466
|
+
t.equal(fastURI.serialize(polluted), 'mailto:a@b.test?__proto__=x', '__proto__ round-trips')
|
|
467
|
+
t.equal({}.x, undefined, 'Object.prototype is not polluted')
|
|
468
|
+
t.equal(Object.getPrototypeOf({}), Object.prototype, 'Object.prototype is intact')
|
|
469
|
+
t.end()
|
|
470
|
+
})
|
|
@@ -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,109 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
const PATH_RESERVED = "!$&'()*+,;=:@/"
|
|
7
|
+
|
|
8
|
+
function percentEncode (character, lowerCase) {
|
|
9
|
+
const hex = character.charCodeAt(0).toString(16).padStart(2, '0')
|
|
10
|
+
return '%' + (lowerCase ? hex : hex.toUpperCase())
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test('normalize preserves literal and escaped reserved path characters', (t) => {
|
|
14
|
+
t.equal(
|
|
15
|
+
fastURI.normalize('http://example.com/a;b'),
|
|
16
|
+
'http://example.com/a;b',
|
|
17
|
+
'literal semicolon remains literal'
|
|
18
|
+
)
|
|
19
|
+
t.equal(
|
|
20
|
+
fastURI.normalize('http://example.com/a%3ab'),
|
|
21
|
+
'http://example.com/a%3Ab',
|
|
22
|
+
'escaped colon remains escaped and its hex is uppercased'
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
for (const character of PATH_RESERVED) {
|
|
26
|
+
const literal = `http://example.com/a${character}b`
|
|
27
|
+
const escaped = `http://example.com/a${percentEncode(character, true)}b`
|
|
28
|
+
const normalizedEscape = `http://example.com/a${percentEncode(character, false)}b`
|
|
29
|
+
|
|
30
|
+
t.equal(fastURI.normalize(literal), literal, `preserves literal ${character}`)
|
|
31
|
+
t.equal(fastURI.normalize(escaped), normalizedEscape, `preserves escaped ${character}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
t.equal(
|
|
35
|
+
fastURI.normalize('http://example.com/a/./café'),
|
|
36
|
+
'http://example.com/a/caf%C3%A9',
|
|
37
|
+
'removes real dot segments and UTF-8 encodes raw non-ASCII'
|
|
38
|
+
)
|
|
39
|
+
t.equal(
|
|
40
|
+
fastURI.normalize('http://example.com/a/%2e%2e/b'),
|
|
41
|
+
'http://example.com/a/%2E%2E/b',
|
|
42
|
+
'preserves escaped dots as path data'
|
|
43
|
+
)
|
|
44
|
+
t.equal(
|
|
45
|
+
fastURI.normalize('http://example.com/Kſ'),
|
|
46
|
+
'http://example.com/%E2%84%AA%C5%BF',
|
|
47
|
+
'UTF-8 encodes Unicode characters that case-fold to ASCII'
|
|
48
|
+
)
|
|
49
|
+
t.end()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('serialize uses the RFC 3986 path character set without opening escapes', (t) => {
|
|
53
|
+
const rawPath = `/a${PATH_RESERVED}b`
|
|
54
|
+
t.equal(
|
|
55
|
+
fastURI.serialize({ scheme: 'http', host: 'example.com', path: rawPath }),
|
|
56
|
+
`http://example.com${rawPath}`,
|
|
57
|
+
'keeps all legal raw path characters'
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const escapedPath = Array.from(PATH_RESERVED, (character) => percentEncode(character, true)).join('')
|
|
61
|
+
const normalizedEscapedPath = Array.from(PATH_RESERVED, (character) => percentEncode(character, false)).join('')
|
|
62
|
+
t.equal(
|
|
63
|
+
fastURI.serialize({ scheme: 'http', host: 'example.com', path: '/' + escapedPath }),
|
|
64
|
+
'http://example.com/' + normalizedEscapedPath,
|
|
65
|
+
'uppercases valid escapes without decoding reserved characters'
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
t.equal(
|
|
69
|
+
fastURI.serialize({ scheme: 'http', host: 'example.com', path: '/a?b#c[d]' }),
|
|
70
|
+
'http://example.com/a%3Fb%23c%5Bd%5D',
|
|
71
|
+
'encodes characters that would leave the path component'
|
|
72
|
+
)
|
|
73
|
+
t.equal(
|
|
74
|
+
fastURI.serialize({ path: 'a:b/c:d' }),
|
|
75
|
+
'a%3Ab/c:d',
|
|
76
|
+
'only escapes a colon where path-noscheme requires it'
|
|
77
|
+
)
|
|
78
|
+
t.equal(
|
|
79
|
+
fastURI.serialize({ path: './a:b/c:d' }),
|
|
80
|
+
'a%3Ab/c:d',
|
|
81
|
+
'escapes a first-segment colon exposed by dot-segment removal'
|
|
82
|
+
)
|
|
83
|
+
t.equal(
|
|
84
|
+
fastURI.normalize('./a:b'),
|
|
85
|
+
'a%3Ab',
|
|
86
|
+
'normalization keeps a relative path from becoming a scheme'
|
|
87
|
+
)
|
|
88
|
+
t.equal(
|
|
89
|
+
fastURI.serialize({ path: '/Kſ' }),
|
|
90
|
+
'/%E2%84%AA%C5%BF',
|
|
91
|
+
'UTF-8 encodes Unicode characters that case-fold to ASCII'
|
|
92
|
+
)
|
|
93
|
+
t.end()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('equal distinguishes escaped reserved path data from literal syntax', (t) => {
|
|
97
|
+
for (const character of PATH_RESERVED) {
|
|
98
|
+
const literal = `http://example.com/a${character}b`
|
|
99
|
+
const escaped = `http://example.com/a${percentEncode(character, false)}b`
|
|
100
|
+
t.equal(fastURI.equal(literal, escaped, {}), false, `distinguishes literal and escaped ${character}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
t.equal(
|
|
104
|
+
fastURI.equal('./a:b', 'a:b', {}),
|
|
105
|
+
false,
|
|
106
|
+
'does not equate a relative path with an absolute URI after removing dot segments'
|
|
107
|
+
)
|
|
108
|
+
t.end()
|
|
109
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
const MALFORMED_SCHEME_ERROR = 'URI scheme is malformed.'
|
|
7
|
+
|
|
8
|
+
const malformedSchemes = [
|
|
9
|
+
'%2f%2fevil.example:/pwn',
|
|
10
|
+
'%u002f%u002fevil.example:/pwn',
|
|
11
|
+
'%0d%0aSet-Cookie:%20sid=attacker:/p',
|
|
12
|
+
'foo%3Abar:value',
|
|
13
|
+
'foo%2Fbar:value',
|
|
14
|
+
'1http://example.com/',
|
|
15
|
+
'foo_bar:value',
|
|
16
|
+
'éxample:value',
|
|
17
|
+
'Kttp://example.com/',
|
|
18
|
+
'ſcheme:value'
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
test('parse validates the decoded scheme against RFC 3986', (t) => {
|
|
22
|
+
const validSchemes = [
|
|
23
|
+
['a:value', 'a'],
|
|
24
|
+
['HTTP://example.com/', 'http'],
|
|
25
|
+
['a1+.-:value', 'a1+.-'],
|
|
26
|
+
['%4Aavascript:alert(1)', 'javascript'],
|
|
27
|
+
['foo%2Bbar:value', 'foo+bar'],
|
|
28
|
+
['%u006Aavascript:1', 'javascript'],
|
|
29
|
+
['ht%74ps://example.com/', 'https']
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
for (const [uri, scheme] of validSchemes) {
|
|
33
|
+
const parsed = fastURI.parse(uri)
|
|
34
|
+
t.equal(parsed.error, undefined, uri)
|
|
35
|
+
t.equal(parsed.scheme, scheme, uri + ' scheme')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const uri of malformedSchemes) {
|
|
39
|
+
const parsed = fastURI.parse(uri)
|
|
40
|
+
t.equal(parsed.error, MALFORMED_SCHEME_ERROR, uri)
|
|
41
|
+
}
|
|
42
|
+
t.end()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('decoded schemes select their scheme handlers', (t) => {
|
|
46
|
+
t.equal(
|
|
47
|
+
fastURI.normalize('ht%74ps://example.com:443'),
|
|
48
|
+
'https://example.com/',
|
|
49
|
+
'HTTP normalization runs after decoding the scheme'
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const mailto = fastURI.parse('ma%69lto:user@example.org')
|
|
53
|
+
t.deepEqual(mailto.to, ['user@example.org'], 'mailto parsing runs after decoding the scheme')
|
|
54
|
+
t.end()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('normalize preserves schemes that decode to invalid identifiers', (t) => {
|
|
58
|
+
for (const uri of malformedSchemes) {
|
|
59
|
+
t.equal(fastURI.normalize(uri), uri, uri)
|
|
60
|
+
}
|
|
61
|
+
t.end()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('scheme normalization cannot introduce authority or control delimiters', (t) => {
|
|
65
|
+
const authority = '%2f%2fevil.example:/pwn'
|
|
66
|
+
const crlf = '%0d%0aSet-Cookie:%20sid=attacker:/p'
|
|
67
|
+
|
|
68
|
+
t.equal(fastURI.parse(authority).host, undefined, 'original input has no authority')
|
|
69
|
+
t.equal(fastURI.normalize(authority), authority, 'normalization does not create an authority')
|
|
70
|
+
t.equal(fastURI.normalize(crlf), crlf, 'normalization does not emit raw CRLF')
|
|
71
|
+
t.equal(fastURI.normalize(crlf).includes('\r\n'), false, 'normalized output contains no raw CRLF')
|
|
72
|
+
t.end()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('equal returns false for malformed decoded schemes', (t) => {
|
|
76
|
+
for (const uri of malformedSchemes) {
|
|
77
|
+
t.equal(fastURI.equal(uri, uri, {}), false, uri)
|
|
78
|
+
}
|
|
79
|
+
t.end()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('resolve rejects malformed decoded schemes in either input', (t) => {
|
|
83
|
+
t.throws(
|
|
84
|
+
() => fastURI.resolve('%2f%2fevil.example:/base', 'child'),
|
|
85
|
+
/URI scheme is malformed\./,
|
|
86
|
+
'malformed base'
|
|
87
|
+
)
|
|
88
|
+
t.throws(
|
|
89
|
+
() => fastURI.resolve('https://allowed.example/app/', '%2f%2fevil.example:/pwn'),
|
|
90
|
+
/URI scheme is malformed\./,
|
|
91
|
+
'malformed relative reference'
|
|
92
|
+
)
|
|
93
|
+
t.end()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('serialize validates decoded component schemes', (t) => {
|
|
97
|
+
t.equal(
|
|
98
|
+
fastURI.serialize({ scheme: 'foo%2Bbar', path: 'value' }),
|
|
99
|
+
'foo+bar:value',
|
|
100
|
+
'valid decoded scheme is serialized'
|
|
101
|
+
)
|
|
102
|
+
t.throws(
|
|
103
|
+
() => fastURI.serialize({ scheme: '//evil.example', path: '/pwn' }),
|
|
104
|
+
/URI scheme is malformed\./,
|
|
105
|
+
'raw invalid scheme'
|
|
106
|
+
)
|
|
107
|
+
t.throws(
|
|
108
|
+
() => fastURI.serialize({ scheme: '%2f%2fevil.example', path: '/pwn' }),
|
|
109
|
+
/URI scheme is malformed\./,
|
|
110
|
+
'encoded invalid scheme'
|
|
111
|
+
)
|
|
112
|
+
t.equal(
|
|
113
|
+
fastURI.equal(
|
|
114
|
+
{ scheme: '%2f%2fevil.example', path: '/pwn' },
|
|
115
|
+
{ scheme: '%2f%2fevil.example', path: '/pwn' },
|
|
116
|
+
{}
|
|
117
|
+
),
|
|
118
|
+
false,
|
|
119
|
+
'equality fails closed for malformed component objects'
|
|
120
|
+
)
|
|
121
|
+
t.end()
|
|
122
|
+
})
|
|
@@ -37,3 +37,104 @@ test('equal does not treat reserved path escapes as live path syntax', (t) => {
|
|
|
37
37
|
|
|
38
38
|
t.end()
|
|
39
39
|
})
|
|
40
|
+
|
|
41
|
+
test('serialize preserves literal RFC 3986 reserved path characters', (t) => {
|
|
42
|
+
// GHSA-7mh8-fcmq-x23c: literal reserved path chars must not be rewritten to
|
|
43
|
+
// percent escapes by the escape()-style safe set.
|
|
44
|
+
const cases = [
|
|
45
|
+
'http://example.com/a;b',
|
|
46
|
+
'http://example.com/a=b',
|
|
47
|
+
'http://example.com/a&b',
|
|
48
|
+
'http://example.com/a$b',
|
|
49
|
+
'http://example.com/a:b',
|
|
50
|
+
'http://example.com/a@b'
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
t.plan(cases.length)
|
|
54
|
+
|
|
55
|
+
cases.forEach((uri) => {
|
|
56
|
+
t.equal(fastURI.serialize(fastURI.parse(uri)), uri, uri)
|
|
57
|
+
})
|
|
58
|
+
t.end()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('serialize preserves existing reserved path escapes as data', (t) => {
|
|
62
|
+
// GHSA-7mh8-fcmq-x23c: an existing %3A escape must stay %3A instead of being
|
|
63
|
+
// rewritten into a live colon.
|
|
64
|
+
const cases = [
|
|
65
|
+
'http://example.com/a%3Ab',
|
|
66
|
+
'http://example.com/a%3Bb',
|
|
67
|
+
'http://example.com/a%3D%3D'
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
t.plan(cases.length)
|
|
71
|
+
|
|
72
|
+
cases.forEach((uri) => {
|
|
73
|
+
t.equal(fastURI.serialize(fastURI.parse(uri)), uri, uri)
|
|
74
|
+
})
|
|
75
|
+
t.end()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('serialize keeps path-noscheme colon escaping', (t) => {
|
|
79
|
+
// Without a scheme a literal colon would be reparsed as a scheme separator,
|
|
80
|
+
// so it must stay percent-escaped while an existing %3A is preserved.
|
|
81
|
+
t.equal(fastURI.serialize({ path: 'foo:bar' }), 'foo%3Abar')
|
|
82
|
+
t.equal(fastURI.serialize({ path: 'a%3Ab' }), 'a%3Ab')
|
|
83
|
+
t.end()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('hostname normalization never decodes an escape more than once', (t) => {
|
|
87
|
+
const encodedLocalhost = 'http://%256c%256f%2563%2561%256c%2568%256f%2573%2574/'
|
|
88
|
+
const encodedLoopback = '//127%252e0%252e0%252e1/private'
|
|
89
|
+
const encodedMetadataAddress = '//169%252E254%252E169%252E254/latest/meta-data/'
|
|
90
|
+
|
|
91
|
+
t.equal(fastURI.normalize(encodedLocalhost), encodedLocalhost, 'nested hostname letters remain encoded')
|
|
92
|
+
t.equal(fastURI.normalize(encodedLoopback), encodedLoopback, 'nested IPv4 dots remain encoded')
|
|
93
|
+
t.equal(
|
|
94
|
+
fastURI.resolve('https://safe.example/', encodedLoopback),
|
|
95
|
+
'https://127%252e0%252e0%252e1/private',
|
|
96
|
+
'resolve does not turn nested dots into a loopback address'
|
|
97
|
+
)
|
|
98
|
+
t.equal(
|
|
99
|
+
fastURI.resolve('https://allowed.com/api/v1/', encodedMetadataAddress),
|
|
100
|
+
'https://169%252e254%252e169%252e254/latest/meta-data/',
|
|
101
|
+
'resolve does not turn nested dots into a metadata address'
|
|
102
|
+
)
|
|
103
|
+
t.equal(
|
|
104
|
+
fastURI.normalize('http://allowed.com%255Cevil.com/'),
|
|
105
|
+
'http://allowed.com%255Cevil.com/',
|
|
106
|
+
'normalize does not activate a nested backslash'
|
|
107
|
+
)
|
|
108
|
+
t.equal(
|
|
109
|
+
fastURI.serialize({ scheme: 'http', host: '%256cocalhost', path: '/' }),
|
|
110
|
+
'http://%256cocalhost/',
|
|
111
|
+
'component serialization preserves an encoded percent sign'
|
|
112
|
+
)
|
|
113
|
+
t.equal(
|
|
114
|
+
fastURI.equal(encodedLocalhost, 'http://localhost/', {}),
|
|
115
|
+
false,
|
|
116
|
+
'nested escapes do not compare equal to their twice-decoded target'
|
|
117
|
+
)
|
|
118
|
+
t.end()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('hostname normalization decodes only current unreserved escapes', (t) => {
|
|
122
|
+
t.equal(fastURI.normalize('x://%6cocalhost/'), 'x://localhost/', 'a current unreserved escape is decoded')
|
|
123
|
+
t.equal(fastURI.normalize('x://%256cocalhost/'), 'x://%256cocalhost/', 'an encoded percent is preserved')
|
|
124
|
+
t.equal(fastURI.normalize('x://host%2540evil/'), 'x://host%2540evil/', 'a nested authority delimiter stays inert')
|
|
125
|
+
t.equal(fastURI.normalize('x://%2525/'), 'x://%2525/', 'nested encoded percent signs stay encoded')
|
|
126
|
+
t.end()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('host conversion failures are not treated as comparable URLs', (t) => {
|
|
130
|
+
const malformedHost = 'http://trusted.test%2540evil.test/'
|
|
131
|
+
|
|
132
|
+
t.equal(fastURI.normalize(malformedHost), malformedHost, 'normalization preserves the failing input')
|
|
133
|
+
t.equal(fastURI.equal(malformedHost, malformedHost, {}), false, 'equal rejects a failed host conversion')
|
|
134
|
+
t.throws(
|
|
135
|
+
() => fastURI.resolve(malformedHost, 'child', { domainHost: true }),
|
|
136
|
+
/Host's domain name can not be converted to ASCII/,
|
|
137
|
+
'resolve propagates a host conversion failure'
|
|
138
|
+
)
|
|
139
|
+
t.end()
|
|
140
|
+
})
|
|
Binary file
|