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>>({})
|
const links = ref<Record<string, string>>({})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The URL when it may be rendered and opened: an absolute http(s) URL or a
|
* The URL when it may be rendered and opened: an absolute http(s) URL with a
|
||||||
* same-origin path (one leading "/"), else ''. The server applies the same
|
* host or a same-origin path (one leading "/"), else ''. The server applies
|
||||||
* rule; this keeps a javascript: or data: URL out of href and window.open
|
* the same rule (Go's url.Parse refuses control characters); this keeps a
|
||||||
* even if one gets through.
|
* 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 {
|
function safeUrl(u: string): string {
|
||||||
|
if (/[\u0000-\u001f\u007f]/.test(u)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
if (/^https?:\/\//i.test(u)) {
|
if (/^https?:\/\//i.test(u)) {
|
||||||
return u
|
try {
|
||||||
|
return new URL(u).host !== '' ? u : ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (u.startsWith('/') && !u.startsWith('//') && !u.startsWith('/\\')) {
|
if (u.startsWith('/') && !u.startsWith('//') && !u.startsWith('/\\')) {
|
||||||
return u
|
return u
|
||||||
|
|||||||
@@ -157,6 +157,21 @@ describe('My account contributions', () => {
|
|||||||
expect(toasts.value.map((toast) => toast.text)).toEqual(['Done.'])
|
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 () => {
|
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')
|
setBundle({ ...langFixture.data, ...strings, 'backend::lang.myaccount.section_error': { other: 'This section could not be loaded.' } }, 'en')
|
||||||
const { wrapper } = await openAccount({
|
const { wrapper } = await openAccount({
|
||||||
|
|||||||
Reference in New Issue
Block a user