fix(19.7): RB-IN-01 the My account URL check refuses control characters
Browsers strip tab and newline from URLs, so "/\t/host" passed the SPA's same-origin check and resolved off-site. The check now refuses any ASCII control character first, as Go's url.Parse does on the server, and an absolute URL must parse with a host, so both sides apply one rule.
This commit is contained in:
@@ -23,14 +23,23 @@ const running = ref('')
|
||||
const links = ref<Record<string, string>>({})
|
||||
|
||||
/**
|
||||
* The URL when it may be rendered and opened: an absolute http(s) URL or a
|
||||
* same-origin path (one leading "/"), else ''. The server applies the same
|
||||
* rule; this keeps a javascript: or data: URL out of href and window.open
|
||||
* even if one gets through.
|
||||
* The URL when it may be rendered and opened: an absolute http(s) URL with a
|
||||
* host or a same-origin path (one leading "/"), else ''. The server applies
|
||||
* the same rule (Go's url.Parse refuses control characters); this keeps a
|
||||
* javascript: or data: URL out of href and window.open even if one gets
|
||||
* through. A control character is refused first: browsers strip tab and
|
||||
* newline, so "/\t/host" would otherwise resolve to //host, off-site.
|
||||
*/
|
||||
function safeUrl(u: string): string {
|
||||
if (/[\u0000-\u001f\u007f]/.test(u)) {
|
||||
return ''
|
||||
}
|
||||
if (/^https?:\/\//i.test(u)) {
|
||||
return u
|
||||
try {
|
||||
return new URL(u).host !== '' ? u : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
if (u.startsWith('/') && !u.startsWith('//') && !u.startsWith('/\\')) {
|
||||
return u
|
||||
|
||||
@@ -157,6 +157,21 @@ describe('My account contributions', () => {
|
||||
expect(toasts.value.map((toast) => toast.text)).toEqual(['Done.'])
|
||||
})
|
||||
|
||||
// RB-IN-01: browsers strip tab and newline, so these resolve off-site.
|
||||
for (const url of ['/\t/evil.example/x', '/\n/evil.example', '/\r\n/evil.example', '/x\u0000', 'https://']) {
|
||||
it(`a ${JSON.stringify(url)} URL answer renders no link and never opens a window`, async () => {
|
||||
const opened = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const { wrapper } = await openAccount({
|
||||
[`POST ${OPEN}`]: envelope({ message: 'Done.', url }),
|
||||
})
|
||||
await wrapper.find('[data-account-contribution-action="open"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(opened).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('[data-account-contribution-url]').exists()).toBe(false)
|
||||
expect(wrapper.html()).not.toContain('evil.example')
|
||||
})
|
||||
}
|
||||
|
||||
it('a section with error: true shows the section error text', async () => {
|
||||
setBundle({ ...langFixture.data, ...strings, 'backend::lang.myaccount.section_error': { other: 'This section could not be loaded.' } }, 'en')
|
||||
const { wrapper } = await openAccount({
|
||||
|
||||
Reference in New Issue
Block a user