21e6e2d314
The white-label guard only fired for hostnames hand-listed in CUSTOMER_PRODUCTION_WHITE_LABEL_HOSTS. improveone.accounted.se was never added, so when the .accounted.se domains were pinned to a feature-branch preview the guard let it through: a customer-branded login page served from a build that inlines the staging Supabase project, on the open internet, with no alert. The 26 August willem.accounted.se 503s were the same misrouting caught correctly, because willem was on the list. Inverts the model. Any customer-facing production hostname (not a *.vercel.app preview, not localhost) must be served by the production Supabase project or the guard trips. Adding a new white-label host no longer requires editing a list in order to be protected. Also closes two fail-open holes found alongside it. parseBackendHostname returned null for an undefined NEXT_PUBLIC_SUPABASE_URL, so a missing project read as "not staging" and fell through; it now trips the guard. And proxy.ts gains an explicit env guard: today lib/supabase/middleware.ts asserts the URL and key non-null and @supabase/ssr throws synchronously as the first statement of updateSessionInner, which takes down every path including /login and /robots.txt with an opaque crash rather than a deliberate 503. Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
129 lines
5.0 KiB
TypeScript
129 lines
5.0 KiB
TypeScript
// Accounted's hosted production identity, as checked-in configuration.
|
|
//
|
|
// HOSTED_PRODUCTION_NAMESPACE is the DNS zone the hosted product serves its
|
|
// customers from: app.accounted.se plus one <brand>.accounted.se host per
|
|
// white-label byra. PRODUCTION_SUPABASE_HOST is the only Supabase project
|
|
// those hosts may ever be served by.
|
|
//
|
|
// The guard asserts the backend instead of enumerating the hosts to protect.
|
|
// The first version did the opposite: it listed seven approved hostnames and
|
|
// compared the backend against the staging project by name. It then failed
|
|
// open on 2026-08-26, when a feature-branch preview wired to staging answered
|
|
// improveone.accounted.se, a customer host nobody had added to the list. An
|
|
// allowlist of protected hosts is only as current as the last rollout that
|
|
// remembered to update it, and a denylist naming one forbidden project cannot
|
|
// see a third project at all. Stating which project is production makes both
|
|
// classes of miss unreachable.
|
|
const HOSTED_PRODUCTION_NAMESPACE = 'accounted.se'
|
|
const PRODUCTION_SUPABASE_HOST = 'pwxtzglxptnnvjrpixpg.supabase.co'
|
|
|
|
// The approved customer-facing production hosts. Almost all of them already
|
|
// sit inside the namespace above, so this set is documentation first: it is
|
|
// the checked-in inventory the test suite pins host by host, and it is the
|
|
// only place a host outside the namespace can be classified, since such a
|
|
// hostname is not derivable from anything the deployment knows about itself.
|
|
// That covers Accounted's own legacy canonical host app.gnubok.se, which is
|
|
// live production today, and a customer that brings its own domain
|
|
// (docs/WHITELABEL.md step 1).
|
|
//
|
|
// This is an owner-approved production classification, not an auth callback
|
|
// allowlist. Do not derive it from NEXT_PUBLIC_WHITELABEL_DOMAINS, which can
|
|
// also contain demo, pilot, or self-hosted domains.
|
|
const CUSTOMER_PRODUCTION_WHITE_LABEL_HOSTS = new Set([
|
|
'acount.accounted.se',
|
|
'amnas.accounted.se',
|
|
'app.gnubok.se',
|
|
'arbore.accounted.se',
|
|
'elma.accounted.se',
|
|
'improveone.accounted.se',
|
|
'm360.accounted.se',
|
|
'redovisningskompaniet.accounted.se',
|
|
'willem.accounted.se',
|
|
'ziffr.accounted.se',
|
|
])
|
|
|
|
// Hosts that are never customer-facing: Vercel's per-deployment preview
|
|
// domains, and local or throwaway development names. Everything else inside
|
|
// the hosted namespace counts as production traffic, so a newly added brand
|
|
// host is protected by default rather than by being remembered. A host inside
|
|
// the namespace that is deliberately non-production has to be excluded here
|
|
// explicitly, in the same change that creates it.
|
|
const PREVIEW_HOST_SUFFIX = '.vercel.app'
|
|
const LOCAL_HOSTNAMES = new Set([
|
|
'localhost',
|
|
'127.0.0.1',
|
|
'0.0.0.0',
|
|
'::1',
|
|
'[::1]',
|
|
])
|
|
const LOCAL_HOST_SUFFIXES = ['.localhost', '.local', '.test']
|
|
|
|
function normalizeHostname(hostname: string): string {
|
|
return hostname.trim().toLowerCase().replace(/\.$/, '')
|
|
}
|
|
|
|
function parseBackendHostname(supabaseUrl: string | undefined): string | null {
|
|
if (!supabaseUrl) return null
|
|
|
|
try {
|
|
return normalizeHostname(new URL(supabaseUrl).hostname)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function isPreviewHostname(hostname: string): boolean {
|
|
return hostname.endsWith(PREVIEW_HOST_SUFFIX)
|
|
}
|
|
|
|
function isLocalHostname(hostname: string): boolean {
|
|
return (
|
|
LOCAL_HOSTNAMES.has(hostname) ||
|
|
LOCAL_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))
|
|
)
|
|
}
|
|
|
|
function isHostedNamespaceHostname(hostname: string): boolean {
|
|
return (
|
|
hostname === HOSTED_PRODUCTION_NAMESPACE ||
|
|
hostname.endsWith(`.${HOSTED_PRODUCTION_NAMESPACE}`)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Whether a request host is customer-facing production traffic, and so may be
|
|
* served only by the production Supabase project.
|
|
*
|
|
* Hosts outside the hosted namespace stay out of scope unless they are an
|
|
* approved customer domain: a self-hosted deployment runs its own backend on
|
|
* its own domain, and demanding Accounted's project there would brick it.
|
|
*/
|
|
function requiresProductionBackend(requestHostname: string): boolean {
|
|
const hostname = normalizeHostname(requestHostname)
|
|
if (isPreviewHostname(hostname) || isLocalHostname(hostname)) return false
|
|
|
|
return (
|
|
isHostedNamespaceHostname(hostname) ||
|
|
CUSTOMER_PRODUCTION_WHITE_LABEL_HOSTS.has(hostname)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Block Accounted's customer-facing production hosts from any backend that is
|
|
* not the production Supabase project. The backend host is an exact match:
|
|
* this is an environment safety boundary, not suffix-based domain
|
|
* authorization.
|
|
*
|
|
* A missing, empty or unparseable backend URL counts as not production. Such a
|
|
* build cannot serve a customer host either way: it would otherwise reach
|
|
* updateSession and throw straight out of the Web Handler on every path.
|
|
*/
|
|
export function usesForbiddenWhiteLabelBackend(
|
|
requestHostname: string,
|
|
supabaseUrl: string | undefined,
|
|
): boolean {
|
|
if (!requiresProductionBackend(requestHostname)) return false
|
|
|
|
return parseBackendHostname(supabaseUrl) !== PRODUCTION_SUPABASE_HOST
|
|
}
|