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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
const LOCAL_APP_ORIGIN = 'http://localhost:3000'
|
||||
|
||||
interface ParsedHost {
|
||||
hostname: string
|
||||
port: string
|
||||
}
|
||||
|
||||
function normalizeHostname(hostname: string): string {
|
||||
return hostname.toLowerCase().replace(/\.$/, '')
|
||||
}
|
||||
|
||||
function parseHttpOrigin(value: string | undefined): URL | null {
|
||||
if (!value) return null
|
||||
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (!['http:', 'https:'].includes(url.protocol)) return null
|
||||
if (url.username || url.password) return null
|
||||
return url
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseHost(value: string | null | undefined): ParsedHost | null {
|
||||
if (!value) return null
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
try {
|
||||
const url = parseHttpOrigin(
|
||||
trimmed.includes('://') ? trimmed : `https://${trimmed}`,
|
||||
)
|
||||
if (!url) return null
|
||||
if (url.pathname !== '/' || url.search || url.hash) return null
|
||||
|
||||
return {
|
||||
hostname: normalizeHostname(url.hostname),
|
||||
port: url.port,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function registeredWhiteLabelHosts(): Set<string> {
|
||||
const configured = process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS
|
||||
if (!configured) return new Set()
|
||||
|
||||
const hosts = configured
|
||||
.split(',')
|
||||
.map((value) => parseHost(value))
|
||||
.filter((value): value is ParsedHost => value !== null && value.port === '')
|
||||
.map(({ hostname }) => hostname)
|
||||
|
||||
return new Set(hosts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the configured canonical application origin.
|
||||
*
|
||||
* Paths, queries, and fragments in NEXT_PUBLIC_APP_URL are deliberately
|
||||
* discarded so callers cannot accidentally append auth paths below them.
|
||||
*/
|
||||
export function getCanonicalAppOrigin(): string {
|
||||
const configured = parseHttpOrigin(process.env.NEXT_PUBLIC_APP_URL)
|
||||
return configured?.origin ?? LOCAL_APP_ORIGIN
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a browser origin or request host to an application origin.
|
||||
*
|
||||
* The canonical app host is always trusted. Additional hosts must be exact
|
||||
* entries in NEXT_PUBLIC_WHITELABEL_DOMAINS. Wildcards and suffix matching are
|
||||
* intentionally unsupported: auth links may never follow an attacker-chosen
|
||||
* Host header. Registered white-label domains are always upgraded to HTTPS.
|
||||
*/
|
||||
export function resolveTrustedAppOrigin(candidate: string | null | undefined): string {
|
||||
const canonicalOrigin = getCanonicalAppOrigin()
|
||||
const canonical = new URL(canonicalOrigin)
|
||||
const parsed = parseHost(candidate)
|
||||
|
||||
if (!parsed) return canonicalOrigin
|
||||
|
||||
if (parsed.hostname === normalizeHostname(canonical.hostname)) {
|
||||
return canonicalOrigin
|
||||
}
|
||||
|
||||
if (!registeredWhiteLabelHosts().has(parsed.hostname)) {
|
||||
return canonicalOrigin
|
||||
}
|
||||
|
||||
// A non-default port is not a registered hosted domain, even when its
|
||||
// hostname matches. URL normalisation represents :443 as an empty port.
|
||||
if (parsed.port !== '') return canonicalOrigin
|
||||
|
||||
return `https://${parsed.hostname}`
|
||||
}
|
||||
|
||||
/** Resolve an API request to a trusted application origin. */
|
||||
export function resolveRequestAppOrigin(request: Request): string {
|
||||
const requestOrigin = parseHttpOrigin(request.url)?.origin
|
||||
return resolveTrustedAppOrigin(requestOrigin)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a GoTrue password recovery callback on a registered application host.
|
||||
* Unknown browser origins fall back to the canonical application URL.
|
||||
*/
|
||||
export function buildPasswordResetRedirectTo(browserOrigin: string): string {
|
||||
return `${resolveTrustedAppOrigin(browserOrigin)}/auth/callback?next=/reset-password`
|
||||
}
|
||||
Reference in New Issue
Block a user