fast-uri 4.1.4 → 4.1.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 +10 -6
- package/lib/schemes.js +46 -16
- package/package.json +1 -1
- package/test/mailto-field-name-security.test.js +151 -0
- package/test/security-normalization.test.js +38 -0
package/index.js
CHANGED
|
@@ -537,13 +537,17 @@ function parseWithStatus (uri, opts) {
|
|
|
537
537
|
malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP)
|
|
538
538
|
}
|
|
539
539
|
|
|
540
|
-
if (
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
540
|
+
if (uri.indexOf('%') !== -1 && parsed.host !== undefined && !malformedIPLiteral) {
|
|
541
|
+
let host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true)
|
|
542
|
+
if (!isIP) {
|
|
543
|
+
// Fold reg-name case after decoding unreserved octets. The second
|
|
544
|
+
// pass only restores uppercase hex in escapes that remain encoded.
|
|
545
|
+
host = normalizePercentEncoding(host.toLowerCase())
|
|
546
546
|
}
|
|
547
|
+
parsed.host = reescapeHostDelimiters(host, isIP)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
|
|
547
551
|
if (parsed.path) {
|
|
548
552
|
parsed.path = normalizePathEncoding(parsed.path)
|
|
549
553
|
}
|
package/lib/schemes.js
CHANGED
|
@@ -495,23 +495,24 @@ function mailtoParse (component, options) {
|
|
|
495
495
|
mailtoComponent.error = mailtoComponent.error || 'URI mailto has malformed header fields.'
|
|
496
496
|
continue
|
|
497
497
|
}
|
|
498
|
-
const name = eqIdx === -1 ? token : token.slice(0, eqIdx)
|
|
498
|
+
const name = decodeHex(eqIdx === -1 ? token : token.slice(0, eqIdx))
|
|
499
|
+
const normalizedName = name.toLowerCase()
|
|
499
500
|
const value = eqIdx === -1 ? '' : token.slice(eqIdx + 1)
|
|
500
|
-
if (
|
|
501
|
+
if (normalizedName === 'to') {
|
|
501
502
|
const addrs = value.split(',')
|
|
502
503
|
for (let j = 0; j < addrs.length; j++) to.push(addrs[j])
|
|
503
504
|
continue
|
|
504
505
|
}
|
|
505
|
-
if (
|
|
506
|
+
if (normalizedName === 'subject') {
|
|
506
507
|
mailtoComponent.subject = decodeHex(value)
|
|
507
508
|
continue
|
|
508
509
|
}
|
|
509
|
-
if (
|
|
510
|
+
if (normalizedName === 'body') {
|
|
510
511
|
mailtoComponent.body = decodeHex(value)
|
|
511
512
|
continue
|
|
512
513
|
}
|
|
513
514
|
if (headers === null) headers = /** @type {Record<string,string>} */ (Object.create(null))
|
|
514
|
-
headers[
|
|
515
|
+
headers[name] = decodeHex(value)
|
|
515
516
|
}
|
|
516
517
|
if (headers !== null) mailtoComponent.headers = headers
|
|
517
518
|
}
|
|
@@ -536,6 +537,18 @@ function mailtoParse (component, options) {
|
|
|
536
537
|
return mailtoComponent
|
|
537
538
|
}
|
|
538
539
|
|
|
540
|
+
/**
|
|
541
|
+
* Encodes a decoded mailto header name. Unlike `encodeWithAllow`, a percent
|
|
542
|
+
* sign is always data here, even when followed by two hex digits. Preserving
|
|
543
|
+
* such a sequence as an existing escape would decode the name again on the
|
|
544
|
+
* next parse and could turn a generic header into `to`, `subject`, or `body`.
|
|
545
|
+
* @param {string} name
|
|
546
|
+
* @returns {string}
|
|
547
|
+
*/
|
|
548
|
+
function mailtoEncodeHeaderName (name) {
|
|
549
|
+
return encodeWithAllow(name.replace(/%/gu, '%25'), HFNAME)
|
|
550
|
+
}
|
|
551
|
+
|
|
539
552
|
/**
|
|
540
553
|
* @param {import('../types/index').URIComponent} component
|
|
541
554
|
* @param {import('../types/index').Options} options
|
|
@@ -544,6 +557,31 @@ function mailtoParse (component, options) {
|
|
|
544
557
|
function mailtoSerialize (component, options) {
|
|
545
558
|
const mailtoComponent = component
|
|
546
559
|
const to = Array.isArray(mailtoComponent.to) ? mailtoComponent.to.slice() : []
|
|
560
|
+
const sourceHeaders = mailtoComponent.headers && typeof mailtoComponent.headers === 'object'
|
|
561
|
+
? Object.assign(Object.create(null), mailtoComponent.headers)
|
|
562
|
+
: Object.create(null)
|
|
563
|
+
const headers = Object.create(null)
|
|
564
|
+
|
|
565
|
+
// Header field names are case-insensitive. Canonicalize the fields with
|
|
566
|
+
// dedicated component properties so callers cannot smuggle a second spelling
|
|
567
|
+
// through `headers`. As during parsing, all `to` fields are accumulated;
|
|
568
|
+
// repeated subject/body fields use the last value.
|
|
569
|
+
for (const name in sourceHeaders) {
|
|
570
|
+
const normalizedName = name.toLowerCase()
|
|
571
|
+
if (normalizedName === 'to') {
|
|
572
|
+
const addrs = String(sourceHeaders[name]).split(',')
|
|
573
|
+
for (let i = 0; i < addrs.length; i++) to.push(addrs[i])
|
|
574
|
+
} else if (normalizedName === 'subject' || normalizedName === 'body') {
|
|
575
|
+
headers[normalizedName] = sourceHeaders[name]
|
|
576
|
+
} else {
|
|
577
|
+
headers[name] = sourceHeaders[name]
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (mailtoComponent.subject !== undefined) headers.subject = mailtoComponent.subject
|
|
582
|
+
if (mailtoComponent.body !== undefined) headers.body = mailtoComponent.body
|
|
583
|
+
mailtoComponent.headers = headers
|
|
584
|
+
|
|
547
585
|
if (to.length) {
|
|
548
586
|
for (let i = 0; i < to.length; i++) {
|
|
549
587
|
const addr = String(to[i])
|
|
@@ -570,21 +608,13 @@ function mailtoSerialize (component, options) {
|
|
|
570
608
|
mailtoComponent.path = undefined
|
|
571
609
|
}
|
|
572
610
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
: Object.create(null)
|
|
576
|
-
|
|
577
|
-
if (mailtoComponent.subject) headers.subject = mailtoComponent.subject
|
|
578
|
-
if (mailtoComponent.body) headers.body = mailtoComponent.body
|
|
579
|
-
mailtoComponent.headers = headers
|
|
580
|
-
|
|
581
|
-
// `headers` has a null prototype and was filled via Object.assign, so every
|
|
582
|
-
// enumerable key is an own property and no hasOwnProperty guard is needed.
|
|
611
|
+
// `headers` has a null prototype, so every enumerable key is an own property
|
|
612
|
+
// and no hasOwnProperty guard is needed.
|
|
583
613
|
let query = ''
|
|
584
614
|
let count = 0
|
|
585
615
|
for (const name in headers) {
|
|
586
616
|
if (count++ !== 0) query += '&'
|
|
587
|
-
query +=
|
|
617
|
+
query += mailtoEncodeHeaderName(name) + '=' + encodeWithAllow(String(headers[name]), HFNAME)
|
|
588
618
|
}
|
|
589
619
|
|
|
590
620
|
if (count !== 0) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const test = require('tape')
|
|
4
|
+
const fastURI = require('..')
|
|
5
|
+
|
|
6
|
+
function encodeCharacter (value, index, lowercaseHex) {
|
|
7
|
+
let hex = value.charCodeAt(index).toString(16)
|
|
8
|
+
if (!lowercaseHex) hex = hex.toUpperCase()
|
|
9
|
+
return value.slice(0, index) + '%' + hex + value.slice(index + 1)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function mailtoState (component) {
|
|
13
|
+
return {
|
|
14
|
+
to: component.to,
|
|
15
|
+
subject: component.subject,
|
|
16
|
+
body: component.body,
|
|
17
|
+
headers: component.headers && { ...component.headers }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('mailto parsing classifies decoded, case-insensitive reserved field names', (t) => {
|
|
22
|
+
const cases = [
|
|
23
|
+
{ name: 'to', value: 'attacker@evil.test', property: 'to' },
|
|
24
|
+
{ name: 'subject', value: 'injected subject', property: 'subject' },
|
|
25
|
+
{ name: 'body', value: 'injected body', property: 'body' }
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
for (const entry of cases) {
|
|
29
|
+
const variants = new Set([entry.name.toUpperCase()])
|
|
30
|
+
for (let i = 0; i < entry.name.length; i++) {
|
|
31
|
+
variants.add(encodeCharacter(entry.name, i, false))
|
|
32
|
+
variants.add(encodeCharacter(entry.name, i, true))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const name of variants) {
|
|
36
|
+
const parsed = fastURI.parse(`mailto:alice@example.test?${name}=${entry.value}`)
|
|
37
|
+
if (entry.property === 'to') {
|
|
38
|
+
t.deepEqual(parsed.to, ['alice@example.test', entry.value], name + ' is classified as a recipient')
|
|
39
|
+
} else {
|
|
40
|
+
t.equal(parsed[entry.property], entry.value, name + ' is classified as ' + entry.property)
|
|
41
|
+
}
|
|
42
|
+
t.equal(parsed.headers, undefined, name + ' is not exposed as a generic header')
|
|
43
|
+
|
|
44
|
+
const reparsed = fastURI.parse(fastURI.serialize(parsed))
|
|
45
|
+
t.deepEqual(mailtoState(reparsed), mailtoState(parsed), name + ' keeps its meaning after a roundtrip')
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
t.end()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('mailto generic field names are decoded exactly once across roundtrips', (t) => {
|
|
53
|
+
const cases = [
|
|
54
|
+
['%2574o', '%74o'],
|
|
55
|
+
['%2573ubject', '%73ubject'],
|
|
56
|
+
['%2562ody', '%62ody'],
|
|
57
|
+
['x%2520y', 'x%20y'],
|
|
58
|
+
['%26to', '&to'],
|
|
59
|
+
['%25GGto', '%GGto']
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
for (const [encodedName, decodedName] of cases) {
|
|
63
|
+
let serialized = `mailto:alice@example.test?${encodedName}=value`
|
|
64
|
+
for (let round = 0; round < 3; round++) {
|
|
65
|
+
const parsed = fastURI.parse(serialized)
|
|
66
|
+
t.deepEqual({ ...parsed.headers }, { [decodedName]: 'value' }, encodedName + ' stays generic in round ' + round)
|
|
67
|
+
t.deepEqual(parsed.to, ['alice@example.test'], encodedName + ' does not add a recipient in round ' + round)
|
|
68
|
+
t.equal(parsed.subject, undefined, encodedName + ' does not set a subject in round ' + round)
|
|
69
|
+
t.equal(parsed.body, undefined, encodedName + ' does not set a body in round ' + round)
|
|
70
|
+
serialized = fastURI.serialize(parsed)
|
|
71
|
+
}
|
|
72
|
+
t.equal(serialized, `mailto:alice@example.test?${encodedName}=value`, encodedName + ' serializes idempotently')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
t.equal(
|
|
76
|
+
fastURI.serialize({ scheme: 'mailto', to: ['alice@example.test'], headers: { '%74o': 'value' } }),
|
|
77
|
+
'mailto:alice@example.test?%2574o=value',
|
|
78
|
+
'a percent sequence in a decoded component key is encoded as data'
|
|
79
|
+
)
|
|
80
|
+
t.end()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('mailto serialization canonicalizes reserved generic header names', (t) => {
|
|
84
|
+
const serialized = fastURI.serialize({
|
|
85
|
+
scheme: 'mailto',
|
|
86
|
+
to: ['alice@example.test'],
|
|
87
|
+
subject: 'safe subject',
|
|
88
|
+
body: 'safe body',
|
|
89
|
+
headers: {
|
|
90
|
+
TO: 'attacker@evil.test',
|
|
91
|
+
Subject: 'hidden subject',
|
|
92
|
+
BODY: 'hidden body',
|
|
93
|
+
cc: 'copy@example.test'
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
t.equal(
|
|
98
|
+
serialized,
|
|
99
|
+
'mailto:alice@example.test,attacker@evil.test?subject=safe%20subject&body=safe%20body&cc=copy@example.test',
|
|
100
|
+
'dedicated properties override header variants and all recipients are visible in the path'
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const parsed = fastURI.parse(serialized)
|
|
104
|
+
t.deepEqual(parsed.to, ['alice@example.test', 'attacker@evil.test'], 'canonical recipients are classified consistently')
|
|
105
|
+
t.equal(parsed.subject, 'safe subject', 'the dedicated subject wins')
|
|
106
|
+
t.equal(parsed.body, 'safe body', 'the dedicated body wins')
|
|
107
|
+
t.deepEqual({ ...parsed.headers }, { cc: 'copy@example.test' }, 'only generic headers remain generic')
|
|
108
|
+
|
|
109
|
+
const empty = fastURI.serialize({
|
|
110
|
+
scheme: 'mailto',
|
|
111
|
+
to: ['alice@example.test'],
|
|
112
|
+
subject: '',
|
|
113
|
+
body: '',
|
|
114
|
+
headers: { Subject: 'hidden subject', BODY: 'hidden body' }
|
|
115
|
+
})
|
|
116
|
+
t.equal(empty, 'mailto:alice@example.test?subject=&body=', 'explicit empty properties override header variants')
|
|
117
|
+
const emptyParsed = fastURI.parse(empty)
|
|
118
|
+
t.equal(emptyParsed.subject, '', 'an empty subject round-trips')
|
|
119
|
+
t.equal(emptyParsed.body, '', 'an empty body round-trips')
|
|
120
|
+
t.end()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('mailto duplicate reserved fields have deterministic parsing semantics', (t) => {
|
|
124
|
+
const parsed = fastURI.parse(
|
|
125
|
+
'mailto:alice@example.test?subject=first&%53UBJECT=last&body=first&%42ODY=last&to=one@example.test&%54O=two@example.test'
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
t.equal(parsed.subject, 'last', 'the last subject wins')
|
|
129
|
+
t.equal(parsed.body, 'last', 'the last body wins')
|
|
130
|
+
t.deepEqual(
|
|
131
|
+
parsed.to,
|
|
132
|
+
['alice@example.test', 'one@example.test', 'two@example.test'],
|
|
133
|
+
'all recipient fields are accumulated'
|
|
134
|
+
)
|
|
135
|
+
t.equal(parsed.headers, undefined, 'no reserved spelling remains generic')
|
|
136
|
+
t.end()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('encoded reserved field names expose decoded control characters immediately', (t) => {
|
|
140
|
+
const input = 'mailto:alice@example.test?%73ubject=hello%0D%0Aworld'
|
|
141
|
+
const parsed = fastURI.parse(input)
|
|
142
|
+
|
|
143
|
+
t.equal(parsed.subject, 'hello\r\nworld', 'the first parse exposes CRLF in the dedicated property')
|
|
144
|
+
t.equal(parsed.headers, undefined, 'the subject is not hidden as a generic header')
|
|
145
|
+
t.equal(
|
|
146
|
+
fastURI.parse(fastURI.serialize(parsed)).subject,
|
|
147
|
+
parsed.subject,
|
|
148
|
+
'the decoded subject remains stable after a roundtrip'
|
|
149
|
+
)
|
|
150
|
+
t.end()
|
|
151
|
+
})
|
|
@@ -126,6 +126,44 @@ test('hostname normalization decodes only current unreserved escapes', (t) => {
|
|
|
126
126
|
t.end()
|
|
127
127
|
})
|
|
128
128
|
|
|
129
|
+
test('scheme-relative hostname normalization folds decoded ASCII case', (t) => {
|
|
130
|
+
const encodedHost = '//%41.com'
|
|
131
|
+
const normalizedHost = fastURI.normalize(encodedHost)
|
|
132
|
+
const encodedMetadata = '//%4Detadata.internal/private'
|
|
133
|
+
const literalMetadata = '//metadata.internal/private'
|
|
134
|
+
|
|
135
|
+
t.equal(fastURI.parse(encodedHost).host, 'a.com', 'parse folds an encoded uppercase host letter')
|
|
136
|
+
t.equal(normalizedHost, '//a.com', 'normalize emits a lowercase host')
|
|
137
|
+
t.equal(fastURI.normalize(normalizedHost), normalizedHost, 'host normalization is idempotent')
|
|
138
|
+
t.equal(fastURI.equal(encodedHost, '//a.com'), true, 'encoded and literal host spellings compare equal')
|
|
139
|
+
t.equal(fastURI.equal(encodedMetadata, literalMetadata), true, 'encoded metadata host compares equal')
|
|
140
|
+
t.equal(
|
|
141
|
+
fastURI.resolve('x://trusted.example/', encodedMetadata),
|
|
142
|
+
fastURI.resolve('x://trusted.example/', literalMetadata),
|
|
143
|
+
'encoded and literal metadata hosts resolve identically'
|
|
144
|
+
)
|
|
145
|
+
t.equal(
|
|
146
|
+
fastURI.normalize('//example.com%2fpath'),
|
|
147
|
+
'//example.com%2Fpath',
|
|
148
|
+
'reserved host escapes remain encoded with uppercase hex'
|
|
149
|
+
)
|
|
150
|
+
t.equal(fastURI.normalize('//%2541.com'), '//%2541.com', 'nested host escapes are not decoded twice')
|
|
151
|
+
t.equal(fastURI.equal('//User@%41.com/path', '//user@a.com/path'), false, 'userinfo remains case-sensitive')
|
|
152
|
+
t.equal(fastURI.equal('//%41.com/Path', '//a.com/path'), false, 'path remains case-sensitive')
|
|
153
|
+
t.equal(fastURI.equal('//%41.com/?Token=Value', '//a.com/?token=value'), false, 'query remains case-sensitive')
|
|
154
|
+
t.end()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('host normalization applies to schemes that skip path normalization', (t) => {
|
|
158
|
+
const encodedHost = 'mailto://%41.com'
|
|
159
|
+
const literalHost = 'mailto://a.com'
|
|
160
|
+
|
|
161
|
+
t.equal(fastURI.parse(encodedHost).host, fastURI.parse(literalHost).host, 'parse results have consistent hosts')
|
|
162
|
+
t.equal(fastURI.normalize(encodedHost), fastURI.normalize(literalHost), 'normalize results are consistent')
|
|
163
|
+
t.equal(fastURI.equal(encodedHost, literalHost), true, 'encoded and literal hosts compare equal')
|
|
164
|
+
t.end()
|
|
165
|
+
})
|
|
166
|
+
|
|
129
167
|
test('host conversion failures are not treated as comparable URLs', (t) => {
|
|
130
168
|
const malformedHost = 'http://trusted.test%2540evil.test/'
|
|
131
169
|
|