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>
This commit is contained in:
Jakob Wennberg
2026-09-01 14:07:34 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 57d757192e
commit 21e6e2d314
6 changed files with 424 additions and 63 deletions
@@ -2,21 +2,75 @@ import { describe, expect, it } from 'vitest'
import { usesForbiddenWhiteLabelBackend } from '../production-white-label-backend'
const STAGING_URL = 'https://metjnjrhvujscngnpzdv.supabase.co'
const THIRD_PROJECT_URL = 'https://qqqqqqqqqqqqqqqqqqqq.supabase.co'
const PRODUCTION_URL = 'https://pwxtzglxptnnvjrpixpg.supabase.co'
// app.gnubok.se is the one entry that the hosted-namespace rule cannot derive:
// it is Accounted's legacy canonical host, live on production today, and it
// only stays protected while it is in the approved inventory.
const APPROVED_PRODUCTION_HOSTS = [
'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',
]
describe('production white-label backend guard', () => {
it.each(APPROVED_PRODUCTION_HOSTS)(
'blocks %s when it uses the staging project',
hostname => {
expect(usesForbiddenWhiteLabelBackend(hostname, STAGING_URL)).toBe(true)
},
)
it.each(APPROVED_PRODUCTION_HOSTS)(
'serves %s from the production project',
hostname => {
expect(usesForbiddenWhiteLabelBackend(hostname, PRODUCTION_URL)).toBe(
false,
)
},
)
// The 2026-08-26 incident: a preview build wired to staging answered
// improveone.accounted.se, a customer host that was not on the protected
// list. Nothing inside the hosted namespace needs listing any more.
it.each([
'acount.accounted.se',
'arbore.accounted.se',
'elma.accounted.se',
'm360.accounted.se',
'redovisningskompaniet.accounted.se',
'willem.accounted.se',
'ziffr.accounted.se',
])('blocks %s when it uses the staging project', hostname => {
'app.accounted.se',
'accounted.se',
'improveone.accounted.se',
'notacount.accounted.se',
'a-byra-that-does-not-exist-yet.accounted.se',
])('blocks the unlisted hosted host %s on the staging project', hostname => {
expect(usesForbiddenWhiteLabelBackend(hostname, STAGING_URL)).toBe(true)
})
it('blocks a third project it has never heard of', () => {
expect(
usesForbiddenWhiteLabelBackend('willem.accounted.se', THIRD_PROJECT_URL),
).toBe(true)
})
// Fail closed, not open: an env-less build that reaches updateSession throws
// straight out of the Web Handler and 500s every path instead.
it.each([
undefined,
'',
'not a URL',
'__NEXT_PUBLIC_SUPABASE_URL__',
'https://pwxtzglxptnnvjrpixpg.supabase.co.attacker.test',
])('blocks a production host on the unusable backend %s', url => {
expect(usesForbiddenWhiteLabelBackend('acount.accounted.se', url)).toBe(
true,
)
})
it('normalizes case and a trailing dot before the exact host checks', () => {
expect(
usesForbiddenWhiteLabelBackend(
@@ -24,32 +78,33 @@ describe('production white-label backend guard', () => {
'https://METJNJRHVUJSCNGNPZDV.SUPABASE.CO./rest/v1',
),
).toBe(true)
})
it.each([
'app.accounted.se',
'accounted.se',
'preview.vercel.app',
'acount.accounted.se.attacker.test',
'notacount.accounted.se',
])('does not extend the production classification to %s', hostname => {
expect(usesForbiddenWhiteLabelBackend(hostname, STAGING_URL)).toBe(false)
})
it('allows a customer production host to use a different backend', () => {
expect(
usesForbiddenWhiteLabelBackend('acount.accounted.se', PRODUCTION_URL),
usesForbiddenWhiteLabelBackend(
'ACOUNT.ACCOUNTED.SE.',
'https://PWXTZGLXPTNNVJRPIXPG.SUPABASE.CO./rest/v1',
),
).toBe(false)
})
it.each([
undefined,
'',
'not a URL',
'https://metjnjrhvujscngnpzdv.supabase.co.attacker.test',
])('does not mistake an unrecognized backend for the staging project', url => {
expect(usesForbiddenWhiteLabelBackend('acount.accounted.se', url)).toBe(
false,
)
'erp-base-git-add-white-label-infra.vercel.app',
'localhost',
'127.0.0.1',
'[::1]',
'app.localhost',
'accounted.test',
'acount.accounted.se.attacker.test',
])('leaves the preview or local host %s alone', hostname => {
expect(usesForbiddenWhiteLabelBackend(hostname, STAGING_URL)).toBe(false)
})
// A customer that brings its own domain is not derivable from the hosted
// namespace, so it stays out of scope until it is classified in the approved
// host inventory. Self-hosted deployments depend on exactly that: their own
// backend on their own domain has to keep working.
it('does not classify a domain outside the hosted namespace', () => {
expect(
usesForbiddenWhiteLabelBackend('demo.partner-brand.se', STAGING_URL),
).toBe(false)
})
})
+92 -8
View File
@@ -1,18 +1,62 @@
// 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',
])
const FORBIDDEN_STAGING_SUPABASE_HOST =
'metjnjrhvujscngnpzdv.supabase.co'
// 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(/\.$/, '')
@@ -28,17 +72,57 @@ function parseBackendHostname(supabaseUrl: string | undefined): string | 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}`)
)
}
/**
* Block Accounted's customer-facing white-label hosts from using the staging
* Supabase project. The request host and backend host are exact matches: this
* is an environment safety boundary, not suffix-based domain authorization.
* 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 {
const hostname = normalizeHostname(requestHostname)
if (!CUSTOMER_PRODUCTION_WHITE_LABEL_HOSTS.has(hostname)) return false
if (!requiresProductionBackend(requestHostname)) return false
return parseBackendHostname(supabaseUrl) === FORBIDDEN_STAGING_SUPABASE_HOST
return parseBackendHostname(supabaseUrl) !== PRODUCTION_SUPABASE_HOST
}