diff --git a/DECISIONS.md b/DECISIONS.md index 4974e56e..ce6d8c28 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1427,6 +1427,7 @@ One line per decision: `[YYYY-MM-DD] : `. 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). diff --git a/docs/WHITELABEL.md b/docs/WHITELABEL.md index 613d5c2a..e783299e 100644 --- a/docs/WHITELABEL.md +++ b/docs/WHITELABEL.md @@ -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 `.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 `.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. diff --git a/lib/domains/__tests__/production-white-label-backend.test.ts b/lib/domains/__tests__/production-white-label-backend.test.ts index 7169eb74..954fd926 100644 --- a/lib/domains/__tests__/production-white-label-backend.test.ts +++ b/lib/domains/__tests__/production-white-label-backend.test.ts @@ -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) }) }) diff --git a/lib/domains/production-white-label-backend.ts b/lib/domains/production-white-label-backend.ts index 5769a9cb..efe174ac 100644 --- a/lib/domains/production-white-label-backend.ts +++ b/lib/domains/production-white-label-backend.ts @@ -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 .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 } diff --git a/proxy.test.ts b/proxy.test.ts index e40b02fb..4e799074 100644 --- a/proxy.test.ts +++ b/proxy.test.ts @@ -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') diff --git a/proxy.ts b/proxy.ts index e47ecfc8..1ca35545 100644 --- a/proxy.ts +++ b/proxy.ts @@ -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)