fix: block staging backend on production white-label hosts (#1903)
* fix: block staging backend on production brands * fix: clarify production domain classification * fix: alert on forbidden white-label backend
This commit is contained in:
@@ -1235,6 +1235,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-25] Proposal line-pattern settlement leg now takes the counterparty template's learned legacy pair (credit for expense, debit for income, mirror-swapped, || 1930), passed raw from QuickReviewDialog: two skeptics refuted the 1930 default (engine books e.g. 2440 from SIE-learned patterns; preview/prefill showed 1930). Declined CodeRabbit's two suggestions on #1894 deliberately: the 3740 rounding line keeps the engine's business-side placement for BOTH diff signs (parity contract; the engine's negative-diff imbalance cannot reach the ledger, commit_journal_entry rejects it; engine-side sign fix is a separate issue) and the naiveOreRound baseline stays raised to 622 (engineRound is a documented parity exception, not drift).
|
||||
[2026-08-25] The production-only _backfill_remaining_20260817 invoice repair snapshot is privilege-contained, not deleted or relocated: PR #1655 identifies it as the safety snapshot for the 337-row 2026-08-17 remaining_amount repair, but the repository establishes neither its retention classification nor approval to destroy financial evidence, so the migration enables RLS, revokes PUBLIC/anon/authenticated access, and limits service_role to read-only for an authorized follow-up review while postgres retains owner control. Neither PR #1655 nor repository history establishes the original repair's behandlingshistorik/rattelse traceability or whether any repaired invoice was linked to a posted voucher; verifying the repair's who/when/what trail and its relation to booked entries is an explicit compliance follow-up, not inferred or altered by this access fix. Default privileges stay unchanged in this scoped fix because Supabase's platform transition and the application's many existing implicit grants require a separate compatibility audit.
|
||||
[2026-08-25] Risk ID RISK-2026-08-25-INVOICE-BACKFILL-SNAPSHOT treatment record (PR #1901): Risk Owner and follow-up owner Emil; classification restricted financial remediation evidence pending BFL review; treatment preserves all 337 rows and merges only the anonymous-access containment; BFL retention/rattelse review deadline 2026-09-25; residual risk after containment Low, explicitly including postgres-owner bypass until that review. Retention or deletion requires the separate reviewed follow-up, and this PR must not delete or alter snapshot rows. This entry and PR #1901 are the repository-native Risk Treatment Plan reference because the repository has no risk register.
|
||||
[2026-08-25] acount, arbore, elma, m360, redovisningskompaniet, willem, and ziffr under accounted.se are customer-facing production hosts and fail closed with an empty 503 before session handling when NEXT_PUBLIC_SUPABASE_URL targets staging project metjnjrhvujscngnpzdv: Emil classified the hosts explicitly; exact-host callback allowlisting and the canonical app remain unchanged, while project ownership, Auth settings, DNS, keys, data, and migration are separate operational work.
|
||||
[2026-08-25] Supabase Auth bot protection uses dependency-free Cloudflare Turnstile with a two-step rollout: a missing public site key keeps existing Auth flows available, while a configured client fails closed until it has a token; provider enforcement is enabled only after the client deploy is verified. The generic Docker image always permits Cloudflare's script and frame origins because its optional site key is substituted at runtime, after the CSP has been built.
|
||||
[2026-08-25] Plugin distribution goes through the Claude plugin directory (public GitHub link, claude plugin validate, submit from claude.ai admin-settings or Console), not an organisation marketplace: org marketplaces accept private/internal repos only and require the Claude GitHub App, so a public monorepo can never pass that dialog (the 'Repository not accessible' error is misleading). Install-time guidance is a /accounted:setup slash command, the convention Anthropic's own plugins use (commands/*-setup.md); no SETUP.md mechanism exists in the plugin spec.
|
||||
[2026-08-25] Issue #1870: skattekonto AGI seed reverted 2730 -> 2731 (salary side kept on 2731), not the alternative of moving SALARY_ACCOUNTS.AVGIFTER_LIABILITY to 2730: BAS 2026 defines 2731 as exactly the reported-but-unpaid arbetsgivaravgift liability (the accrual account is 2940), and the salary module's whole-krona/ore-residual logic (PR #1609, 2026-08-14 decisions) is built around 2731. The 20260519160000 migration's rationale mislabeled 2731 as the accrual account; a one-sided flip either way reintroduces the split. Historical 2730 debits since 2026-05-19 are left for per-company reclass verifikat, not repaired in-migration.
|
||||
|
||||
@@ -109,6 +109,10 @@ 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.
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
3. Add `https://portal.partner.se/auth/callback` and `https://portal.partner.se/invite/*` to the Supabase Auth Redirect URLs allowlist. Keep the canonical `NEXT_PUBLIC_APP_URL` callback there too.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { usesForbiddenWhiteLabelBackend } from '../production-white-label-backend'
|
||||
|
||||
const STAGING_URL = 'https://metjnjrhvujscngnpzdv.supabase.co'
|
||||
const PRODUCTION_URL = 'https://pwxtzglxptnnvjrpixpg.supabase.co'
|
||||
|
||||
describe('production white-label backend guard', () => {
|
||||
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 => {
|
||||
expect(usesForbiddenWhiteLabelBackend(hostname, STAGING_URL)).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes case and a trailing dot before the exact host checks', () => {
|
||||
expect(
|
||||
usesForbiddenWhiteLabelBackend(
|
||||
'ACOUNT.ACCOUNTED.SE.',
|
||||
'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),
|
||||
).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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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',
|
||||
'arbore.accounted.se',
|
||||
'elma.accounted.se',
|
||||
'm360.accounted.se',
|
||||
'redovisningskompaniet.accounted.se',
|
||||
'willem.accounted.se',
|
||||
'ziffr.accounted.se',
|
||||
])
|
||||
|
||||
const FORBIDDEN_STAGING_SUPABASE_HOST =
|
||||
'metjnjrhvujscngnpzdv.supabase.co'
|
||||
|
||||
function normalizeHostname(hostname: string): string {
|
||||
return hostname.trim().toLowerCase().replace(/\.$/, '')
|
||||
}
|
||||
|
||||
function parseBackendHostname(supabaseUrl: string | undefined): string | null {
|
||||
if (!supabaseUrl) return null
|
||||
|
||||
try {
|
||||
return normalizeHostname(new URL(supabaseUrl).hostname)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function usesForbiddenWhiteLabelBackend(
|
||||
requestHostname: string,
|
||||
supabaseUrl: string | undefined,
|
||||
): boolean {
|
||||
const hostname = normalizeHostname(requestHostname)
|
||||
if (!CUSTOMER_PRODUCTION_WHITE_LABEL_HOSTS.has(hostname)) return false
|
||||
|
||||
return parseBackendHostname(supabaseUrl) === FORBIDDEN_STAGING_SUPABASE_HOST
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { unstable_doesMiddlewareMatch } from 'next/experimental/testing/server'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const updateSessionMock = vi.hoisted(() =>
|
||||
vi.fn(async () => new NextResponse(null, { status: 204 })),
|
||||
)
|
||||
const loggerErrorMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/lib/supabase/middleware', () => ({
|
||||
updateSession: updateSessionMock,
|
||||
}))
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
createLogger: () => ({ error: loggerErrorMock }),
|
||||
}))
|
||||
|
||||
import { config, proxy } from './proxy'
|
||||
|
||||
const STAGING_URL = 'https://metjnjrhvujscngnpzdv.supabase.co'
|
||||
const PRODUCTION_URL = 'https://pwxtzglxptnnvjrpixpg.supabase.co'
|
||||
|
||||
describe('production white-label proxy guard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', STAGING_URL)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it.each(['/login', '/auth/callback', '/api/health'])(
|
||||
'covers the application request path %s',
|
||||
pathname => {
|
||||
expect(
|
||||
unstable_doesMiddlewareMatch({
|
||||
config,
|
||||
nextConfig: {},
|
||||
url: `https://acount.accounted.se${pathname}`,
|
||||
}),
|
||||
).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it('returns a non-cacheable empty 503 before session handling', async () => {
|
||||
const response = await proxy(
|
||||
new NextRequest('https://acount.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(
|
||||
'Blocked production white-label host from staging backend',
|
||||
{
|
||||
alert: true,
|
||||
operation: 'white_label_backend_guard',
|
||||
requestHostname: 'acount.accounted.se',
|
||||
backendClassification: 'staging',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves canonical app behavior with the same backend', async () => {
|
||||
const request = new NextRequest('https://app.accounted.se/login')
|
||||
|
||||
expect((await proxy(request)).status).toBe(204)
|
||||
expect(updateSessionMock).toHaveBeenCalledOnce()
|
||||
expect(updateSessionMock).toHaveBeenCalledWith(request)
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
expect((await proxy(request)).status).toBe(204)
|
||||
expect(updateSessionMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('allows the production host after it moves to a different backend', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', PRODUCTION_URL)
|
||||
const request = new NextRequest('https://acount.accounted.se/login')
|
||||
|
||||
expect((await proxy(request)).status).toBe(204)
|
||||
expect(updateSessionMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses the request URL host and ignores x-forwarded-host', async () => {
|
||||
const spoofedForwardedHost = new NextRequest(
|
||||
'https://preview.vercel.app/login',
|
||||
{ headers: { 'x-forwarded-host': 'acount.accounted.se' } },
|
||||
)
|
||||
expect((await proxy(spoofedForwardedHost)).status).toBe(204)
|
||||
|
||||
const productionHost = new NextRequest(
|
||||
'https://acount.accounted.se/login',
|
||||
{ headers: { 'x-forwarded-host': 'preview.vercel.app' } },
|
||||
)
|
||||
expect((await proxy(productionHost)).status).toBe(503)
|
||||
expect(updateSessionMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,32 @@
|
||||
import { type NextRequest } from 'next/server'
|
||||
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')
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
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',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return await updateSession(request)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user