fast-uri
Version:
Dependency-free RFC 3986 URI toolbox
152 lines (132 loc) • 5.73 kB
JavaScript
const test = require('tape')
const fastURI = require('..')
function encodeCharacter (value, index, lowercaseHex) {
let hex = value.charCodeAt(index).toString(16)
if (!lowercaseHex) hex = hex.toUpperCase()
return value.slice(0, index) + '%' + hex + value.slice(index + 1)
}
function mailtoState (component) {
return {
to: component.to,
subject: component.subject,
body: component.body,
headers: component.headers && { ...component.headers }
}
}
test('mailto parsing classifies decoded, case-insensitive reserved field names', (t) => {
const cases = [
{ name: 'to', value: 'attacker@evil.test', property: 'to' },
{ name: 'subject', value: 'injected subject', property: 'subject' },
{ name: 'body', value: 'injected body', property: 'body' }
]
for (const entry of cases) {
const variants = new Set([entry.name.toUpperCase()])
for (let i = 0; i < entry.name.length; i++) {
variants.add(encodeCharacter(entry.name, i, false))
variants.add(encodeCharacter(entry.name, i, true))
}
for (const name of variants) {
const parsed = fastURI.parse(`mailto:alice@example.test?${name}=${entry.value}`)
if (entry.property === 'to') {
t.deepEqual(parsed.to, ['alice@example.test', entry.value], name + ' is classified as a recipient')
} else {
t.equal(parsed[entry.property], entry.value, name + ' is classified as ' + entry.property)
}
t.equal(parsed.headers, undefined, name + ' is not exposed as a generic header')
const reparsed = fastURI.parse(fastURI.serialize(parsed))
t.deepEqual(mailtoState(reparsed), mailtoState(parsed), name + ' keeps its meaning after a roundtrip')
}
}
t.end()
})
test('mailto generic field names are decoded exactly once across roundtrips', (t) => {
const cases = [
['%2574o', '%74o'],
['%2573ubject', '%73ubject'],
['%2562ody', '%62ody'],
['x%2520y', 'x%20y'],
['%26to', '&to'],
['%25GGto', '%GGto']
]
for (const [encodedName, decodedName] of cases) {
let serialized = `mailto:alice@example.test?${encodedName}=value`
for (let round = 0; round < 3; round++) {
const parsed = fastURI.parse(serialized)
t.deepEqual({ ...parsed.headers }, { [decodedName]: 'value' }, encodedName + ' stays generic in round ' + round)
t.deepEqual(parsed.to, ['alice@example.test'], encodedName + ' does not add a recipient in round ' + round)
t.equal(parsed.subject, undefined, encodedName + ' does not set a subject in round ' + round)
t.equal(parsed.body, undefined, encodedName + ' does not set a body in round ' + round)
serialized = fastURI.serialize(parsed)
}
t.equal(serialized, `mailto:alice@example.test?${encodedName}=value`, encodedName + ' serializes idempotently')
}
t.equal(
fastURI.serialize({ scheme: 'mailto', to: ['alice@example.test'], headers: { '%74o': 'value' } }),
'mailto:alice@example.test?%2574o=value',
'a percent sequence in a decoded component key is encoded as data'
)
t.end()
})
test('mailto serialization canonicalizes reserved generic header names', (t) => {
const serialized = fastURI.serialize({
scheme: 'mailto',
to: ['alice@example.test'],
subject: 'safe subject',
body: 'safe body',
headers: {
TO: 'attacker@evil.test',
Subject: 'hidden subject',
BODY: 'hidden body',
cc: 'copy@example.test'
}
})
t.equal(
serialized,
'mailto:alice@example.test,attacker@evil.test?subject=safe%20subject&body=safe%20body&cc=copy@example.test',
'dedicated properties override header variants and all recipients are visible in the path'
)
const parsed = fastURI.parse(serialized)
t.deepEqual(parsed.to, ['alice@example.test', 'attacker@evil.test'], 'canonical recipients are classified consistently')
t.equal(parsed.subject, 'safe subject', 'the dedicated subject wins')
t.equal(parsed.body, 'safe body', 'the dedicated body wins')
t.deepEqual({ ...parsed.headers }, { cc: 'copy@example.test' }, 'only generic headers remain generic')
const empty = fastURI.serialize({
scheme: 'mailto',
to: ['alice@example.test'],
subject: '',
body: '',
headers: { Subject: 'hidden subject', BODY: 'hidden body' }
})
t.equal(empty, 'mailto:alice@example.test?subject=&body=', 'explicit empty properties override header variants')
const emptyParsed = fastURI.parse(empty)
t.equal(emptyParsed.subject, '', 'an empty subject round-trips')
t.equal(emptyParsed.body, '', 'an empty body round-trips')
t.end()
})
test('mailto duplicate reserved fields have deterministic parsing semantics', (t) => {
const parsed = fastURI.parse(
'mailto:alice@example.test?subject=first&%53UBJECT=last&body=first&%42ODY=last&to=one@example.test&%54O=two@example.test'
)
t.equal(parsed.subject, 'last', 'the last subject wins')
t.equal(parsed.body, 'last', 'the last body wins')
t.deepEqual(
parsed.to,
['alice@example.test', 'one@example.test', 'two@example.test'],
'all recipient fields are accumulated'
)
t.equal(parsed.headers, undefined, 'no reserved spelling remains generic')
t.end()
})
test('encoded reserved field names expose decoded control characters immediately', (t) => {
const input = 'mailto:alice@example.test?%73ubject=hello%0D%0Aworld'
const parsed = fastURI.parse(input)
t.equal(parsed.subject, 'hello\r\nworld', 'the first parse exposes CRLF in the dedicated property')
t.equal(parsed.headers, undefined, 'the subject is not hidden as a generic header')
t.equal(
fastURI.parse(fastURI.serialize(parsed)).subject,
parsed.subject,
'the decoded subject remains stable after a roundtrip'
)
t.end()
})