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:
co-authored by
Claude Opus 5
parent
57d757192e
commit
21e6e2d314
+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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user