Skip to content

Commit

Permalink
fix(coercePath): widen regex to escape : in IPv6 address as well
Browse files Browse the repository at this point in the history
This fixes a bug in tests where the preview server is bound to an IPv6
URL, which causes the matcher to fail when comparing the URL to origin,
which causes the request to send wthout a `referer` header, which causes
an error in the service worker and incorrect error snapshots (e.g. using
full URL instead of relative URL because no match was found).

Fix:

* When escaping colons in path before passing to `path-to-regexp`,
make sure to include colons in the host, but only if it's a bracketed
IPv6 host (this means that you can use wildcards in a non-bracketed
host if you really want to, e.g. `:subdomain.foo.com`)

* Combine previously multiple regexes for scheme and port into a single
regex for scheme, port, and host (since their logic is interdependent)

* Add tests for various convoluted IPv6 scenarios and wildcard placements
  • Loading branch information
milesrichardson committed Sep 29, 2022
1 parent f9aa05a commit b57db77
Show file tree
Hide file tree
Showing 2 changed files with 187 additions and 10 deletions.
158 changes: 156 additions & 2 deletions src/utils/matching/matchRequestUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ describe('matchRequestUrl', () => {
})
})

test('decodes path parameters with ipv6 address', () => {
const url = 'http://example.com:5001/example'
const match = matchRequestUrl(
new URL(`https://[::1]/reflect-url/${encodeURIComponent(url)}`),
'https://[::1]/reflect-url/:url',
)
expect(match).toHaveProperty('matches', true)
expect(match).toHaveProperty('params', {
url,
})
})

test('returns false when does not match against the request URL', () => {
const match = matchRequestUrl(
new URL('https://test.mswjs.io'),
Expand All @@ -61,6 +73,56 @@ describe('matchRequestUrl', () => {
expect(match).toHaveProperty('matches', false)
expect(match).toHaveProperty('params', {})
})

test('returns true for exact match ipv4', () => {
const match = matchRequestUrl(
new URL('http://127.0.0.1:62588/user'),
'http://127.0.0.1:62588/user',
)

expect(match).toHaveProperty('matches', true)
expect(match).toHaveProperty('params', {})
})

test('returns true for exact match ipv6', () => {
const match = matchRequestUrl(
new URL('http://[::1]:62588/user'),
'http://[::1]:62588/user',
)

expect(match).toHaveProperty('matches', true)
expect(match).toHaveProperty('params', {})
})

test('returns true for wildcard match ipv6 with parameters', () => {
const match = matchRequestUrl(
new URL('http://[::1]:62588/user/23848304'),
'http://[::1]:62588/user/:user_id',
)

expect(match).toHaveProperty('matches', true)
expect(match).toHaveProperty('params', { user_id: '23848304' })
})

test('returns true for wildcard match ipv6, no port, with parameters', () => {
const match = matchRequestUrl(
new URL('http://[::1]/user/23848304'),
'http://[::1]/user/:user_id',
)

expect(match).toHaveProperty('matches', true)
expect(match).toHaveProperty('params', { user_id: '23848304' })
})

test('returns false for wildcard non-match ipv6 with parameters', () => {
const match = matchRequestUrl(
new URL('http://[::1]:62588/not-matching/23848304'),
'http://[::1]:62588/user/:user_id',
)

expect(match).toHaveProperty('matches', false)
expect(match).toHaveProperty('params', {})
})
})

describe('coercePath', () => {
Expand All @@ -72,21 +134,113 @@ describe('coercePath', () => {
expect(coercePath('http://localhost:3000')).toEqual(
'http\\://localhost\\:3000',
)
expect(coercePath('http://[::1]:3000')).toEqual('http\\://[\\:\\:1]\\:3000')
expect(coercePath('http://[::1]')).toEqual('http\\://[\\:\\:1]')
expect(coercePath('https://[::1]')).toEqual('https\\://[\\:\\:1]')
expect(coercePath('https://[::1]/:111')).toEqual('https\\://[\\:\\:1]/:111')
expect(coercePath('http://127.0.0.1:3000')).toEqual(
'http\\://127.0.0.1\\:3000',
)
expect(coercePath('http://127.0.0.1')).toEqual('http\\://127.0.0.1')
expect(coercePath('https://127.0.0.1')).toEqual('https\\://127.0.0.1')
expect(coercePath('https://127.0.0.1/:111')).toEqual(
'https\\://127.0.0.1/:111',
)
})

test('escapes the colon before the port number', () => {
test('escapes any colon(s) in host for bracketed ipv6 host ::1', () => {
expect(coercePath('http://[::1]:3000/:aaa/:bbb/:3000')).toEqual(
'http\\://[\\:\\:1]\\:3000/:aaa/:bbb/:3000',
)
expect(coercePath('http://[::1]:3000')).toEqual('http\\://[\\:\\:1]\\:3000')
expect(coercePath('//[::1]:3000')).toEqual('//[\\:\\:1]\\:3000')
expect(coercePath('//[::1]:3000/')).toEqual('//[\\:\\:1]\\:3000/')
expect(coercePath('[::1]')).toEqual('[\\:\\:1]')
expect(coercePath('[::1]')).toEqual('[\\:\\:1]')
expect(coercePath('http://[::1]')).toEqual('http\\://[\\:\\:1]')
expect(coercePath('http://[::1]/')).toEqual('http\\://[\\:\\:1]/')
expect(coercePath('[::1]/foo/bar')).toEqual('[\\:\\:1]/foo/bar')
expect(coercePath('[::1]/:360/:180')).toEqual('[\\:\\:1]/:360/:180')
})

test('escapes any colon(s) in host for bracketed ipv6 host [1:2:3::4]', () => {
expect(coercePath('http://[1:2:3::4]:3000')).toEqual(
'http\\://[1\\:2\\:3\\:\\:4]\\:3000',
)
expect(coercePath('//[1:2:3::4]:3000')).toEqual(
'//[1\\:2\\:3\\:\\:4]\\:3000',
)
expect(coercePath('//[1:2:3::4]:3000/')).toEqual(
'//[1\\:2\\:3\\:\\:4]\\:3000/',
)
expect(coercePath('[1:2:3::4]')).toEqual('[1\\:2\\:3\\:\\:4]')
expect(coercePath('[1:2:3::4]')).toEqual('[1\\:2\\:3\\:\\:4]')
expect(coercePath('http://[1:2:3::4]')).toEqual(
'http\\://[1\\:2\\:3\\:\\:4]',
)
expect(coercePath('http://[1:2:3::4]/')).toEqual(
'http\\://[1\\:2\\:3\\:\\:4]/',
)
expect(coercePath('[1:2:3::4]/foo/bar')).toEqual(
'[1\\:2\\:3\\:\\:4]/foo/bar',
)
expect(coercePath('[1:2:3::4]/:360/:180')).toEqual(
'[1\\:2\\:3\\:\\:4]/:360/:180',
)
})

test('escapes any colon(s) in host for various bracketed ipv6 hosts', () => {
expect(coercePath('//[1:2:3::4]:3000')).toEqual(
'//[1\\:2\\:3\\:\\:4]\\:3000',
)
expect(coercePath('//[1:2:37ce::4]:3000')).toEqual(
'//[1\\:2\\:37ce\\:\\:4]\\:3000',
)
expect(coercePath('[5:6:7ce3:333:e22::]:64735/user_ipv6/:111')).toEqual(
'[5\\:6\\:7ce3\\:333\\:e22\\:\\:]\\:64735/user_ipv6/:111',
)
})

test('does not escape any colon(s) in host for non-bracketed ipv6 hosts', () => {
expect(
coercePath('http://:subdomain.localhost:3000/:aaa/:bbb/:3000'),
).toEqual('http\\://:subdomain.localhost\\:3000/:aaa/:bbb/:3000')
expect(coercePath('http://127.0.0.:subnet:3000/:aaa/:bbb/:3000')).toEqual(
'http\\://127.0.0.:subnet\\:3000/:aaa/:bbb/:3000',
)
expect(coercePath('127.0.0.:subnet:3000')).toEqual('127.0.0.:subnet\\:3000')
expect(coercePath('127.0.0.:subnet/')).toEqual('127.0.0.:subnet/')
expect(coercePath('//127.0.0.:subnet/')).toEqual('//127.0.0.:subnet/')
expect(coercePath('http://127.0.0.:subnet/')).toEqual(
'http\\://127.0.0.:subnet/',
)

// note: ipv6 is hexadecimal, i.e. [a-f], so this should not be escaped
expect(coercePath('http://[:1zz::]')).toEqual('http://[:1zz::]')

// This is either an invalid address, or the address is `:` and port `:1`
expect(coercePath('http://::1')).toEqual('http\\://:\\:1')
})

test('escapes the colon before the port number immediately after the host', () => {
expect(coercePath('localhost:8080')).toEqual('localhost\\:8080')
expect(coercePath('http://127.0.0.1:8080')).toEqual(
'http\\://127.0.0.1\\:8080',
)
expect(coercePath('https://example.com:1234')).toEqual(
'https\\://example.com\\:1234',
)

expect(coercePath('localhost:8080/:5678')).toEqual('localhost\\:8080/:5678')
expect(coercePath('https://example.com:8080/:5678')).toEqual(
'https\\://example.com\\:8080/:5678',
)
expect(coercePath('//example.com:8080/:5678')).toEqual(
'//example.com\\:8080/:5678',
)
expect(coercePath('//[::1]:8080/:5678')).toEqual('//[\\:\\:1]\\:8080/:5678')
expect(coercePath('[::1]:8080/:360/:180')).toEqual(
'[\\:\\:1]\\:8080/:360/:180',
)
})

test('replaces wildcard with an unnnamed capturing group', () => {
Expand Down
39 changes: 31 additions & 8 deletions src/utils/matching/matchRequestUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,39 @@ export function coercePath(path: string): string {
},
)
/**
* Escape the port so that "path-to-regexp" can match
* absolute URLs including port numbers.
*/
.replace(/([^\/])(:)(?=\d+)/, '$1\\$2')
/**
* Escape the protocol so that "path-to-regexp" could match
* absolute URL.
* Escape any `:` character in the SCHEME, PORT, and HOST (if bracketed IPv6)
* This allows `path-to-regexp` to match absolute URL including port numbers.
* @see https://github.com/pillarjs/path-to-regexp/issues/259
*
* Example bracketed IPv6 replacement (any `:` in HOST is escaped):
* ```
* http://[::1]:3000/:aaa/:bbb
* http\://[\:\:1]\:3000/:aaa/:bbb
*```
*
* Example non-bracketed replacement (any `:` in HOST is _not_ escaped)
* ```
* http://:subdomain.example.com:3000/:aaa/:bbb
* http\://:subdomain.example.com\:3000/:aaa/:bbb
* ```
*
* Scheme, port, and path are optional, but address is always expected
* to exist (e.g. `http://` is invalid, but `http://:foobar` is OK)
*/
.replace(/^([^\/]+)(:)(?=\/\/)/, '$1\\$2')
.replace(
/(?<scheme>^(?:\/\/|(?:[^\/\s]+)(?::)(?=\/\/)\/\/))?(?<addr>(?<bracketed>\[(?<ip6addr>[0-9a-f:]+?)\])|(?<unbracketed>[^\/\[\]\s]+)(?<!:[0-9]{1,5}))(?=\/|\:[0-9]{1,5}|$)(?<port>:[0-9]{1,5})?(?<path>\/$|(?:\/[^\/\s]+)+?$)?$/,
(_substring, ...args) => {
const namedGroups = args.slice(-1)[0]
const { bracketed, path, port, scheme, unbracketed } = namedGroups

return [
scheme?.replace(/:/, '\\:') ?? '',
bracketed?.replace(/:/g, '\\:') ?? unbracketed,
port?.replace(/^:/, '\\:') ?? '',
path ?? '',
].join('')
},
)
)
}

Expand Down

0 comments on commit b57db77

Please sign in to comment.