Files
accounted/lib/domains/production-white-label-backend.ts
T
Jakob Wennberg 21e6e2d314 fix(proxy): assert the production Supabase project for customer hosts instead of allowlisting them (#2107)
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>
2026-09-01 14:07:34 +02:00

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
}