Skip to content
This repository has been archived by the owner on Nov 10, 2023. It is now read-only.

feat: Sanitize iframe URL #3809

Merged
merged 4 commits into from
Apr 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/logic/hooks/useSafeAppUrl.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useLocation } from 'react-router-dom'
import { useCallback } from 'react'
import { sanitizeUrl } from 'src/utils/sanitizeUrl'

type AppUrlReturnType = {
getAppUrl: () => string | null
Expand All @@ -10,7 +11,13 @@ export const useSafeAppUrl = (): AppUrlReturnType => {

const getAppUrl = useCallback(() => {
const query = new URLSearchParams(search)
return query.get('appUrl')
try {
const url = query.get('appUrl')

return sanitizeUrl(url)
} catch {
throw new Error('Detected javascript injection in the URL. Check the appUrl parameter')
}
}, [search])

return {
Expand Down
133 changes: 133 additions & 0 deletions src/utils/__tests__/sanitizeUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { sanitizeUrl } from '../sanitizeUrl'

describe('sanitizeUrl', () => {
it('does not alter http URLs with alphanumeric characters', () => {
expect(sanitizeUrl('http://example.com/path/to:something')).toBe('http://example.com/path/to:something')
})

it('does not alter http URLs with ports with alphanumeric characters', () => {
expect(sanitizeUrl('http://example.com:4567/path/to:something')).toBe('http://example.com:4567/path/to:something')
})

it('does not alter https URLs with alphanumeric characters', () => {
expect(sanitizeUrl('https://example.com')).toBe('https://example.com')
})

it('does not alter https URLs with ports with alphanumeric characters', () => {
expect(sanitizeUrl('https://example.com:4567/path/to:something')).toBe('https://example.com:4567/path/to:something')
})

it('does not alter relative-path reference URLs with alphanumeric characters', () => {
expect(sanitizeUrl('./path/to/my.json')).toBe('./path/to/my.json')
})

it('does not alter absolute-path reference URLs with alphanumeric characters', () => {
expect(sanitizeUrl('/path/to/my.json')).toBe('/path/to/my.json')
})

it('does not alter protocol-less network-path URLs with alphanumeric characters', () => {
expect(sanitizeUrl('//google.com/robots.txt')).toBe('//google.com/robots.txt')
})

it('does not alter protocol-less URLs with alphanumeric characters', () => {
expect(sanitizeUrl('www.example.com')).toBe('www.example.com')
})

it('does not alter deep-link urls with alphanumeric characters', () => {
expect(sanitizeUrl('com.braintreepayments.demo://example')).toBe('com.braintreepayments.demo://example')
})

it('does not alter mailto urls with alphanumeric characters', () => {
expect(sanitizeUrl('mailto:test@example.com?subject=hello+world')).toBe(
'mailto:test@example.com?subject=hello+world',
)
})

it('does not alter urls with accented characters', () => {
expect(sanitizeUrl('www.example.com/with-áccêntš')).toBe('www.example.com/with-áccêntš')
})

it('does not strip harmless unicode characters', () => {
expect(sanitizeUrl('www.example.com/лот.рфшишкиü–')).toBe('www.example.com/лот.рфшишкиü–')
})

it('strips out ctrl chars', () => {
expect(sanitizeUrl('www.example.com/\u200D\u0000\u001F\x00\x1F\uFEFFfoo')).toBe('www.example.com/foo')
})

it('replaces blank urls with an empty space', () => {
expect(sanitizeUrl('')).toBe('')
})

it('replaces null values with an empty space', () => {
// @ts-ignore
expect(sanitizeUrl(null)).toBe('')
})

it('removes whitespace from urls', () => {
expect(sanitizeUrl(' http://example.com/path/to:something ')).toBe('http://example.com/path/to:something')
})

describe('invalid protocols', () => {
describe.each(['javascript', 'data', 'vbscript'])('%s', (protocol) => {
it(`replaces ${protocol} urls with an empty space`, () => {
expect(() => sanitizeUrl(`${protocol}:alert(document.domain)`)).toThrow(/invalid protocol/i)
})

it(`allows ${protocol} urls that start with a letter prefix`, () => {
expect(sanitizeUrl(`not_${protocol}:alert(document.domain)`)).toBe(`not_${protocol}:alert(document.domain)`)
})

it(`disallows ${protocol} urls that start with non-\w characters as a suffix for the protocol`, () => {
expect(() => sanitizeUrl(`&!*${protocol}:alert(document.domain)`)).toThrow(/invalid protocol/i)
})

it(`disregards capitalization for ${protocol} urls`, () => {
// upper case every other letter in protocol name
const mixedCapitalizationProtocol = protocol
.split('')
.map((character, index) => {
if (index % 2 === 0) {
return character.toUpperCase()
}
return character
})
.join('')

expect(() => sanitizeUrl(`${mixedCapitalizationProtocol}:alert(document.domain)`)).toThrow(/invalid protocol/i)
})

it(`ignores invisible ctrl characters in ${protocol} urls`, () => {
const protocolWithControlCharacters = protocol
.split('')
.map((character, index) => {
if (index === 1) {
return character + '%EF%BB%BF%EF%BB%BF'
} else if (index === 2) {
return character + '%e2%80%8b'
}
return character
})
.join('')

expect(() =>
sanitizeUrl(decodeURIComponent(`${protocolWithControlCharacters}:alert(document.domain)`)),
).toThrow(/invalid protocol/i)
})

it(`replaces ${protocol} urls with empty space when url begins with %20`, () => {
expect(() => sanitizeUrl(decodeURIComponent(`%20%20%20%20${protocol}:alert(document.domain)`))).toThrow(
/invalid protocol/i,
)
})

it(`replaces ${protocol} urls with empty space when ${protocol} url begins with an empty space`, () => {
expect(() => sanitizeUrl(` ${protocol}:alert(document.domain)`)).toThrow(/invalid protocol/i)
})

it(`does not replace ${protocol}: if it is not in the scheme of the URL`, () => {
expect(sanitizeUrl(`http://example.com#${protocol}:foo`)).toBe(`http://example.com#${protocol}:foo`)
})
})
})
})
34 changes: 34 additions & 0 deletions src/utils/sanitizeUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const invalidProtocolRegex = /^([^\w]*)(javascript|data|vbscript)/im
const ctrlCharactersRegex = /[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/gim
const urlSchemeRegex = /^([^:]+):/gm
const relativeFirstCharacters = ['.', '/']

function isRelativeUrlWithoutProtocol(url: string): boolean {
return relativeFirstCharacters.indexOf(url[0]) > -1
}

export function sanitizeUrl(url: string | null): string {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does it allow null as a param?

if (!url) {
return ''
}

const sanitizedUrl = url.replace(ctrlCharactersRegex, '').trim()

if (isRelativeUrlWithoutProtocol(sanitizedUrl)) {
return sanitizedUrl
}

const urlSchemeParseResults = sanitizedUrl.match(urlSchemeRegex)

if (!urlSchemeParseResults) {
return sanitizedUrl
}

const urlScheme = urlSchemeParseResults[0]

if (invalidProtocolRegex.test(urlScheme)) {
throw new Error('Invalid protocol')
}

return sanitizedUrl
}