fix(auth): secure white-label invite and reset links (#1680)
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildPasswordResetRedirectTo,
|
||||
getCanonicalAppOrigin,
|
||||
resolveRequestAppOrigin,
|
||||
resolveTrustedAppOrigin,
|
||||
} from '../trusted-app-origin'
|
||||
|
||||
const ORIGINAL_APP_URL = process.env.NEXT_PUBLIC_APP_URL
|
||||
const ORIGINAL_WHITELABEL_DOMAINS = process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
|
||||
|
||||
describe('trusted application origins', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test'
|
||||
delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_APP_URL === undefined) delete process.env.NEXT_PUBLIC_APP_URL
|
||||
else process.env.NEXT_PUBLIC_APP_URL = ORIGINAL_APP_URL
|
||||
|
||||
if (ORIGINAL_WHITELABEL_DOMAINS === undefined) {
|
||||
delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
|
||||
} else {
|
||||
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = ORIGINAL_WHITELABEL_DOMAINS
|
||||
}
|
||||
})
|
||||
|
||||
it('uses an exact registered white-label host over HTTPS', () => {
|
||||
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test, books.partner.test'
|
||||
|
||||
expect(resolveTrustedAppOrigin('https://portal.brand.test')).toBe(
|
||||
'https://portal.brand.test',
|
||||
)
|
||||
expect(resolveTrustedAppOrigin('PORTAL.BRAND.TEST.')).toBe(
|
||||
'https://portal.brand.test',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects spoofed, credential, wildcard, and non-default-port hosts', () => {
|
||||
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test,*.wildcard.test'
|
||||
|
||||
for (const candidate of [
|
||||
'https://portal.brand.test.attacker.test',
|
||||
'https://portal.brand.test@attacker.test',
|
||||
'https://child.wildcard.test',
|
||||
'https://portal.brand.test:444',
|
||||
]) {
|
||||
expect(resolveTrustedAppOrigin(candidate), candidate).toBe(
|
||||
'https://app.accounted.test',
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the canonical origin when the request host is not registered', () => {
|
||||
expect(resolveTrustedAppOrigin('https://unregistered.test')).toBe(
|
||||
'https://app.accounted.test',
|
||||
)
|
||||
expect(resolveTrustedAppOrigin(null)).toBe('https://app.accounted.test')
|
||||
})
|
||||
|
||||
it('normalises the canonical URL to its origin and has a local safe fallback', () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test/base?ignored=yes'
|
||||
expect(getCanonicalAppOrigin()).toBe('https://app.accounted.test')
|
||||
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'javascript:alert(1)'
|
||||
expect(getCanonicalAppOrigin()).toBe('http://localhost:3000')
|
||||
})
|
||||
|
||||
it('validates the request URL and ignores a spoofed forwarded host', () => {
|
||||
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test'
|
||||
|
||||
const trusted = new Request('https://portal.brand.test/api/company/members/invite', {
|
||||
headers: { 'x-forwarded-host': 'attacker.test' },
|
||||
})
|
||||
const spoofed = new Request('https://attacker.test/api/company/members/invite', {
|
||||
headers: { 'x-forwarded-host': 'portal.brand.test' },
|
||||
})
|
||||
|
||||
expect(resolveRequestAppOrigin(trusted)).toBe('https://portal.brand.test')
|
||||
expect(resolveRequestAppOrigin(spoofed)).toBe('https://app.accounted.test')
|
||||
})
|
||||
})
|
||||
|
||||
describe('password reset callback', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test'
|
||||
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_APP_URL === undefined) delete process.env.NEXT_PUBLIC_APP_URL
|
||||
else process.env.NEXT_PUBLIC_APP_URL = ORIGINAL_APP_URL
|
||||
|
||||
if (ORIGINAL_WHITELABEL_DOMAINS === undefined) {
|
||||
delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
|
||||
} else {
|
||||
process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = ORIGINAL_WHITELABEL_DOMAINS
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a registered brand callback on the brand domain', () => {
|
||||
expect(buildPasswordResetRedirectTo('https://portal.brand.test')).toBe(
|
||||
'https://portal.brand.test/auth/callback?next=/reset-password',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the allowlisted canonical callback for an unknown browser origin', () => {
|
||||
expect(buildPasswordResetRedirectTo('https://attacker.test')).toBe(
|
||||
'https://app.accounted.test/auth/callback?next=/reset-password',
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user