Files
accounted/proxy.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

135 lines
5.3 KiB
TypeScript

import { NextResponse, type NextRequest } from 'next/server'
import { usesForbiddenWhiteLabelBackend } from '@/lib/domains/production-white-label-backend'
import { createLogger } from '@/lib/logger'
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,
)
) {
// 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)
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - Static assets (images, scripts, manifest, icons, etc.)
*
* NOTE: `/api` is intentionally INCLUDED so the proxy can enforce the MFA
* (AAL2) gate on cookie-authenticated API calls (updateSession short-
* circuits API routes after that check: see lib/supabase/middleware.ts).
*
* `/rl` is the PostHog reverse-proxy prefix (rewrites in next.config.ts).
* It MUST be excluded: middleware runs BEFORE next.config rewrites, so
* without this updateSession() treats an ingestion POST as an unknown
* protected path and 307s it to /login. That silently kills analytics on
* every logged-out page and, because flags and asset loads still succeed
* through the rewrite, the integration looks healthy while no events
* arrive. Keep in sync with `api_host` in instrumentation-client.ts.
*/
'/((?!_next/static|_next/image|favicon.ico|\\.well-known|rl/|sw\\.js|sw-register\\.js|manifest\\.json|manifest\\.webmanifest|icons/|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|js|json)$).*)',
],
}