Skip to content

Commit 607bfbe

Browse files
Merge commit from fork
* fix: reject malformed IPv6 literals * fix: restore RFC 5952 zero-run compression for IPv6 hosts The IPv6 validation rewrite stripped per-hextet leading zeros but no longer applied "::" zero-run compression, so normalize() was not RFC 5952 canonical and equal() returned false for the same address across expanded and compressed forms (e.g. [0:0:0:0:0:0:0:1] did not equal [::1]). That makes equal()-based host checks representation-sensitive. - add compressIPv6ZeroRun (longest run, leftmost on ties, minimum length 2) - expand "::" before recompressing so the result is canonical regardless of where the input placed "::" - add IPv6 canonical/equal tests; update the all-zeros normalize expectation --------- Co-authored-by: Ulises Gascon <ulisesgascongonzalez@gmail.com>
1 parent ae92a4c commit 607bfbe

6 files changed

Lines changed: 272 additions & 94 deletions

File tree

index.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,7 @@ function parseWithStatus (uri, opts) {
362362
let malformedPercentEncoding = false
363363
let malformedSchemeSpecific = false
364364
let malformedHost = false
365+
let malformedIPLiteral = false
365366

366367
let isIP = false
367368
if (options.reference === 'suffix') {
@@ -438,9 +439,16 @@ function parseWithStatus (uri, opts) {
438439
if (parsed.host) {
439440
const ipv4result = isIPv4(parsed.host)
440441
if (ipv4result === false) {
442+
const bracketedIPLiteral = parsed.host[0] === '[' && parsed.host[parsed.host.length - 1] === ']'
441443
const ipv6result = normalizeIPv6(parsed.host)
442-
parsed.host = ipv6result.host.toLowerCase()
443-
isIP = ipv6result.isIPV6
444+
isIP = ipv6result.isIPV6 || ipv6result.isIPVFuture === true
445+
malformedIPLiteral = bracketedIPLiteral && ipv6result.error === true
446+
parsed.host = isIP ? ipv6result.host : ipv6result.host.toLowerCase()
447+
448+
if (malformedIPLiteral) {
449+
parsed.error = parsed.error || 'URI host is malformed.'
450+
malformedAuthorityOrPort = true
451+
}
444452
} else {
445453
isIP = true
446454
}
@@ -471,8 +479,9 @@ function parseWithStatus (uri, opts) {
471479
if (parsed.scheme !== undefined) {
472480
parsed.scheme = unescape(parsed.scheme)
473481
}
474-
if (parsed.host !== undefined) {
475-
parsed.host = reescapeHostDelimiters(normalizePercentEncoding(parsed.host, true), isIP)
482+
if (parsed.host !== undefined && !malformedIPLiteral) {
483+
const host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true)
484+
parsed.host = reescapeHostDelimiters(host, isIP)
476485
}
477486
}
478487
if (parsed.path) {

lib/utils.js

Lines changed: 131 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,14 @@ function stringArrayToHexStripped (input) {
7575
return acc
7676
}
7777

78-
/**
79-
* @typedef {Object} GetIPV6Result
80-
* @property {boolean} error - Indicates if there was an error parsing the IPv6 address.
81-
* @property {string} address - The parsed IPv6 address.
82-
* @property {string} [zone] - The zone identifier, if present.
83-
*/
78+
/** @type {(value: string) => boolean} */
79+
const isHextet = RegExp.prototype.test.bind(/^[\dA-Fa-f]{1,4}$/)
80+
81+
/** @type {(value: string) => boolean} */
82+
const isIPvFuture = RegExp.prototype.test.bind(/^[vV][\dA-Fa-f]+\.[A-Za-z\d\-._~!$&'()*+,;=:]+$/)
83+
84+
/** @type {(value: string) => boolean} */
85+
const isZoneCharacter = RegExp.prototype.test.bind(/^[A-Za-z\d\-._~]$/)
8486

8587
/**
8688
* @param {string} value
@@ -89,119 +91,154 @@ function stringArrayToHexStripped (input) {
8991
const nonSimpleDomain = RegExp.prototype.test.bind(/[^!"$&'()*+,\-.;=_`a-z{}~]/u)
9092

9193
/**
92-
* @param {Array<string>} buffer
94+
* @param {string} zone
9395
* @returns {boolean}
9496
*/
95-
function consumeIsZone (buffer) {
96-
buffer.length = 0
97+
function isZoneIdentifier (zone) {
98+
if (zone.length === 0) return false
99+
100+
for (let i = 0; i < zone.length; i++) {
101+
if (isZoneCharacter(zone[i])) continue
102+
if (zone[i] === '%' && i + 2 < zone.length && isHexPair(zone.slice(i + 1, i + 3))) {
103+
i += 2
104+
continue
105+
}
106+
return false
107+
}
108+
97109
return true
98110
}
99111

100112
/**
101-
* @param {Array<string>} buffer
102-
* @param {Array<string>} address
103-
* @param {GetIPV6Result} output
104-
* @returns {boolean}
113+
* Compresses the longest run of zero hextets to "::" per RFC 5952. A run of a
114+
* single zero hextet is left uncompressed. On ties the leftmost run wins.
115+
*
116+
* @param {string[]} hextets
117+
* @returns {string}
105118
*/
106-
function consumeHextets (buffer, address, output) {
107-
if (buffer.length) {
108-
const hex = stringArrayToHexStripped(buffer)
109-
if (hex !== '') {
110-
address.push(hex)
119+
function compressIPv6ZeroRun (hextets) {
120+
let bestStart = -1
121+
let bestLength = 0
122+
let runStart = -1
123+
let runLength = 0
124+
for (let i = 0; i < hextets.length; i++) {
125+
if (hextets[i] === '0') {
126+
if (runStart === -1) runStart = i
127+
runLength++
128+
if (runLength > bestLength) {
129+
bestLength = runLength
130+
bestStart = runStart
131+
}
111132
} else {
112-
output.error = true
113-
return false
133+
runStart = -1
134+
runLength = 0
114135
}
115-
buffer.length = 0
116136
}
117-
return true
137+
138+
if (bestLength < 2) return hextets.join(':')
139+
140+
const head = hextets.slice(0, bestStart).join(':')
141+
const tail = hextets.slice(bestStart + bestLength).join(':')
142+
return head + '::' + tail
118143
}
119144

120145
/**
146+
* Validates an IPv6 address against the alternatives in RFC 3986 section
147+
* 3.2.2 and returns the same address with leading hextet zeroes removed.
148+
* An embedded IPv4 address counts as two hextets and is only valid at the end.
149+
*
121150
* @param {string} input
122-
* @returns {GetIPV6Result}
151+
* @returns {string|undefined}
123152
*/
124-
function getIPV6 (input) {
125-
let tokenCount = 0
126-
const output = { error: false, address: '', zone: '' }
127-
/** @type {Array<string>} */
128-
const address = []
129-
/** @type {Array<string>} */
130-
const buffer = []
131-
let endipv6Encountered = false
132-
let endIpv6 = false
133-
134-
let consume = consumeHextets
153+
function normalizeIPv6Address (input) {
154+
const compression = input.indexOf('::')
155+
if (compression !== -1 && input.indexOf('::', compression + 1) !== -1) return undefined
156+
157+
const left = compression === -1 ? input.split(':') : input.slice(0, compression).split(':')
158+
const right = compression === -1 ? [] : input.slice(compression + 2).split(':')
159+
if (compression !== -1) {
160+
if (left.length === 1 && left[0] === '') left.length = 0
161+
if (right.length === 1 && right[0] === '') right.length = 0
162+
}
135163

136-
for (let i = 0; i < input.length; i++) {
137-
const cursor = input[i]
138-
if (cursor === '[' || cursor === ']') { continue }
139-
if (cursor === ':') {
140-
if (endipv6Encountered === true) {
141-
endIpv6 = true
142-
}
143-
if (!consume(buffer, address, output)) { break }
144-
if (++tokenCount > 7) {
145-
// not valid
146-
output.error = true
147-
break
148-
}
149-
if (i > 0 && input[i - 1] === ':') {
150-
endipv6Encountered = true
151-
}
152-
address.push(':')
153-
continue
154-
} else if (cursor === '%') {
155-
if (!consume(buffer, address, output)) { break }
156-
// switch to zone detection
157-
consume = consumeIsZone
158-
} else {
159-
buffer.push(cursor)
164+
const parts = left.concat(right)
165+
let hextetCount = 0
166+
for (let i = 0; i < parts.length; i++) {
167+
const part = parts[i]
168+
if (part === '') return undefined
169+
170+
if (part.indexOf('.') !== -1) {
171+
if (i !== parts.length - 1 || (compression !== -1 && right.length === 0) || !isIPv4(part)) return undefined
172+
hextetCount += 2
160173
continue
161174
}
175+
176+
if (!isHextet(part)) return undefined
177+
parts[i] = parseInt(part, 16).toString(16)
178+
hextetCount++
162179
}
163-
if (buffer.length) {
164-
if (consume === consumeIsZone) {
165-
output.zone = buffer.join('')
166-
} else if (endIpv6) {
167-
address.push(buffer.join(''))
168-
} else {
169-
address.push(stringArrayToHexStripped(buffer))
170-
}
180+
181+
if (compression === -1) {
182+
if (hextetCount !== 8) return undefined
183+
return compressIPv6ZeroRun(parts)
171184
}
172-
output.address = address.join('')
173-
return output
185+
if (hextetCount >= 8) return undefined
186+
187+
// expand "::" then re-compress the longest run for a canonical result
188+
const expanded = parts.slice(0, left.length)
189+
for (let i = hextetCount; i < 8; i++) expanded.push('0')
190+
for (let i = left.length; i < parts.length; i++) expanded.push(parts[i])
191+
return compressIPv6ZeroRun(expanded)
174192
}
175193

176194
/**
177195
* @typedef {Object} NormalizeIPv6Result
178196
* @property {string} host - The normalized host.
179197
* @property {string} [escapedHost] - The escaped host.
180198
* @property {boolean} isIPV6 - Indicates if the host is an IPv6 address.
199+
* @property {boolean} [isIPVFuture] - Indicates if the host is an IPvFuture literal.
200+
* @property {boolean} [error] - Indicates if a bracketed IP literal is malformed.
181201
*/
182202

183203
/**
204+
* Validates and normalizes a bracketed IP literal. Raw zone separators remain
205+
* accepted for backwards compatibility, while encoded separators and zone
206+
* contents follow RFC 6874.
207+
*
184208
* @param {string} host
185209
* @returns {NormalizeIPv6Result}
186210
*/
187211
function normalizeIPv6 (host) {
188-
if (findToken(host, ':') < 2) { return { host, isIPV6: false } }
189-
const ipv6 = getIPV6(host)
190-
191-
if (!ipv6.error) {
192-
let newHost = ipv6.address
193-
let escapedHost = ipv6.address
194-
if (ipv6.zone) {
195-
// RFC 6874 encodes the zone separator as "%25" in a URI. Accept both
196-
// component forms used by this API ("%zone" and "%25zone") while
197-
// consuming at most that one separator escape.
198-
const zone = ipv6.zone.startsWith('25') ? ipv6.zone.slice(2) : ipv6.zone
199-
newHost += '%' + zone
200-
escapedHost += '%25' + zone
201-
}
202-
return { host: newHost, isIPV6: true, escapedHost }
203-
} else {
204-
return { host, isIPV6: false }
212+
const bracketed = host[0] === '[' && host[host.length - 1] === ']'
213+
const hasBracket = host[0] === '[' || host[host.length - 1] === ']'
214+
if (hasBracket && !bracketed) return { host, isIPV6: false, error: true }
215+
216+
let input = bracketed ? host.slice(1, -1) : host
217+
if (bracketed && isIPvFuture(input)) {
218+
input = input.toLowerCase()
219+
return { host: `[${input}]`, escapedHost: input, isIPV6: false, isIPVFuture: true }
220+
}
221+
222+
if (findToken(input, ':') < 2) {
223+
return { host, isIPV6: false, error: bracketed }
224+
}
225+
226+
let zoneIdentifier = ''
227+
const zoneSeparator = input.indexOf('%')
228+
if (zoneSeparator !== -1) {
229+
const separatorLength = input.slice(zoneSeparator, zoneSeparator + 3).toLowerCase() === '%25' ? 3 : 1
230+
zoneIdentifier = input.slice(zoneSeparator + separatorLength)
231+
if (!isZoneIdentifier(zoneIdentifier)) return { host, isIPV6: false, error: true }
232+
input = input.slice(0, zoneSeparator)
233+
}
234+
235+
const address = normalizeIPv6Address(input)
236+
if (address === undefined) return { host, isIPV6: false, error: true }
237+
238+
return {
239+
host: address + (zoneIdentifier ? '%' + zoneIdentifier : ''),
240+
escapedHost: address + (zoneIdentifier ? '%25' + zoneIdentifier : ''),
241+
isIPV6: true
205242
}
206243
}
207244

@@ -657,12 +694,16 @@ function recomposeAuthority (component) {
657694
}
658695

659696
if (component.host !== undefined) {
660-
// Decode only unreserved bytes, once. In particular, keep %25 encoded so
661-
// it cannot become the introducer for a second escape during recomposition.
662-
let host = normalizePercentEncoding(component.host, true)
697+
let host = component.host
663698
if (!isIPv4(host)) {
664-
const ipV6res = normalizeIPv6(host)
665-
if (ipV6res.isIPV6 === true) {
699+
let ipV6res = normalizeIPv6(host)
700+
if (ipV6res.isIPV6 !== true && ipV6res.isIPVFuture !== true) {
701+
// Decode only unreserved bytes, once. In particular, keep %25 encoded
702+
// so it cannot introduce a second escape during recomposition.
703+
host = normalizePercentEncoding(host, true)
704+
ipV6res = normalizeIPv6(host)
705+
}
706+
if (ipV6res.isIPV6 === true || ipV6res.isIPVFuture === true) {
666707
host = `[${ipV6res.escapedHost}]`
667708
} else {
668709
host = reescapeHostDelimiters(host, false)

test/fixtures/uri-js-parse.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"host": "[2001:dbz::1]",
5454
"port": 80,
5555
"path": "",
56+
"error": "URI host is malformed.",
5657
"reference": "relative"
5758
}
5859
],
@@ -242,6 +243,7 @@
242243
{
243244
"host": "[2606:2800:220:1:248:1893:25c8:1946:43209]",
244245
"path": "",
246+
"error": "URI host is malformed.",
245247
"reference": "relative"
246248
}
247249
],

test/ipv6-canonical.test.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use strict'
2+
3+
const test = require('tape')
4+
const fastURI = require('..')
5+
6+
test('IPv6 hosts normalize to the RFC 5952 canonical form', (t) => {
7+
const cases = [
8+
['http://[0:0:0:0:0:0:0:1]/', 'http://[::1]/'],
9+
['http://[0:0:0:0:0:0:0:0]/', 'http://[::]/'],
10+
['http://[2001:0db8:0000:0000:0000:0000:0000:0001]/', 'http://[2001:db8::1]/'],
11+
['http://[2001:0:0:0:0:0:0:1]/', 'http://[2001::1]/'],
12+
['http://[fe80:0:0:0:0:0:0:1]/', 'http://[fe80::1]/'],
13+
['http://[1:0:0:0:2:0:0:3]/', 'http://[1::2:0:0:3]/']
14+
]
15+
16+
for (const [uri, normalized] of cases) {
17+
t.equal(fastURI.normalize(uri), normalized, `${uri} normalizes to ${normalized}`)
18+
}
19+
t.end()
20+
})
21+
22+
test('IPv6 equal() matches the same address across compressed and expanded forms', (t) => {
23+
const pairs = [
24+
['http://[::1]/', 'http://[0:0:0:0:0:0:0:1]/'],
25+
['http://[::]/', 'http://[0:0:0:0:0:0:0:0]/'],
26+
['http://[2001:db8::1]/', 'http://[2001:0db8:0000:0000:0000:0000:0000:0001]/'],
27+
['http://[1::2:0:0:3]/', 'http://[1:0:0:0:2:0:0:3]/']
28+
]
29+
30+
for (const [a, b] of pairs) {
31+
t.equal(fastURI.equal(a, b), true, `${a} equals ${b}`)
32+
}
33+
t.end()
34+
})

0 commit comments

Comments
 (0)