Visitar URL original
fix: reject literal backslash in URI authority · fastify/fast-uri@2d50fba · GitHub
Skip to content

Commit 2d50fba

Browse files
committed
fix: reject literal backslash in URI authority
1 parent 0549fe3 commit 2d50fba

2 files changed

Lines changed: 79 additions & 0 deletions

File tree

‎index.js‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,10 @@ function serialize (cmpts, opts) {
202202

203203
const URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u
204204

205+
// Captures the authority component (between "//" and the next "/", "?" or "#"),
206+
// with or without a scheme prefix, for the literal-backslash rejection below.
207+
const AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/
208+
205209
/**
206210
* @param {import('./types/index').URIComponent} parsed
207211
* @param {RegExpMatchArray} matches
@@ -248,6 +252,19 @@ function parseWithStatus (uri, opts) {
248252
}
249253
}
250254

255+
// A literal backslash (U+005C) is not a valid RFC 3986 URI character and is
256+
// not an authority delimiter. Reject it in the authority rather than
257+
// rewriting it: normalizing "\" -> "/" (WHATWG error recovery) could silently
258+
// change the resource identified by an otherwise-invalid input, and lets "\"
259+
// act as a host delimiter here while Node's native URL parses a different
260+
// host (SSRF / redirect / origin-allowlist bypass). Percent-encoded %5C is
261+
// untouched and remains valid encoded data.
262+
const authorityMatch = uri.match(AUTHORITY_PREFIX)
263+
if (authorityMatch !== null && authorityMatch[1].indexOf('\\') !== -1) {
264+
parsed.error = 'URI authority must not contain a literal backslash.'
265+
malformedAuthorityOrPort = true
266+
}
267+
251268
const matches = uri.match(URI_PARSE)
252269

253270
if (matches) {

‎test/security.test.js‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,65 @@ test('parse canonicalises IDN / Unicode hosts to their ASCII form', (t) => {
159159
t.equal(parsed.host, expectedHost, `host canonicalised to ASCII: ${description}`)
160160
})
161161
})
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+
})

0 commit comments

Comments
 (0)