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:
@@ -1427,6 +1427,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-01] multi_user skeptic fixes: Stripe cancel EXPIRES the multi_user stripe grant instead of deleting it (grace anchor; other grants still deleted per freeze-and-retain); app-side state checks go RPC-first via SECURITY DEFINER company_multi_user_state (capability_grants RLS hides team rows from non-team users, byrå clients would misread as frozen); byra-kind teams get a standing team-scoped multi_user grant via backfill + teams trigger (WL-10 assumption made real; partner billing is out-of-band); PGRST202 on resolution fails OPEN (pre-migration DB has no multi_user rows: gated fallback would freeze all non-owners); /api/v1 got the same dormancy gate as MCP. RLS-level enforcement and the mid-session API fallback write-back window stay v2 follow-ups (documented, same class as pre-existing stale-preference fallback).
|
||||
[2026-09-01] Declined CodeRabbit's UpgradeNote suggestion (PR #1758 follow-up) to append the self-host connector sentence to children instead of replacing them: every caller's children is hosted subscription copy ("... kräver ett abonnemang"), so appending would show subscription wording on a self-host, the exact thing the branch exists to avoid; the "CSV/SIE import stays free" text it cited is a code comment in BankSyncNowButton, not children. Replace-on-self-host stays; a dedicated selfHosted children prop can come when a caller actually needs per-panel reassurance there.
|
||||
[2026-09-01] getConnectorConfig() rebuilds baseUrl as origin + path (userinfo/query/fragment stripped, warn-logged without the raw value): /api/connector/status echoes baseUrl to the operator and the sync/proxy URLs get paths appended, so nothing secret-shaped pasted into GNUBOK_CONNECT_URL may survive; the stripped parts were never meaningful in a base URL. The status route is also Cache-Control: no-store (key prefix + wiring layout out of shared browser caches).
|
||||
[2026-09-01] White-label backend guard inverted from an allowlist of protected hosts to an assertion that any customer-facing production host is served by the production Supabase project. This reverses the earlier explicit-allowlist decision: that model failed open for improveone.accounted.se, which was serving a byra login page wired to the staging project with no alert, because it was never added to the list.
|
||||
[2026-09-01] Anon-callable SECURITY DEFINER writes: the guard shape `IF auth.uid() IS NOT NULL AND NOT EXISTS (membership)` is unsafe on its own. The anon JWT carries no `sub` claim, so auth.uid() is NULL for role anon too and the guard short-circuits into the trusted branch. It is defense in depth behind a REVOKE FROM PUBLIC, anon, never a substitute for one. Every new SECURITY DEFINER function ships with that REVOKE; tests/pg/definer-function-grants.pg.test.ts enforces it from a sweep rather than a hand list, because hand-listing is exactly how three guarded numbering RPCs were wrongly declared safe.
|
||||
[2026-09-01] public.create_invoice_with_items(jsonb,jsonb) is revoked, not dropped. It is prod-only, uncalled and already non-functional, so removing it is cleanup rather than security, and the revoke closes the hole in full. An irreversible schema deletion against production belongs in its own reviewable migration.
|
||||
[2026-09-01] SKV connector refresh classification fixed broker-side (skeptic refutation on PR #2103, found independently by two skeptics): the broker's /oauth/token catch-all had collapsed SKV's terminal dead-refresh-token dialects (404 id_not_found, 400 invalid_grant, "Refresh Token status is expired": the DOMINANT refresh outcome, per-flow tokens live 65 min) into the generic 502, so a connector instance could never classify ordinary session expiry: raw English 500s instead of the reconnect flow, staged filing ops consumed as non-recoverable, crons retrying raw forever. The broker now re-codes those dialects (refresh grant only, never the code exchange where invalid_grant means an expired one-shot code) as 401 CONNECTOR_SKV_REFRESH_DEAD, which the instance maps to SESSION_EXPIRED; the generic 502 remains raw so a transient SKV outage still never re-arms the reconnect banner (#1155). Same pass: the data proxy now forwards WWW-Authenticate + x-skv-*/x-amzn-*/x-api-* response headers (the instance's MISSING_SCOPE classification reads them; nothing secret rides in them), and the instance's gateway-refusal guidance is connector-aware (a self-host has no SKATTEVERKET_APIGW_CLIENT_ID or Utvecklarportalen access: point at /api/connector/status + support instead).
|
||||
|
||||
+6
-2
@@ -109,9 +109,13 @@ A few things that look brand-related but are configured elsewhere:
|
||||
|
||||
Use this checklist when several white-label domains point at one hosted Accounted deployment:
|
||||
|
||||
Accounted operates these customer-facing production hosts: `acount.accounted.se`, `arbore.accounted.se`, `elma.accounted.se`, `m360.accounted.se`, `redovisningskompaniet.accounted.se`, `willem.accounted.se`, and `ziffr.accounted.se`. They must never use the staging Supabase project `metjnjrhvujscngnpzdv`. The request proxy emits an alerting structured error, then returns an empty, non-cacheable `503` before session handling when that exact production-host and staging-project pairing is detected. The event records only the approved hostname and the `staging` classification, not the configured backend URL or credentials. This containment guard does not classify other domains, prove cross-tenant isolation, or replace the operational work to place customer environments under production ownership and controls.
|
||||
Accounted's hosted product serves its customers from the `accounted.se` zone: `app.accounted.se` plus one `<brand>.accounted.se` host per white-label byra. Every one of those hosts must be served by the production Supabase project `pwxtzglxptnnvjrpixpg`. The request proxy asserts that pairing instead of enumerating the hosts to protect: when the build answering a customer-facing production host is wired to any other backend (the staging project, a third project, or a URL it cannot parse), it emits an alerting structured error and returns an empty, non-cacheable `503` before session handling. The event records only the hostname and the `non_production` classification, never the configured backend URL or credentials.
|
||||
|
||||
`NEXT_PUBLIC_WHITELABEL_DOMAINS` is an auth callback allowlist, not an authoritative customer-production inventory. It can also contain demo, pilot, or self-hosted domains, so the staging-backend guard deliberately does not derive production status from it. When Accounted approves another customer-facing production host, add it to `CUSTOMER_PRODUCTION_WHITE_LABEL_HOSTS` in `lib/domains/production-white-label-backend.ts` as part of the same reviewed rollout.
|
||||
Because the rule is stated as "this namespace belongs to the production project", a newly launched `<brand>.accounted.se` host is protected as soon as it resolves. There is no list to remember to update. The first version of the guard did the opposite: it enumerated seven approved hostnames, and on 2026-08-26 it failed open on `improveone.accounted.se`, a customer host nobody had added, which a feature-branch preview served from the staging project for hours. Vercel preview domains (`*.vercel.app`) and local development names stay out of scope. A host inside the namespace that is deliberately not production has to be excluded explicitly in `lib/domains/production-white-label-backend.ts`, in the same change that creates it.
|
||||
|
||||
Two kinds of host are not derivable from the namespace, so they are still classified by hand in `CUSTOMER_PRODUCTION_WHITE_LABEL_HOSTS` (`lib/domains/production-white-label-backend.ts`): Accounted's legacy canonical host `app.gnubok.se`, and a customer that brings its own domain (step 1 below). Add those as part of the same reviewed rollout. The set also still lists the `accounted.se` hosts the namespace rule already covers: there they are a checked-in inventory the tests pin host by host, not what makes those hosts protected. Do not derive the set from `NEXT_PUBLIC_WHITELABEL_DOMAINS`: that variable is an auth callback allowlist, not an authoritative production inventory, and it can also contain demo, pilot, or self-hosted domains.
|
||||
|
||||
The guard contains a misrouted deployment. It does not classify domains outside the hosted namespace, prove cross-tenant isolation, or replace the operational work of placing customer environments under production ownership and controls. Its `alert: true` flag also pages nobody on its own: middleware never registers the observability sink, so the alerting rule is configured on the hosting side and matches `operation=white_label_backend_guard` in the emitted log line.
|
||||
|
||||
1. Register the exact custom hostname on the hosting deployment and finish its DNS verification.
|
||||
2. Add that hostname to the comma-separated `NEXT_PUBLIC_WHITELABEL_DOMAINS` value. Entries are exact hostnames such as `portal.partner.se`; wildcard entries are ignored.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+150
-11
@@ -18,11 +18,104 @@ import { config, proxy } from './proxy'
|
||||
|
||||
const STAGING_URL = 'https://metjnjrhvujscngnpzdv.supabase.co'
|
||||
const PRODUCTION_URL = 'https://pwxtzglxptnnvjrpixpg.supabase.co'
|
||||
const ANON_KEY = 'anon-key'
|
||||
|
||||
// Both vars are stubbed explicitly in every suite below. The unit project has
|
||||
// no setup file and loads no dotenv, so leaning on a developer's exported
|
||||
// shell environment is a test that passes locally and fails in CI.
|
||||
function stubConfiguredEnvironment(supabaseUrl: string): void {
|
||||
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', supabaseUrl)
|
||||
vi.stubEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY', ANON_KEY)
|
||||
}
|
||||
|
||||
describe('supabase environment proxy guard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
stubConfiguredEnvironment(PRODUCTION_URL)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('returns a non-cacheable empty 503 when the Supabase URL is missing', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', undefined)
|
||||
|
||||
const response = await proxy(new NextRequest('https://app.accounted.se/login'))
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(response.headers.get('cache-control')).toBe('no-store')
|
||||
expect(await response.text()).toBe('')
|
||||
expect(updateSessionMock).not.toHaveBeenCalled()
|
||||
expect(loggerErrorMock).toHaveBeenCalledOnce()
|
||||
expect(loggerErrorMock).toHaveBeenCalledWith(
|
||||
'Refused request without Supabase configuration',
|
||||
{
|
||||
alert: true,
|
||||
operation: 'supabase_env_missing',
|
||||
requestHostname: 'app.accounted.se',
|
||||
missing: ['NEXT_PUBLIC_SUPABASE_URL'],
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the same 503 when the anon key is missing', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY', '')
|
||||
|
||||
const response = await proxy(new NextRequest('https://app.accounted.se/robots.txt'))
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(updateSessionMock).not.toHaveBeenCalled()
|
||||
expect(loggerErrorMock).toHaveBeenCalledWith(
|
||||
'Refused request without Supabase configuration',
|
||||
expect.objectContaining({ missing: ['NEXT_PUBLIC_SUPABASE_ANON_KEY'] }),
|
||||
)
|
||||
})
|
||||
|
||||
it('treats an unsubstituted Docker sentinel as unconfigured', async () => {
|
||||
stubConfiguredEnvironment('__NEXT_PUBLIC_SUPABASE_URL__')
|
||||
vi.stubEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY', '__NEXT_PUBLIC_SUPABASE_ANON_KEY__')
|
||||
|
||||
expect(
|
||||
(await proxy(new NextRequest('https://app.accounted.se/login'))).status,
|
||||
).toBe(503)
|
||||
expect(updateSessionMock).not.toHaveBeenCalled()
|
||||
expect(loggerErrorMock).toHaveBeenCalledWith(
|
||||
'Refused request without Supabase configuration',
|
||||
expect.objectContaining({
|
||||
missing: ['NEXT_PUBLIC_SUPABASE_URL', 'NEXT_PUBLIC_SUPABASE_ANON_KEY'],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('runs before the white-label guard on a customer host', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', undefined)
|
||||
vi.stubEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY', undefined)
|
||||
|
||||
expect(
|
||||
(await proxy(new NextRequest('https://willem.accounted.se/login'))).status,
|
||||
).toBe(503)
|
||||
expect(loggerErrorMock).toHaveBeenCalledOnce()
|
||||
expect(loggerErrorMock).toHaveBeenCalledWith(
|
||||
'Refused request without Supabase configuration',
|
||||
expect.objectContaining({ operation: 'supabase_env_missing' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('delegates to session handling once the environment is configured', async () => {
|
||||
const request = new NextRequest('https://app.accounted.se/login')
|
||||
|
||||
expect((await proxy(request)).status).toBe(204)
|
||||
expect(loggerErrorMock).not.toHaveBeenCalled()
|
||||
expect(updateSessionMock).toHaveBeenCalledOnce()
|
||||
expect(updateSessionMock).toHaveBeenCalledWith(request)
|
||||
})
|
||||
})
|
||||
|
||||
describe('production white-label proxy guard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', STAGING_URL)
|
||||
stubConfiguredEnvironment(STAGING_URL)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -53,33 +146,79 @@ describe('production white-label proxy guard', () => {
|
||||
expect(updateSessionMock).not.toHaveBeenCalled()
|
||||
expect(loggerErrorMock).toHaveBeenCalledOnce()
|
||||
expect(loggerErrorMock).toHaveBeenCalledWith(
|
||||
'Blocked production white-label host from staging backend',
|
||||
'Blocked production white-label host from a non-production backend',
|
||||
{
|
||||
alert: true,
|
||||
operation: 'white_label_backend_guard',
|
||||
requestHostname: 'acount.accounted.se',
|
||||
backendClassification: 'staging',
|
||||
backendClassification: 'non_production',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves canonical app behavior with the same backend', async () => {
|
||||
const request = new NextRequest('https://app.accounted.se/login')
|
||||
// The message says non-production rather than staging because the guard
|
||||
// asserts the production project: a project it has never heard of fails the
|
||||
// same way, and an alert rule keys on `operation`, which does not move.
|
||||
it('logs the same guard event for a project it has never heard of', async () => {
|
||||
stubConfiguredEnvironment('https://qqqqqqqqqqqqqqqqqqqq.supabase.co')
|
||||
|
||||
expect((await proxy(request)).status).toBe(204)
|
||||
expect(updateSessionMock).toHaveBeenCalledOnce()
|
||||
expect(updateSessionMock).toHaveBeenCalledWith(request)
|
||||
const response = await proxy(
|
||||
new NextRequest('https://willem.accounted.se/login'),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(updateSessionMock).not.toHaveBeenCalled()
|
||||
expect(loggerErrorMock).toHaveBeenCalledWith(
|
||||
'Blocked production white-label host from a non-production backend',
|
||||
{
|
||||
alert: true,
|
||||
operation: 'white_label_backend_guard',
|
||||
requestHostname: 'willem.accounted.se',
|
||||
backendClassification: 'non_production',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// The 2026-08-26 incident. This host was served by a branch preview wired to
|
||||
// staging and was missing from the protected-host list, so the guard used to
|
||||
// let it through.
|
||||
it('blocks a hosted brand nobody remembered to classify', async () => {
|
||||
const response = await proxy(
|
||||
new NextRequest('https://improveone.accounted.se/login'),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(updateSessionMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Deliberate reversal of the earlier assertion that the canonical app host
|
||||
// passes on the staging project. app.accounted.se is the production host: if
|
||||
// the build serving it is wired to another project, it fails closed too.
|
||||
it('blocks the canonical app host on the same backend', async () => {
|
||||
const response = await proxy(new NextRequest('https://app.accounted.se/login'))
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(updateSessionMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not treat the callback allowlist as a production classification', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', 'internal-demo.accounted.test')
|
||||
const request = new NextRequest('https://internal-demo.accounted.test/login')
|
||||
vi.stubEnv('NEXT_PUBLIC_WHITELABEL_DOMAINS', 'demo.partner-brand.se')
|
||||
const request = new NextRequest('https://demo.partner-brand.se/login')
|
||||
|
||||
expect((await proxy(request)).status).toBe(204)
|
||||
expect(updateSessionMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('allows the production host after it moves to a different backend', async () => {
|
||||
it.each([
|
||||
'https://erp-base-git-add-white-label-infra.vercel.app/login',
|
||||
'http://localhost:3000/login',
|
||||
])('leaves the preview or local request %s alone', async url => {
|
||||
expect((await proxy(new NextRequest(url))).status).toBe(204)
|
||||
expect(updateSessionMock).toHaveBeenCalledOnce()
|
||||
expect(loggerErrorMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows the production host once it is on the production backend', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', PRODUCTION_URL)
|
||||
const request = new NextRequest('https://acount.accounted.se/login')
|
||||
|
||||
|
||||
@@ -5,26 +5,104 @@ import { updateSession } from '@/lib/supabase/middleware'
|
||||
|
||||
const log = createLogger('proxy')
|
||||
|
||||
// Both guards below flag their record `alert: true`, and from here that flag
|
||||
// pages nobody: the observability sink is only ever registered by lib/init.ts,
|
||||
// which middleware must not call (it would drag the extension registry and the
|
||||
// analytics client into the middleware bundle). The flag stays as the record's
|
||||
// intent; the rule that turns one of these lines into an alert is configured on
|
||||
// the hosting side, matching the stable `operation` field of the emitted JSON.
|
||||
|
||||
/**
|
||||
* Empty, non-cacheable 503. The body stays empty on purpose: the deployment is
|
||||
* already known to be wired wrong, so the response must not echo a hostname, a
|
||||
* backend URL or a credential back to whoever asked.
|
||||
*/
|
||||
function serviceUnavailable(): NextResponse {
|
||||
return new NextResponse(null, {
|
||||
status: 503,
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a build-time public value never made it into the bundle: absent,
|
||||
* empty, or still the `__NEXT_PUBLIC_*__` sentinel the Dockerfile bakes in and
|
||||
* docker-entrypoint.sh substitutes at container start. Mirrors the check in
|
||||
* lib/supabase/client.ts.
|
||||
*
|
||||
* Takes the value as an argument rather than reading process.env itself: an
|
||||
* in-place comparison of a NEXT_PUBLIC_* var is constant-folded at build time
|
||||
* and erases the sentinel the entrypoint needs to find. See
|
||||
* lib/env/public-flags.ts.
|
||||
*/
|
||||
function isMissingPublicValue(value: string | undefined): boolean {
|
||||
return !value || value.startsWith('__')
|
||||
}
|
||||
|
||||
/**
|
||||
* The Supabase browser env vars that updateSession needs, by name, or an empty
|
||||
* list when the deployment is configured.
|
||||
*
|
||||
* Static property reads, never a `process.env[name]` loop: Next.js substitutes
|
||||
* only literal references at build time, so a dynamic lookup would report a
|
||||
* healthy deployment as unconfigured and 503 every request on every path.
|
||||
*/
|
||||
function missingSupabaseEnv(): string[] {
|
||||
const missing: string[] = []
|
||||
|
||||
if (isMissingPublicValue(process.env.NEXT_PUBLIC_SUPABASE_URL)) {
|
||||
missing.push('NEXT_PUBLIC_SUPABASE_URL')
|
||||
}
|
||||
if (isMissingPublicValue(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)) {
|
||||
missing.push('NEXT_PUBLIC_SUPABASE_ANON_KEY')
|
||||
}
|
||||
|
||||
return missing
|
||||
}
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
// Ahead of everything, because everything below needs a Supabase client.
|
||||
// updateSession builds one with non-null assertions and @supabase/ssr throws
|
||||
// synchronously when either value is falsy: that throw escapes the Web
|
||||
// Handler and 500s every path the matcher covers, /login and /robots.txt
|
||||
// included, with no log line to find it by. Preview deployments built
|
||||
// without the vars stayed bricked that way for weeks.
|
||||
const missingEnv = missingSupabaseEnv()
|
||||
if (missingEnv.length > 0) {
|
||||
log.error('Refused request without Supabase configuration', {
|
||||
alert: true,
|
||||
operation: 'supabase_env_missing',
|
||||
requestHostname: request.nextUrl.hostname,
|
||||
missing: missingEnv,
|
||||
})
|
||||
|
||||
return serviceUnavailable()
|
||||
}
|
||||
|
||||
if (
|
||||
usesForbiddenWhiteLabelBackend(
|
||||
request.nextUrl.hostname,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
)
|
||||
) {
|
||||
log.error('Blocked production white-label host from staging backend', {
|
||||
alert: true,
|
||||
operation: 'white_label_backend_guard',
|
||||
requestHostname: request.nextUrl.hostname,
|
||||
backendClassification: 'staging',
|
||||
})
|
||||
|
||||
return new NextResponse(null, {
|
||||
status: 503,
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
// Renamed from 'Blocked production white-label host from staging backend'.
|
||||
// The guard asserts the production project rather than naming one
|
||||
// forbidden project, so the staging project, a third project and a backend
|
||||
// URL the build cannot parse all land here. `operation` is unchanged: it is
|
||||
// what a hosting-side alert rule matches on.
|
||||
log.error(
|
||||
'Blocked production white-label host from a non-production backend',
|
||||
{
|
||||
alert: true,
|
||||
operation: 'white_label_backend_guard',
|
||||
requestHostname: request.nextUrl.hostname,
|
||||
backendClassification: 'non_production',
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return serviceUnavailable()
|
||||
}
|
||||
|
||||
return await updateSession(request)
|
||||
|
||||
Reference in New Issue
Block a user