fix(auth): secure white-label invite and reset links (#1680)

This commit is contained in:
Mattsson
2026-08-18 23:17:26 +02:00
committed by GitHub
parent 3ec76d39db
commit dfa7097f3a
9 changed files with 362 additions and 9 deletions
@@ -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',
)
})
})
+113
View File
@@ -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`
}