fix(security): audit remediation 2026-09-01: api_keys identity, viewer gates, OAuth binding, XSS, MFA gate (#2155)
* fix(security): bind api_keys to the caller, lock hash-as-bearer RPCs and provider token tables Security audit 2026-09-01, critical items. - api_keys INSERT requires user_id = auth.uid() again (an admin could forge a key for any co-member and act as them in every company they belong to); SELECT is own-keys-or-admin; a BEFORE trigger freezes the identity and credential columns against user-session UPDATEs. - rotate_mcp_refresh_token and validate_and_increment_api_key become service_role only: they match rows by a presented SHA-256, so a hash readable by co-members was a bearer credential. - validate_and_increment_api_key fails closed when the key's user is no longer a member of the key's company. - provider_consent_tokens and provider_otc: the DELETE policies collapsed to "caller has any team row" (correlated subquery on a non-existent team_members.company_id). All member policies dropped; service_role only, matching every existing code path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): role gates, ownership guards and posting integrity in the database Security audit 2026-09-01, high items at the database layer. - One table-level guard, enforce_company_writer_role(), blocks the read-only viewer role on 55 company-scoped tables including through the 15 membership-only SECURITY DEFINER writers. Keyed on the JWT role claim so it fires inside definer bodies; no-op for service_role and trigger cascades. - company_members user_id/company_id immutable from user sessions; invitations can never grant owner; team_members gains a transition guard (admins keep non-owner role moves); companies team_id and archiving are owner-only and team attachment needs team membership. - Direct statements (current_user = authenticated) can no longer insert posted headers, add lines under posted verifikat, or post a draft with a voucher number the sequence never issued. Sanctioned RPCs run as the definer and are untouched; the engine's own draft-then-post shapes still pass. - create_document_version refuses viewers and foreign storage paths; validate_version_chain needs membership and loses anon EXECUTE; match_documents / match_booking_templates lose anon; cron maintenance RPCs become service_role only; the production-only seed_asset_categories is dropped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * build: pin tsx as an exact devDependency instead of fetching it with npx at build time prebuild ran "npx tsx" with no lockfile entry, so every Vercel, Docker and CI build downloaded tsx@latest and its transitive tree from the registry with no integrity check, inside the build environment. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): refuse the viewer role on API-key and MCP write paths The v1 wrapper and the MCP company routing checked company membership but never role, and both run as service role, so a read-only viewer holding an API key could post vouchers and change settings through the API. Mutating methods and non-read scopes now return 403 ROLE_READ_ONLY for viewers on v1; MCP write tools refuse viewers the same way. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): stop serving uploaded SVG, XML and HTML as executable content on the app origin Uploads persisted the browser-declared mime type and the inline proxy served it verbatim, sandboxing only text/html; the storage proxy forwarded the uploader's Content-Type. Any writer, or any Peppol sender, could plant a scripted SVG or XHTML that executed on app.gnubok.se. - inline route: allow-list of natively safe types (PDF, raster images) served as before; everything else gets the opaque sandbox CSP. - storage proxy: octet-stream + attachment + sandbox unless the DB mime for the key is on the allow-list. - document-service: the stored mime is the magic-byte validated type. - logo upload: magic-byte validation, SVG refused. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): byrå brand logo upload decides the type by magic bytes and drops SVG Same pattern as the company logo route: the logos bucket is public, so a scripted SVG (or anything declared as an image) must never land there. The upload pickers stop advertising SVG. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): bind Enable Banking, Stripe and WooCommerce callbacks to the initiating user The callbacks resolved the pending row by oauth_state alone, so a victim who completed an attacker-initiated consent had their bank account, merchant account or store attached to the attacker's company. requireFlowInitiator() now requires the cookie session of the user who started the flow: no session redirects to login with the callback URL preserved, a different user is refused and nothing is exchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): guard tenant-controlled outbound fetches and surface the disabled rate limiter WooCommerce and Shopify syncs fetched a member-editable store URL with plain fetch() and redirect following under the service role, and the invoice PDF renderer fetched company_settings.logo_url unguarded. All three go through a new safeFetch() (public-IP validation via url-guard, https only, redirect: 'manual', body size cap) and re-normalise the stored host at use time. checkRateLimit() keeps failing open on hosted but logs one error per process when Upstash is not configured and exports isRateLimiterConfigured(). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): decide the API MFA gate from server-authenticated factors, not the session cookie getAuthenticatorAssuranceLevel() without arguments derives nextLevel from session.user.factors, which comes from the unsigned sb-*-auth-token cookie. Deleting factors from the cookie made an enrolled account look like it had nothing to step up to, on every /api route and in requireAuth. Both gates now read factors from the getUser() result or listFactors() and the level from the verified JWT claim, and fail closed on errors. Page-branch gate hardened the same way. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): bind Fortnox/Visma, Gmail and Skatteverket callbacks to the initiating user The arcim-migration callback exchanged the provider code onto whatever consent the one-time state named, with no check of who completed the flow and no org-number comparison, so a phished Fortnox admin handed their ledger to the attacker's company. provider_otc now records the initiating user (migration 20260902100000); the callback requires that session and, after the exchange, refuses a provider company whose org number differs from the consent's company. The Gmail and Skatteverket callbacks enforce the same initiator check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): BankID signup confirms the email before linking the identity Signup created an email-confirmed, MFA-exempt account for any address the caller typed and returned a magic link, so an attacker could pre-register a victim's email and keep a permanent BankID login into the account the victim later adopted. The user is now created unconfirmed, the identity carries email_verified_at NULL (migration 20260902101000), bankid_linked is not set until the mailed confirmation is clicked, and BankID login of a pending identity is refused with the confirmation re-sent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): bind MCP OAuth redirect URIs to the consenting user and cap scopes A user-registered redirect URI was allowlisted globally, the consent page named no client, and all scopes were pre-checked, so one phishing link handed an attacker a full-scope key for the victim's company. Registered URIs now resolve only for the registrant or a colleague sharing a company; the consent page shows the client identity and redirect host; non-built-in clients default to read-only pre-checks; scopes are capped by the user's role (viewer: read only) at consent and at /token. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(auth): client follow-ups for BankID confirmation, callback mismatch copy and decision log - register client handles the new confirmation_sent response from BankID signup with the existing inbox screen instead of calling verifyOtp. - BankID login surfaces the email_unconfirmed explanation. - WooCommerce settings map woocommerce_error=wrong_user to its own copy. - Logo help text no longer advertises SVG. - DECISIONS.md records the audit remediation choices. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(mcp-oauth): literal SoD columns in the api_keys insert so the phantom-column scanner resolves them Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(logo): type the upload fixtures as Uint8Array<ArrayBuffer> so they are valid BlobParts Fixes the typecheck ratchet on PR #2155 and ratchets the baseline down by the one legacy error the change removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
6e8d76a9cb
commit
18cbc4c30a
@@ -757,3 +757,201 @@ describe('truncateIp: privacy-preserving IP logging', () => {
|
||||
|
||||
// Suppress unused-import warning: we re-export to keep the type chain visible.
|
||||
void mockStoreIdempotency
|
||||
|
||||
describe('withApiV1: read-only role gate (viewer)', () => {
|
||||
// The v1 surface runs as the service role, so RLS never sees the viewer.
|
||||
// This wrapper is the only place the read-only role is enforced for API
|
||||
// keys; the DB triggers that block viewer writes apply to cookie sessions.
|
||||
function viewerKey(scopes: string[]) {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-viewer',
|
||||
companyId: 'company-1',
|
||||
scopes,
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'viewer' }))
|
||||
}
|
||||
|
||||
it('refuses a viewer key on POST journal-entries with 403 ROLE_READ_ONLY before the handler runs', async () => {
|
||||
viewerKey(['bookkeeping:write'])
|
||||
const handlerSpy = vi.fn(async (_req: Request, ctx: { requestId: string }) =>
|
||||
ok({ id: 'je-1' }, { requestId: ctx.requestId }),
|
||||
)
|
||||
// No requireScope override: the real catalogue entry for the route is what
|
||||
// classifies the request ('POST .../journal-entries' -> bookkeeping:write).
|
||||
const handler = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'journal-entries.create',
|
||||
handlerSpy,
|
||||
)
|
||||
|
||||
const res = await handler(
|
||||
makeRequest('https://x.test/api/v1/companies/company-1/journal-entries', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer gnubok_sk_x', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ description: 'x', lines: [] }),
|
||||
}),
|
||||
companyParams('company-1'),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('FORBIDDEN')
|
||||
expect(body.error.details.code).toBe('ROLE_READ_ONLY')
|
||||
expect(body.error.details.role).toBe('viewer')
|
||||
expect(body.error.details.required_scope).toBe('bookkeeping:write')
|
||||
// Swedish user-facing message from the registry entry.
|
||||
expect(body.error.message).toBe('Du har inte behörighet att utföra denna åtgärd.')
|
||||
expect(body.error.request_id).toMatch(/^req_/)
|
||||
expect(handlerSpy).not.toHaveBeenCalled()
|
||||
// Refused before the seat gate: no extra read for a refused write.
|
||||
expect(getMultiUserStateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a viewer even when the write is a dry-run (nothing to simulate for a read-only role)', async () => {
|
||||
viewerKey(['invoices:write'])
|
||||
const handlerSpy = vi.fn(async (_req: Request, ctx: { requestId: string }) =>
|
||||
ok({ ok: true }, { requestId: ctx.requestId }),
|
||||
)
|
||||
const handler = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'invoices.create',
|
||||
handlerSpy,
|
||||
{ requireScope: 'invoices:write' },
|
||||
)
|
||||
|
||||
const res = await handler(
|
||||
makeRequest('https://x.test/api/v1/companies/company-1/invoices?dry_run=true', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer gnubok_sk_x' },
|
||||
}),
|
||||
companyParams('company-1'),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.error.details.code).toBe('ROLE_READ_ONLY')
|
||||
expect(handlerSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a viewer on a GET whose scope is an elevated grant (webhooks:manage)', async () => {
|
||||
viewerKey(['webhooks:manage'])
|
||||
const handlerSpy = vi.fn(async (_req: Request, ctx: { requestId: string }) =>
|
||||
ok({ webhooks: [] }, { requestId: ctx.requestId }),
|
||||
)
|
||||
const handler = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'webhooks.list',
|
||||
handlerSpy,
|
||||
{ requireScope: 'webhooks:manage' },
|
||||
)
|
||||
|
||||
const res = await handler(
|
||||
makeRequest('https://x.test/api/v1/companies/company-1/webhooks', {
|
||||
headers: { Authorization: 'Bearer gnubok_sk_x' },
|
||||
}),
|
||||
companyParams('company-1'),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.error.details.code).toBe('ROLE_READ_ONLY')
|
||||
expect(body.error.details.required_scope).toBe('webhooks:manage')
|
||||
expect(handlerSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a viewer GET on a :read scope untouched (200, seat gate still consulted)', async () => {
|
||||
viewerKey(['reports:read'])
|
||||
const handler = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'journal-entries.list',
|
||||
async (_req, ctx) => ok({ entries: [], companyId: ctx.companyId }, { requestId: ctx.requestId }),
|
||||
)
|
||||
|
||||
const res = await handler(
|
||||
makeRequest('https://x.test/api/v1/companies/company-1/journal-entries', {
|
||||
headers: { Authorization: 'Bearer gnubok_sk_x' },
|
||||
}),
|
||||
companyParams('company-1'),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.companyId).toBe('company-1')
|
||||
// A viewer is a non-owner: the dormant-seat logic still runs for reads.
|
||||
expect(getMultiUserStateMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still applies the seat gate to a viewer read in a frozen company', async () => {
|
||||
viewerKey(['reports:read'])
|
||||
getMultiUserStateMock.mockResolvedValue({ state: 'frozen', graceEndsAt: null })
|
||||
const handler = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'journal-entries.list',
|
||||
async (_req, ctx) => ok({ entries: [] }, { requestId: ctx.requestId }),
|
||||
)
|
||||
|
||||
const res = await handler(
|
||||
makeRequest('https://x.test/api/v1/companies/company-1/journal-entries', {
|
||||
headers: { Authorization: 'Bearer gnubok_sk_x' },
|
||||
}),
|
||||
companyParams('company-1'),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.error.details.capability).toBe('multi_user')
|
||||
expect(body.error.details.code).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(['member', 'admin', 'owner'])('lets a %s key through the role gate on POST journal-entries', async (role) => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
scopes: ['bookkeeping:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role }))
|
||||
const handlerSpy = vi.fn(async (_req: Request, ctx: { requestId: string }) =>
|
||||
NextResponse.json({ data: { id: 'je-1', requestId: ctx.requestId } }, { status: 201 }),
|
||||
)
|
||||
const handler = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'journal-entries.create',
|
||||
handlerSpy,
|
||||
)
|
||||
|
||||
const res = await handler(
|
||||
makeRequest('https://x.test/api/v1/companies/company-1/journal-entries', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer gnubok_sk_x', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ description: 'x', lines: [] }),
|
||||
}),
|
||||
companyParams('company-1'),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
expect(handlerSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the non-member answer unchanged: 404, no role information leaks', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-outsider',
|
||||
companyId: 'company-2',
|
||||
scopes: ['bookkeeping:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeSupabaseStub(null))
|
||||
const handler = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'journal-entries.create',
|
||||
async (_req, ctx) => ok({ ok: true }, { requestId: ctx.requestId }),
|
||||
)
|
||||
|
||||
const res = await handler(
|
||||
makeRequest('https://x.test/api/v1/companies/company-1/journal-entries', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer gnubok_sk_x' },
|
||||
}),
|
||||
companyParams('company-1'),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('NOT_FOUND')
|
||||
expect(body.error.details.role).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
* the token when one is supplied.
|
||||
* 4. When the URL contains `companyId`, verifies the API key's user has
|
||||
* access to that company via `company_members`. Multi-company keys are
|
||||
* supported transparently: the URL is the source of truth.
|
||||
* supported transparently: the URL is the source of truth. A `viewer`
|
||||
* (read-only) membership is refused for every write: mutating method
|
||||
* or non-`:read` scope (FORBIDDEN, details.code ROLE_READ_ONLY).
|
||||
* 5. Resolves the dry-run flag (`?dry_run=true` query OR `X-Dry-Run` header).
|
||||
* 6. Resolves `Idempotency-Key` (header) and replays cached responses. The
|
||||
* dry-run flag is part of the cache identity and dry-run responses are
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
extractBearerToken,
|
||||
hasScope,
|
||||
RATE_LIMIT_RETRY_AFTER_SECONDS,
|
||||
scopeKind,
|
||||
validateApiKey,
|
||||
} from '@/lib/auth/api-keys'
|
||||
import { runWithActor } from '@/lib/bookkeeping/actor-context-node'
|
||||
@@ -78,6 +81,13 @@ const DRY_RUN_HEADER = 'X-Dry-Run'
|
||||
// idempotency replay, requireIdempotencyKey enforcement), and omitting PUT
|
||||
// would let test keys write through PUT routes for real.
|
||||
const REQUIRES_IDEMPOTENCY = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
|
||||
// RFC 9110 safe methods. The read-only role gate treats EVERYTHING else as a
|
||||
// write: a superset of REQUIRES_IDEMPOTENCY, so an exotic method can never
|
||||
// slip a viewer past the gate. Deliberately separate from REQUIRES_IDEMPOTENCY,
|
||||
// whose semantics (replay, dry-run forcing) must not widen as a side effect.
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
||||
/** The read-only company role (see `CompanyRole` in `@/types`). */
|
||||
const READ_ONLY_ROLE = 'viewer'
|
||||
|
||||
export interface ApiV1Context {
|
||||
/** Stable id for this HTTP request: appears in logs, error envelope, X-Request-Id. */
|
||||
@@ -414,14 +424,54 @@ export function withApiV1<P extends DynamicParams = { params: Promise<Record<str
|
||||
})
|
||||
}
|
||||
|
||||
const membershipRole = (membership as { role?: string }).role
|
||||
|
||||
// Read-only role gate. Cookie routes enforce the viewer role through
|
||||
// withRouteContext({ requireWrite }) and the DB enforces it through
|
||||
// RLS + triggers for cookie sessions, but this surface runs as the
|
||||
// service role: nothing below this line would stop a viewer's key from
|
||||
// posting. A request is a write when EITHER its method is unsafe
|
||||
// (catches action verbs whatever their scope) OR its scope is an
|
||||
// elevated grant (catches reads-by-method that still manage tenant
|
||||
// state, e.g. GET /webhooks on webhooks:manage). Dry-run and test
|
||||
// keys are refused too: a viewer has no write to simulate.
|
||||
//
|
||||
// Placed AFTER the membership 404 so a non-member sees exactly what it
|
||||
// saw before (no new company-existence signal), and BEFORE the seat
|
||||
// gate so a refused write costs no extra read.
|
||||
if (
|
||||
membershipRole === READ_ONLY_ROLE &&
|
||||
(!SAFE_METHODS.has(request.method) || scopeKind(requiredScope) === 'write')
|
||||
) {
|
||||
userLog.warn('read-only membership refused write request', {
|
||||
companyId,
|
||||
method: request.method,
|
||||
requiredScope,
|
||||
...forensic,
|
||||
})
|
||||
return await v1ErrorResponseFromCode('FORBIDDEN', userLog, {
|
||||
requestId,
|
||||
status: 403,
|
||||
reason: 'role_read_only',
|
||||
details: {
|
||||
code: 'ROLE_READ_ONLY',
|
||||
companyId,
|
||||
role: READ_ONLY_ROLE,
|
||||
required_scope: requiredScope,
|
||||
message:
|
||||
'This company membership is read-only (viewer): write requests are refused. Ask a company owner or admin to change the role.',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Multi-user seat gate: the API-key surface is a chokepoint like the
|
||||
// cookie routes and MCP. A non-owner membership in a frozen company
|
||||
// (multi_user lapsed past its 20-day grace) is refused here so an old
|
||||
// key cannot keep working the books after the freeze. Owners pass
|
||||
// without the extra read; the service client sees team-scoped grants.
|
||||
if ((membership as { role?: string }).role !== 'owner') {
|
||||
if (membershipRole !== 'owner') {
|
||||
const access = await getMultiUserState(supabase, companyId)
|
||||
if (isMembershipDormant((membership as { role: string }).role, access.state)) {
|
||||
if (isMembershipDormant(membershipRole as string, access.state)) {
|
||||
userLog.warn('multi-user seat gate refused frozen membership', { companyId, ...forensic })
|
||||
return await v1ErrorResponseFromCode('FORBIDDEN', userLog, {
|
||||
requestId,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { isBuiltInRedirectUri, isAllowedRedirectUri } from '../oauth-allowlist'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import {
|
||||
builtInRedirectProvider,
|
||||
capScopesForRole,
|
||||
isAllowedRedirectUri,
|
||||
isBuiltInRedirectUri,
|
||||
lookupCompanyRole,
|
||||
resolveRedirectUri,
|
||||
} from '../oauth-allowlist'
|
||||
import { ALL_SCOPES, type ApiKeyScope } from '../scope-catalog'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
describe('isBuiltInRedirectUri', () => {
|
||||
@@ -23,45 +31,244 @@ describe('isBuiltInRedirectUri', () => {
|
||||
})
|
||||
})
|
||||
|
||||
function makeFakeSupabase(rows: Array<{ id: string }>): SupabaseClient {
|
||||
// Chainable thenable that resolves to { data, error } when awaited via
|
||||
// .maybeSingle(). Matches the shape isAllowedRedirectUri actually invokes.
|
||||
const chain = {
|
||||
from() { return chain },
|
||||
select() { return chain },
|
||||
eq() { return chain },
|
||||
is() { return chain },
|
||||
limit() { return chain },
|
||||
async maybeSingle() {
|
||||
return { data: rows[0] ?? null, error: null }
|
||||
},
|
||||
}
|
||||
return chain as unknown as SupabaseClient
|
||||
describe('builtInRedirectProvider', () => {
|
||||
it.each([
|
||||
['https://claude.ai/api/oauth/callback', 'claude'],
|
||||
['https://claude.com/api/oauth/callback', 'claude'],
|
||||
['https://chatgpt.com/connector/oauth/abc123', 'chatgpt'],
|
||||
['https://chatgpt.com/connector_platform_oauth_redirect', 'chatgpt'],
|
||||
['http://localhost:3000/cb', 'local'],
|
||||
['http://127.0.0.1:8080/cb', 'local'],
|
||||
['https://claude-login.example/cb', null],
|
||||
['', null],
|
||||
])('maps %s to %s', (uri, expected) => {
|
||||
expect(builtInRedirectProvider(uri)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
type Row = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Minimal PostgREST-shaped fake over in-memory tables: eq/is filters are
|
||||
* applied, everything else is a no-op, and the chain resolves to the filtered
|
||||
* rows (all rows when awaited, first row via maybeSingle). `failTable` makes
|
||||
* every query against that table return a DB error.
|
||||
*/
|
||||
function fakeClient(tables: Record<string, Row[]>, failTable?: string) {
|
||||
const from = vi.fn((table: string) => {
|
||||
const filters: [string, unknown][] = []
|
||||
const run = () =>
|
||||
(tables[table] ?? []).filter((row) =>
|
||||
filters.every(([col, val]) => (val === null ? row[col] == null : row[col] === val)),
|
||||
)
|
||||
const result = () =>
|
||||
table === failTable
|
||||
? { data: null, error: { message: 'db down' } }
|
||||
: { data: run(), error: null }
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const method of ['select', 'order', 'range', 'limit']) chain[method] = () => chain
|
||||
chain.eq = (col: string, val: unknown) => {
|
||||
filters.push([col, val])
|
||||
return chain
|
||||
}
|
||||
chain.is = (col: string, val: unknown) => {
|
||||
filters.push([col, val])
|
||||
return chain
|
||||
}
|
||||
chain.maybeSingle = async () => {
|
||||
const r = result()
|
||||
return { data: Array.isArray(r.data) ? (r.data[0] ?? null) : null, error: r.error }
|
||||
}
|
||||
chain.then = (resolve: (v: unknown) => void) => resolve(result())
|
||||
return chain
|
||||
})
|
||||
return { from } as unknown as SupabaseClient & { from: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
describe('isAllowedRedirectUri', () => {
|
||||
it('short-circuits to true for built-in patterns without touching the DB', async () => {
|
||||
const REGISTRATIONS: Row[] = [
|
||||
{ id: 'reg-1', user_id: 'user-2', client_name: 'Byråns bot', redirect_uri: 'https://app.example.com/cb', revoked_at: null },
|
||||
{ id: 'reg-2', user_id: 'user-9', client_name: 'Evil', redirect_uri: 'https://evil.example/cb', revoked_at: null },
|
||||
{ id: 'reg-3', user_id: 'user-1', client_name: 'Min egen app', redirect_uri: 'https://mine.example/cb', revoked_at: null },
|
||||
{ id: 'reg-4', user_id: 'user-1', client_name: 'Gammal app', redirect_uri: 'https://old.example/cb', revoked_at: '2026-01-01T00:00:00Z' },
|
||||
]
|
||||
|
||||
const MEMBERSHIPS: Row[] = [
|
||||
{ id: 'm1', user_id: 'user-1', company_id: 'company-1', role: 'owner' },
|
||||
{ id: 'm2', user_id: 'user-2', company_id: 'company-1', role: 'member' },
|
||||
{ id: 'm3', user_id: 'user-2', company_id: 'company-2', role: 'owner' },
|
||||
{ id: 'm4', user_id: 'user-9', company_id: 'company-9', role: 'owner' },
|
||||
]
|
||||
|
||||
const DB = { oauth_client_registrations: REGISTRATIONS, company_members: MEMBERSHIPS }
|
||||
|
||||
describe('resolveRedirectUri', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('short-circuits to the built-in provider without touching the DB', async () => {
|
||||
const sb = {
|
||||
from: vi.fn(() => {
|
||||
throw new Error('should not be called')
|
||||
}),
|
||||
} as unknown as SupabaseClient
|
||||
expect(await isAllowedRedirectUri('https://claude.ai/api/cb', sb)).toBe(true)
|
||||
expect(await isAllowedRedirectUri('http://localhost:3000/cb', sb)).toBe(true)
|
||||
expect(await resolveRedirectUri('https://claude.ai/api/cb', sb, { consentingUserId: 'user-1' })).toEqual({
|
||||
allowed: true,
|
||||
kind: 'built_in',
|
||||
provider: 'claude',
|
||||
})
|
||||
expect(await resolveRedirectUri('http://localhost:3000/cb', sb)).toEqual({
|
||||
allowed: true,
|
||||
kind: 'built_in',
|
||||
provider: 'local',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns true when the DB has a registration for the URI', async () => {
|
||||
const sb = makeFakeSupabase([{ id: 'reg-1' }])
|
||||
expect(await isAllowedRedirectUri('https://myapp.example.com/cb', sb)).toBe(true)
|
||||
it("accepts the consenting user's own registration", async () => {
|
||||
const result = await resolveRedirectUri('https://mine.example/cb', fakeClient(DB), {
|
||||
consentingUserId: 'user-1',
|
||||
})
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
kind: 'registered',
|
||||
clientName: 'Min egen app',
|
||||
registeredByConsentingUser: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns false when no registration exists', async () => {
|
||||
const sb = makeFakeSupabase([])
|
||||
expect(await isAllowedRedirectUri('https://evil.com/cb', sb)).toBe(false)
|
||||
it('accepts a registration by a colleague who shares a company', async () => {
|
||||
const result = await resolveRedirectUri('https://app.example.com/cb', fakeClient(DB), {
|
||||
consentingUserId: 'user-1',
|
||||
})
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
kind: 'registered',
|
||||
clientName: 'Byråns bot',
|
||||
registeredByConsentingUser: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns false for empty / non-string inputs', async () => {
|
||||
expect(await isAllowedRedirectUri('')).toBe(false)
|
||||
expect(await isAllowedRedirectUri(undefined as unknown as string)).toBe(false)
|
||||
it('rejects a registration by an unrelated user', async () => {
|
||||
// user-9 is a real member of the instance, just not of any company user-1
|
||||
// belongs to: their registration must not be a valid target for user-1.
|
||||
const result = await resolveRedirectUri('https://evil.example/cb', fakeClient(DB), {
|
||||
consentingUserId: 'user-1',
|
||||
})
|
||||
expect(result).toEqual({ allowed: false })
|
||||
})
|
||||
|
||||
it('accepts any active registration when no consenting user is given (anonymous /register)', async () => {
|
||||
const result = await resolveRedirectUri('https://evil.example/cb', fakeClient(DB))
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
kind: 'registered',
|
||||
clientName: 'Evil',
|
||||
registeredByConsentingUser: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a revoked registration, even the user’s own', async () => {
|
||||
const result = await resolveRedirectUri('https://old.example/cb', fakeClient(DB), {
|
||||
consentingUserId: 'user-1',
|
||||
})
|
||||
expect(result).toEqual({ allowed: false })
|
||||
})
|
||||
|
||||
it('rejects an unknown URI', async () => {
|
||||
expect(await resolveRedirectUri('https://nowhere.example/cb', fakeClient(DB))).toEqual({ allowed: false })
|
||||
})
|
||||
|
||||
it('fails closed when the registration lookup errors', async () => {
|
||||
const result = await resolveRedirectUri(
|
||||
'https://mine.example/cb',
|
||||
fakeClient(DB, 'oauth_client_registrations'),
|
||||
{ consentingUserId: 'user-1' },
|
||||
)
|
||||
expect(result).toEqual({ allowed: false })
|
||||
})
|
||||
|
||||
it('fails closed when the shared-company check errors', async () => {
|
||||
const result = await resolveRedirectUri('https://app.example.com/cb', fakeClient(DB, 'company_members'), {
|
||||
consentingUserId: 'user-1',
|
||||
})
|
||||
expect(result).toEqual({ allowed: false })
|
||||
})
|
||||
|
||||
it('fails closed when no client is given and none can be constructed', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '')
|
||||
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', '')
|
||||
expect(await resolveRedirectUri('https://mine.example/cb', undefined, { consentingUserId: 'user-1' })).toEqual({
|
||||
allowed: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns not allowed for empty / non-string inputs', async () => {
|
||||
expect(await resolveRedirectUri('')).toEqual({ allowed: false })
|
||||
expect(await resolveRedirectUri(undefined as unknown as string)).toEqual({ allowed: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAllowedRedirectUri', () => {
|
||||
it('is the boolean view of resolveRedirectUri', async () => {
|
||||
expect(await isAllowedRedirectUri('https://claude.ai/api/cb')).toBe(true)
|
||||
expect(await isAllowedRedirectUri('https://mine.example/cb', fakeClient(DB), { consentingUserId: 'user-1' })).toBe(true)
|
||||
expect(await isAllowedRedirectUri('https://evil.example/cb', fakeClient(DB), { consentingUserId: 'user-1' })).toBe(false)
|
||||
expect(await isAllowedRedirectUri('https://evil.example/cb', fakeClient(DB))).toBe(true)
|
||||
expect(await isAllowedRedirectUri('', fakeClient(DB))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('capScopesForRole', () => {
|
||||
const mixed: ApiKeyScope[] = [
|
||||
'transactions:read',
|
||||
'transactions:write',
|
||||
'pending_operations:approve',
|
||||
'webhooks:manage',
|
||||
'reconciliation:signoff',
|
||||
'reports:read',
|
||||
]
|
||||
|
||||
it('keeps every scope for owner, admin and member', () => {
|
||||
for (const role of ['owner', 'admin', 'member']) {
|
||||
expect(capScopesForRole(mixed, role)).toEqual(mixed)
|
||||
}
|
||||
})
|
||||
|
||||
it('caps a viewer to :read scopes only', () => {
|
||||
expect(capScopesForRole(mixed, 'viewer')).toEqual(['transactions:read', 'reports:read'])
|
||||
})
|
||||
|
||||
it('caps an unknown role and a missing membership to :read scopes', () => {
|
||||
expect(capScopesForRole(mixed, 'superuser')).toEqual(['transactions:read', 'reports:read'])
|
||||
expect(capScopesForRole(mixed, null)).toEqual(['transactions:read', 'reports:read'])
|
||||
})
|
||||
|
||||
it('leaves no write-kind scope in a capped set for the full catalogue', () => {
|
||||
const capped = capScopesForRole(ALL_SCOPES, 'viewer')
|
||||
expect(capped.length).toBeGreaterThan(0)
|
||||
expect(capped.every((s) => s.endsWith(':read'))).toBe(true)
|
||||
expect(capped).not.toContain('pending_operations:approve')
|
||||
})
|
||||
|
||||
it('returns a copy, never the caller’s array', () => {
|
||||
const result = capScopesForRole(mixed, 'owner')
|
||||
expect(result).not.toBe(mixed)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lookupCompanyRole', () => {
|
||||
it('returns the role for an existing membership', async () => {
|
||||
expect(await lookupCompanyRole(fakeClient(DB), 'user-2', 'company-1')).toEqual({ role: 'member', error: null })
|
||||
expect(await lookupCompanyRole(fakeClient(DB), 'user-2', 'company-2')).toEqual({ role: 'owner', error: null })
|
||||
})
|
||||
|
||||
it('returns a null role when no membership row exists', async () => {
|
||||
expect(await lookupCompanyRole(fakeClient(DB), 'user-9', 'company-1')).toEqual({ role: null, error: null })
|
||||
})
|
||||
|
||||
it('surfaces a query error instead of guessing', async () => {
|
||||
const result = await lookupCompanyRole(fakeClient(DB, 'company_members'), 'user-1', 'company-1')
|
||||
expect(result.role).toBeNull()
|
||||
expect(result.error).toBe('db down')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
}))
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import {
|
||||
requireFlowInitiator,
|
||||
buildLoginRedirect,
|
||||
redactUserId,
|
||||
FLOW_INITIATOR_MISMATCH_MESSAGE,
|
||||
} from '../oauth-flow-binding'
|
||||
|
||||
const CALLBACK_URL =
|
||||
'https://app.example.se/api/extensions/stripe/callback?code=ac_123&state=state-1'
|
||||
|
||||
function mockSession(getUser: ReturnType<typeof vi.fn>) {
|
||||
vi.mocked(createClient).mockResolvedValue({ auth: { getUser } } as never)
|
||||
return getUser
|
||||
}
|
||||
|
||||
function sessionWith(userId: string | null, error: unknown = null) {
|
||||
return mockSession(
|
||||
vi.fn().mockResolvedValue({
|
||||
data: { user: userId ? { id: userId } : null },
|
||||
error,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('requireFlowInitiator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.se')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('passes when the cookie session belongs to the initiator', async () => {
|
||||
const getUser = sessionWith('user-1')
|
||||
|
||||
const result = await requireFlowInitiator(new Request(CALLBACK_URL), 'user-1')
|
||||
|
||||
expect(result).toEqual({ ok: true, userId: 'user-1' })
|
||||
expect(getUser).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refuses with a 403 envelope when a different user completes the flow', async () => {
|
||||
sessionWith('user-2')
|
||||
|
||||
const result = await requireFlowInitiator(new Request(CALLBACK_URL), 'user-1', {
|
||||
flow: 'stripe.callback',
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) throw new Error('unreachable')
|
||||
expect(result.reason).toBe('mismatch')
|
||||
if (result.reason !== 'mismatch') throw new Error('unreachable')
|
||||
expect(result.sessionUserId).toBe('user-2')
|
||||
expect(result.response.status).toBe(403)
|
||||
const body = await result.response.json()
|
||||
expect(body.error.code).toBe('OAUTH_FLOW_INITIATOR_MISMATCH')
|
||||
expect(body.error.message).toBe(FLOW_INITIATOR_MISMATCH_MESSAGE)
|
||||
expect(typeof body.error.message_en).toBe('string')
|
||||
})
|
||||
|
||||
it('sends an anonymous browser to /login with the callback URL as next', async () => {
|
||||
sessionWith(null)
|
||||
|
||||
const result = await requireFlowInitiator(new Request(CALLBACK_URL), 'user-1')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) throw new Error('unreachable')
|
||||
expect(result.reason).toBe('no_session')
|
||||
expect(result.response.status).toBe(307)
|
||||
const location = new URL(result.response.headers.get('location') ?? '')
|
||||
expect(location.origin).toBe('https://app.example.se')
|
||||
expect(location.pathname).toBe('/login')
|
||||
// Same-origin relative path + query, the only shape the login page's
|
||||
// safeReturnTo accepts, so signing in resumes the very same callback.
|
||||
expect(location.searchParams.get('next')).toBe(
|
||||
'/api/extensions/stripe/callback?code=ac_123&state=state-1',
|
||||
)
|
||||
})
|
||||
|
||||
it('treats a getUser error as no session (fail closed)', async () => {
|
||||
sessionWith(null, { message: 'invalid JWT' })
|
||||
|
||||
const result = await requireFlowInitiator(new Request(CALLBACK_URL), 'user-1')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) throw new Error('unreachable')
|
||||
expect(result.reason).toBe('no_session')
|
||||
})
|
||||
|
||||
it('treats a thrown client error as no session instead of finalizing on a guess', async () => {
|
||||
vi.mocked(createClient).mockRejectedValue(new Error('cookies() outside request scope'))
|
||||
|
||||
const result = await requireFlowInitiator(new Request(CALLBACK_URL), 'user-1')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) throw new Error('unreachable')
|
||||
expect(result.reason).toBe('no_session')
|
||||
expect(result.response.headers.get('location')).toContain('/login?next=')
|
||||
})
|
||||
|
||||
it('never passes on an empty expected id even when someone is signed in', async () => {
|
||||
sessionWith('user-2')
|
||||
|
||||
const result = await requireFlowInitiator(new Request(CALLBACK_URL), '')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildLoginRedirect', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('falls back to the request origin when NEXT_PUBLIC_APP_URL is unset', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', '')
|
||||
|
||||
const response = buildLoginRedirect(
|
||||
new Request('http://localhost:3000/api/extensions/woocommerce/return?success=1&user_id=abc'),
|
||||
)
|
||||
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'http://localhost:3000/login?next=' +
|
||||
encodeURIComponent('/api/extensions/woocommerce/return?success=1&user_id=abc'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('redactUserId', () => {
|
||||
it('keeps only a correlation prefix of a uuid', () => {
|
||||
expect(redactUserId('123e4567-e89b-12d3-a456-426614174000')).toBe('123e4567...')
|
||||
})
|
||||
|
||||
it('handles short and missing ids', () => {
|
||||
expect(redactUserId('user-1')).toBe('user-1')
|
||||
expect(redactUserId(null)).toBe('(none)')
|
||||
expect(redactUserId(undefined)).toBe('(none)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
logError: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
logInfo: vi.fn(),
|
||||
redisCtor: vi.fn(),
|
||||
ratelimitCtor: vi.fn(),
|
||||
limit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
createLogger: () => ({
|
||||
error: mocks.logError,
|
||||
warn: mocks.logWarn,
|
||||
info: mocks.logInfo,
|
||||
child: () => ({ error: mocks.logError, warn: mocks.logWarn, info: mocks.logInfo }),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@upstash/redis', () => ({
|
||||
Redis: class Redis {
|
||||
constructor(cfg: unknown) {
|
||||
mocks.redisCtor(cfg)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@upstash/ratelimit', () => ({
|
||||
Ratelimit: class Ratelimit {
|
||||
static slidingWindow = vi.fn((max: number, window: string) => ({ max, window }))
|
||||
limit = mocks.limit
|
||||
constructor(cfg: unknown) {
|
||||
mocks.ratelimitCtor(cfg)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// The "report once per process" flag is module state, so every test gets a
|
||||
// fresh module instance instead of a test-only reset export.
|
||||
async function loadModule() {
|
||||
vi.resetModules()
|
||||
return import('@/lib/auth/rate-limit-http')
|
||||
}
|
||||
|
||||
const OPTS = { prefix: 'test', identifier: 'ip:1', maxRequests: 5, windowMs: 60_000 }
|
||||
|
||||
describe('checkRateLimit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubEnv('UPSTASH_REDIS_REST_URL', '')
|
||||
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', '')
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '')
|
||||
vi.stubEnv('NODE_ENV', 'production')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('Redis not configured', () => {
|
||||
it('fails open on a hosted production deployment but logs one error with the alert flag', async () => {
|
||||
const { checkRateLimit } = await loadModule()
|
||||
|
||||
expect(await checkRateLimit(OPTS)).toEqual({ ok: true })
|
||||
expect(await checkRateLimit({ ...OPTS, identifier: 'ip:2' })).toEqual({ ok: true })
|
||||
expect(await checkRateLimit({ ...OPTS, prefix: 'other' })).toEqual({ ok: true })
|
||||
|
||||
expect(mocks.logError).toHaveBeenCalledTimes(1)
|
||||
const [message, ctx] = mocks.logError.mock.calls[0]
|
||||
expect(message).toMatch(/UPSTASH_REDIS_REST_URL/)
|
||||
expect(message).toMatch(/failing open/)
|
||||
expect(ctx).toMatchObject({ alert: true })
|
||||
expect(mocks.redisCtor).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays quiet on a self-hosted deployment (Redis is optional there)', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
||||
const { checkRateLimit } = await loadModule()
|
||||
|
||||
expect(await checkRateLimit(OPTS)).toEqual({ ok: true })
|
||||
expect(mocks.logError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays quiet outside production (local dev and tests are not deployments)', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'development')
|
||||
const { checkRateLimit } = await loadModule()
|
||||
|
||||
expect(await checkRateLimit(OPTS)).toEqual({ ok: true })
|
||||
expect(mocks.logError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the limiter as not configured', async () => {
|
||||
const { isRateLimiterConfigured } = await loadModule()
|
||||
expect(isRateLimiterConfigured()).toBe(false)
|
||||
|
||||
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.example')
|
||||
expect(isRateLimiterConfigured()).toBe(false)
|
||||
|
||||
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'tok')
|
||||
expect(isRateLimiterConfigured()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Redis configured', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.example')
|
||||
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'tok')
|
||||
})
|
||||
|
||||
it('allows the request when the sliding window has room and logs nothing', async () => {
|
||||
mocks.limit.mockResolvedValue({ success: true, reset: Date.now() + 1000, limit: 5, remaining: 4 })
|
||||
const { checkRateLimit } = await loadModule()
|
||||
|
||||
expect(await checkRateLimit(OPTS)).toEqual({ ok: true })
|
||||
expect(mocks.limit).toHaveBeenCalledWith('ip:1')
|
||||
expect(mocks.redisCtor).toHaveBeenCalledWith({ url: 'https://redis.example', token: 'tok' })
|
||||
expect(mocks.ratelimitCtor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prefix: 'test', analytics: false }),
|
||||
)
|
||||
expect(mocks.logError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a Swedish 429 with Retry-After and X-RateLimit headers when blocked', async () => {
|
||||
const reset = Date.now() + 30_000
|
||||
mocks.limit.mockResolvedValue({ success: false, reset, limit: 5, remaining: 0 })
|
||||
const { checkRateLimit } = await loadModule()
|
||||
|
||||
const result = await checkRateLimit(OPTS)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.response?.status).toBe(429)
|
||||
expect(await result.response?.json()).toEqual({
|
||||
error: 'För många förfrågningar. Försök igen om en stund.',
|
||||
})
|
||||
const retryAfter = Number(result.response?.headers.get('Retry-After'))
|
||||
expect(retryAfter).toBeGreaterThanOrEqual(29)
|
||||
expect(retryAfter).toBeLessThanOrEqual(31)
|
||||
expect(result.response?.headers.get('X-RateLimit-Limit')).toBe('5')
|
||||
expect(result.response?.headers.get('X-RateLimit-Remaining')).toBe('0')
|
||||
expect(result.response?.headers.get('X-RateLimit-Reset')).toBe(String(Math.ceil(reset / 1000)))
|
||||
})
|
||||
|
||||
it('reuses one limiter per prefix/limit/window triple', async () => {
|
||||
mocks.limit.mockResolvedValue({ success: true, reset: 0, limit: 5, remaining: 4 })
|
||||
const { checkRateLimit } = await loadModule()
|
||||
|
||||
await checkRateLimit(OPTS)
|
||||
await checkRateLimit({ ...OPTS, identifier: 'ip:9' })
|
||||
await checkRateLimit({ ...OPTS, maxRequests: 50 })
|
||||
|
||||
expect(mocks.ratelimitCtor).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.redisCtor).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -31,6 +31,22 @@ const MOCK_USER = {
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const VERIFIED_TOTP = { id: 'f1', status: 'verified', factor_type: 'totp' }
|
||||
const UNVERIFIED_TOTP = { id: 'f2', status: 'unverified', factor_type: 'totp' }
|
||||
|
||||
/** listFactors() payload: `all` carries everything, the typed arrays only verified ones. */
|
||||
function factorList(...factors: Array<typeof VERIFIED_TOTP>) {
|
||||
return {
|
||||
data: {
|
||||
all: factors,
|
||||
totp: factors.filter((f) => f.status === 'verified'),
|
||||
phone: [],
|
||||
webauthn: [],
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
type MockAuth = Record<string, unknown>
|
||||
|
||||
function useSupabase(auth: MockAuth) {
|
||||
@@ -39,6 +55,11 @@ function useSupabase(auth: MockAuth) {
|
||||
return supabase
|
||||
}
|
||||
|
||||
function enableMfa() {
|
||||
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '')
|
||||
}
|
||||
|
||||
describe('requireAuth', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -142,53 +163,179 @@ describe('requireAuth', () => {
|
||||
expect(getUser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 403 when MFA is required and AAL2 is not verified', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '')
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
|
||||
const getAuthenticatorAssuranceLevel = vi.fn().mockResolvedValue({
|
||||
data: { currentLevel: 'aal1', nextLevel: 'aal2' },
|
||||
error: null,
|
||||
describe('MFA gate', () => {
|
||||
beforeEach(() => {
|
||||
enableMfa()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
useSupabase({ getClaims, mfa: { getAuthenticatorAssuranceLevel } })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.user).toBeNull()
|
||||
expect(result.error?.status).toBe(403)
|
||||
const body = await result.error?.json()
|
||||
expect(body).toEqual({ error: 'MFA verification required' })
|
||||
})
|
||||
|
||||
it('skips the MFA check for bankid_linked users', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '')
|
||||
const claims = { ...CLAIMS, app_metadata: { provider: 'email', bankid_linked: true } }
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims }, error: null })
|
||||
const getAuthenticatorAssuranceLevel = vi.fn()
|
||||
useSupabase({ getClaims, mfa: { getAuthenticatorAssuranceLevel } })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.user?.id).toBe('user-1')
|
||||
expect(getAuthenticatorAssuranceLevel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes when MFA is required and the session is already AAL2', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', '')
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
|
||||
const getAuthenticatorAssuranceLevel = vi.fn().mockResolvedValue({
|
||||
data: { currentLevel: 'aal2', nextLevel: 'aal2' },
|
||||
error: null,
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
useSupabase({ getClaims, mfa: { getAuthenticatorAssuranceLevel } })
|
||||
|
||||
const result = await requireAuth()
|
||||
it('returns 403 for an AAL1 session when the auth server reports a verified factor', async () => {
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
|
||||
const listFactors = vi.fn().mockResolvedValue(factorList(VERIFIED_TOTP))
|
||||
useSupabase({ getClaims, mfa: { listFactors } })
|
||||
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.user?.id).toBe('user-1')
|
||||
expect(getAuthenticatorAssuranceLevel).toHaveBeenCalledTimes(1)
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.user).toBeNull()
|
||||
expect(result.error?.status).toBe(403)
|
||||
const body = await result.error?.json()
|
||||
expect(body).toEqual({ error: 'MFA verification required' })
|
||||
expect(listFactors).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('never consults the cookie-derived assurance level', async () => {
|
||||
// The attack: the sb-*-auth-token cookie is unsigned JSON, so the
|
||||
// password holder strips `user.factors` and the local
|
||||
// getAuthenticatorAssuranceLevel() reports nextLevel aal1 ("no MFA
|
||||
// enrolled"). The gate must decide on the server's answer instead.
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
|
||||
const getAuthenticatorAssuranceLevel = vi.fn().mockResolvedValue({
|
||||
data: { currentLevel: 'aal1', nextLevel: 'aal1' },
|
||||
error: null,
|
||||
})
|
||||
const listFactors = vi.fn().mockResolvedValue(factorList(VERIFIED_TOTP))
|
||||
useSupabase({ getClaims, mfa: { getAuthenticatorAssuranceLevel, listFactors } })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error?.status).toBe(403)
|
||||
expect(getAuthenticatorAssuranceLevel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes an AAL2 session on the verified claims alone (no listFactors round trip)', async () => {
|
||||
const claims = { ...CLAIMS, aal: 'aal2' }
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims }, error: null })
|
||||
const listFactors = vi.fn()
|
||||
const getAuthenticatorAssuranceLevel = vi.fn()
|
||||
useSupabase({ getClaims, mfa: { listFactors, getAuthenticatorAssuranceLevel } })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.user?.id).toBe('user-1')
|
||||
expect(listFactors).not.toHaveBeenCalled()
|
||||
expect(getAuthenticatorAssuranceLevel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes an AAL1 session when the auth server reports no verified factor', async () => {
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
|
||||
const listFactors = vi.fn().mockResolvedValue(factorList())
|
||||
useSupabase({ getClaims, mfa: { listFactors } })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.user?.id).toBe('user-1')
|
||||
expect(listFactors).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not count an unverified (enrolment in progress) factor as a step-up target', async () => {
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
|
||||
const listFactors = vi.fn().mockResolvedValue(factorList(UNVERIFIED_TOTP))
|
||||
useSupabase({ getClaims, mfa: { listFactors } })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error).toBeNull()
|
||||
})
|
||||
|
||||
it('reads a verified phone factor from the typed array when `all` is absent', async () => {
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
|
||||
const listFactors = vi.fn().mockResolvedValue({
|
||||
data: { phone: [{ id: 'p1', status: 'verified', factor_type: 'phone' }] },
|
||||
error: null,
|
||||
})
|
||||
useSupabase({ getClaims, mfa: { listFactors } })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error?.status).toBe(403)
|
||||
})
|
||||
|
||||
it('treats a session whose verified claims carry no aal as not assured', async () => {
|
||||
const { aal: _aal, ...claims } = CLAIMS
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims }, error: null })
|
||||
const listFactors = vi.fn().mockResolvedValue(factorList(VERIFIED_TOTP))
|
||||
useSupabase({ getClaims, mfa: { listFactors } })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error?.status).toBe(403)
|
||||
})
|
||||
|
||||
it('fails closed when listFactors returns an error', async () => {
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
|
||||
const listFactors = vi.fn().mockResolvedValue({
|
||||
data: null,
|
||||
error: { name: 'AuthApiError', message: 'upstream unavailable' },
|
||||
})
|
||||
useSupabase({ getClaims, mfa: { listFactors } })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error?.status).toBe(403)
|
||||
})
|
||||
|
||||
it('fails closed when listFactors throws', async () => {
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
|
||||
const listFactors = vi.fn().mockRejectedValue(new Error('network down'))
|
||||
useSupabase({ getClaims, mfa: { listFactors } })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error?.status).toBe(403)
|
||||
})
|
||||
|
||||
it('fails closed when the client exposes no mfa API at all', async () => {
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims: CLAIMS }, error: null })
|
||||
useSupabase({ getClaims })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error?.status).toBe(403)
|
||||
})
|
||||
|
||||
it('asks listFactors on the getUser fallback path and refuses a verified factor', async () => {
|
||||
// No verified claims exist here (getClaims failed), so the level is
|
||||
// unknown: a verified factor means the session must be refused.
|
||||
const getClaims = vi.fn().mockRejectedValue(new Error('jwks fetch failed'))
|
||||
const getUser = vi.fn().mockResolvedValue({ data: { user: MOCK_USER }, error: null })
|
||||
const listFactors = vi.fn().mockResolvedValue(factorList(VERIFIED_TOTP))
|
||||
useSupabase({ getClaims, getUser, mfa: { listFactors } })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error?.status).toBe(403)
|
||||
expect(listFactors).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('passes a factor-less user on the getUser fallback path', async () => {
|
||||
const getUser = vi.fn().mockResolvedValue({ data: { user: MOCK_USER }, error: null })
|
||||
const listFactors = vi.fn().mockResolvedValue(factorList())
|
||||
useSupabase({ getUser, mfa: { listFactors } })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.user?.id).toBe('user-1')
|
||||
expect(listFactors).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips the MFA check for bankid_linked users', async () => {
|
||||
const claims = { ...CLAIMS, app_metadata: { provider: 'email', bankid_linked: true } }
|
||||
const getClaims = vi.fn().mockResolvedValue({ data: { claims }, error: null })
|
||||
const listFactors = vi.fn()
|
||||
useSupabase({ getClaims, mfa: { listFactors } })
|
||||
|
||||
const result = await requireAuth()
|
||||
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.user?.id).toBe('user-1')
|
||||
expect(listFactors).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+193
-26
@@ -1,5 +1,22 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createServiceClientNoCookies } from './api-keys'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { scopeKind, type ApiKeyScope } from './scope-catalog'
|
||||
|
||||
/**
|
||||
* What an OAuth consent may grant: which redirect URIs a code may be sent
|
||||
* to, who the client behind a URI is, and which scopes the consenting user's
|
||||
* role in the selected company permits.
|
||||
*/
|
||||
|
||||
// ── Redirect URI allowlist ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Identity of a built-in client, derived from the redirect URI pattern that
|
||||
* matched. Rendered on the consent page so the user can tell a real Claude /
|
||||
* ChatGPT connector from a look-alike registration.
|
||||
*/
|
||||
export type BuiltInProvider = 'claude' | 'chatgpt' | 'local'
|
||||
|
||||
/**
|
||||
* Built-in redirect URI patterns. These bypass the DB lookup entirely so
|
||||
@@ -11,38 +28,81 @@ import { createServiceClientNoCookies } from './api-keys'
|
||||
* callback for already-published apps; both are documented at
|
||||
* developers.openai.com/apps-sdk/build/auth.
|
||||
*/
|
||||
export const BUILT_IN_REDIRECT_PATTERNS: readonly RegExp[] = [
|
||||
/^https:\/\/claude\.ai\/api\//,
|
||||
/^https:\/\/claude\.com\/api\//,
|
||||
/^https:\/\/chatgpt\.com\/connector\/oauth\//,
|
||||
/^https:\/\/chatgpt\.com\/connector_platform_oauth_redirect$/,
|
||||
/^http:\/\/localhost(:\d+)?(\/|$)/,
|
||||
/^http:\/\/127\.0\.0\.1(:\d+)?(\/|$)/,
|
||||
const BUILT_IN_PATTERNS: readonly { pattern: RegExp; provider: BuiltInProvider }[] = [
|
||||
{ pattern: /^https:\/\/claude\.ai\/api\//, provider: 'claude' },
|
||||
{ pattern: /^https:\/\/claude\.com\/api\//, provider: 'claude' },
|
||||
{ pattern: /^https:\/\/chatgpt\.com\/connector\/oauth\//, provider: 'chatgpt' },
|
||||
{ pattern: /^https:\/\/chatgpt\.com\/connector_platform_oauth_redirect$/, provider: 'chatgpt' },
|
||||
{ pattern: /^http:\/\/localhost(:\d+)?(\/|$)/, provider: 'local' },
|
||||
{ pattern: /^http:\/\/127\.0\.0\.1(:\d+)?(\/|$)/, provider: 'local' },
|
||||
]
|
||||
|
||||
export function isBuiltInRedirectUri(uri: string): boolean {
|
||||
return BUILT_IN_REDIRECT_PATTERNS.some((pattern) => pattern.test(uri))
|
||||
export const BUILT_IN_REDIRECT_PATTERNS: readonly RegExp[] = BUILT_IN_PATTERNS.map((p) => p.pattern)
|
||||
|
||||
/** Which built-in client a redirect URI belongs to, or null when none matches. */
|
||||
export function builtInRedirectProvider(uri: string): BuiltInProvider | null {
|
||||
if (typeof uri !== 'string') return null
|
||||
return BUILT_IN_PATTERNS.find(({ pattern }) => pattern.test(uri))?.provider ?? null
|
||||
}
|
||||
|
||||
export function isBuiltInRedirectUri(uri: string): boolean {
|
||||
return builtInRedirectProvider(uri) !== null
|
||||
}
|
||||
|
||||
export type RedirectUriResolution =
|
||||
| { allowed: true; kind: 'built_in'; provider: BuiltInProvider }
|
||||
| {
|
||||
allowed: true
|
||||
kind: 'registered'
|
||||
/** Display name the registering user gave the client (settings UI). */
|
||||
clientName: string
|
||||
/** True when the consenting user registered the URI themselves. */
|
||||
registeredByConsentingUser: boolean
|
||||
}
|
||||
| { allowed: false }
|
||||
|
||||
export interface RedirectUriOptions {
|
||||
/**
|
||||
* The user about to consent at /authorize. When set, a DB-registered URI is
|
||||
* accepted only if this user registered it, or shares at least one company
|
||||
* with the user who did. Any authenticated user can insert into
|
||||
* oauth_client_registrations (RLS: user_id = auth.uid()), so without this
|
||||
* binding a stranger's registration would be a valid phishing target for
|
||||
* every account on the instance. Built-in patterns are unaffected.
|
||||
*
|
||||
* Omitted by the anonymous /register endpoint, which has no user to bind
|
||||
* to: it accepts any active registration, which is harmless because the
|
||||
* code is only ever minted at /authorize where the binding is enforced.
|
||||
*/
|
||||
consentingUserId?: string
|
||||
}
|
||||
|
||||
type RegistrationRow = { id: string; user_id: string; client_name: string }
|
||||
|
||||
/**
|
||||
* Resolve whether a redirect URI is allowed. Built-in patterns short-circuit;
|
||||
* otherwise we look for a non-revoked registration in oauth_client_registrations.
|
||||
* Resolve a redirect URI to the client behind it. Built-in patterns
|
||||
* short-circuit; otherwise we look for a non-revoked registration in
|
||||
* oauth_client_registrations and, when a consenting user is given, check
|
||||
* that the registration is theirs or a colleague's.
|
||||
*
|
||||
* The supabase client should be supplied explicitly by the caller so the
|
||||
* trust boundary is visible at the callsite (SOC 2 CC6.1). When omitted, the
|
||||
* function falls back to a service-role client: required for the /register
|
||||
* endpoint which has no user session yet. The lookup is by exact URI; the
|
||||
* unique partial index on the table ensures at most one active row.
|
||||
* The lookup runs with the service role. The table's SELECT policy is
|
||||
* user_id = auth.uid(), so a user-scoped client cannot see a colleague's
|
||||
* registration at all; the trust boundary is instead the explicit binding to
|
||||
* `consentingUserId` below (SOC 2 CC6.1). Callers may pass a client (the
|
||||
* /register endpoint already holds one); otherwise one is constructed here.
|
||||
*
|
||||
* Fails closed on any error (client construction, DB query): for an
|
||||
* allowlist, "unknown → deny" is the safe default.
|
||||
* allowlist, "unknown → deny" is the safe default. The lookup is by exact
|
||||
* URI; the unique partial index on the table ensures at most one active row.
|
||||
*/
|
||||
export async function isAllowedRedirectUri(
|
||||
export async function resolveRedirectUri(
|
||||
uri: string,
|
||||
supabase?: SupabaseClient
|
||||
): Promise<boolean> {
|
||||
if (typeof uri !== 'string' || uri.length === 0) return false
|
||||
if (isBuiltInRedirectUri(uri)) return true
|
||||
supabase?: SupabaseClient,
|
||||
options: RedirectUriOptions = {},
|
||||
): Promise<RedirectUriResolution> {
|
||||
if (typeof uri !== 'string' || uri.length === 0) return { allowed: false }
|
||||
const provider = builtInRedirectProvider(uri)
|
||||
if (provider) return { allowed: true, kind: 'built_in', provider }
|
||||
|
||||
// Service-role client construction can throw when Supabase env vars are
|
||||
// absent (unit tests, misconfigured deploys). Treat that as "not allowed":
|
||||
@@ -51,17 +111,124 @@ export async function isAllowedRedirectUri(
|
||||
try {
|
||||
client = supabase ?? createServiceClientNoCookies()
|
||||
} catch {
|
||||
return false
|
||||
return { allowed: false }
|
||||
}
|
||||
|
||||
const { data, error } = await client
|
||||
.from('oauth_client_registrations')
|
||||
.select('id')
|
||||
.select('id, user_id, client_name')
|
||||
.eq('redirect_uri', uri)
|
||||
.is('revoked_at', null)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) return false
|
||||
return data !== null
|
||||
if (error || !data) return { allowed: false }
|
||||
const registration = data as RegistrationRow
|
||||
|
||||
const { consentingUserId } = options
|
||||
if (consentingUserId === undefined) {
|
||||
return { allowed: true, kind: 'registered', clientName: registration.client_name, registeredByConsentingUser: false }
|
||||
}
|
||||
|
||||
if (registration.user_id === consentingUserId) {
|
||||
return { allowed: true, kind: 'registered', clientName: registration.client_name, registeredByConsentingUser: true }
|
||||
}
|
||||
|
||||
const shared = await usersShareCompany(client, consentingUserId, registration.user_id)
|
||||
if (!shared) return { allowed: false }
|
||||
return { allowed: true, kind: 'registered', clientName: registration.client_name, registeredByConsentingUser: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Boolean view of resolveRedirectUri, kept for the callers that only need
|
||||
* the allow/deny answer (the /register endpoint and its tests).
|
||||
*/
|
||||
export async function isAllowedRedirectUri(
|
||||
uri: string,
|
||||
supabase?: SupabaseClient,
|
||||
options: RedirectUriOptions = {},
|
||||
): Promise<boolean> {
|
||||
const resolution = await resolveRedirectUri(uri, supabase, options)
|
||||
return resolution.allowed
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the two users are both members of at least one common company.
|
||||
* Both membership lists are paginated (a byrå consultant can sit in hundreds
|
||||
* of companies) and intersected here rather than via an `.in()` filter, whose
|
||||
* URL length would grow with the membership count. Any query failure counts
|
||||
* as "not shared" (fail closed).
|
||||
*/
|
||||
async function usersShareCompany(
|
||||
client: SupabaseClient,
|
||||
userA: string,
|
||||
userB: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const companyIdsOf = (userId: string) =>
|
||||
fetchAllRows<{ company_id: string }>(({ from, to }) =>
|
||||
client
|
||||
.from('company_members')
|
||||
.select('company_id')
|
||||
.eq('user_id', userId)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
const [rowsA, rowsB] = await Promise.all([companyIdsOf(userA), companyIdsOf(userB)])
|
||||
const companiesA = new Set(rowsA.map((r) => r.company_id))
|
||||
return rowsB.some((r) => companiesA.has(r.company_id))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Role ceiling ──────────────────────────────────────────────
|
||||
|
||||
/** Company roles whose members may hold write, manage, approve or signoff scopes. */
|
||||
const WRITER_ROLES: ReadonlySet<string> = new Set(['owner', 'admin', 'member'])
|
||||
|
||||
/**
|
||||
* Cap a scope set to what the consenting user's role in the selected company
|
||||
* permits. Mirrors the app's own gate: `viewer` is read-only everywhere
|
||||
* (withRouteContext requireWrite, the DB-level enforce_company_writer_role
|
||||
* trigger), while owner/admin/member may hold every scope. The stage+approve
|
||||
* segregation-of-duties combination is not capped by role, matching
|
||||
* app/api/settings/api-keys (warn, acknowledge, record), so the consent page
|
||||
* states the rule and the token route records the acknowledgement.
|
||||
*
|
||||
* A null role (no membership row) or an unrecognised role string caps to
|
||||
* read-only: an unknown privilege level must never widen the grant.
|
||||
*/
|
||||
export function capScopesForRole(
|
||||
scopes: readonly ApiKeyScope[],
|
||||
role: string | null,
|
||||
): ApiKeyScope[] {
|
||||
if (role !== null && WRITER_ROLES.has(role)) return [...scopes]
|
||||
return scopes.filter((s) => scopeKind(s) === 'read')
|
||||
}
|
||||
|
||||
export type CompanyRoleLookup =
|
||||
| { role: string | null; error: null }
|
||||
| { role: null; error: string }
|
||||
|
||||
/**
|
||||
* The user's role in a company, or null when no membership row exists. A
|
||||
* failed query is reported separately so callers can fail loudly instead of
|
||||
* silently downgrading (or widening) a grant on a transient error.
|
||||
*/
|
||||
export async function lookupCompanyRole(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
): Promise<CompanyRoleLookup> {
|
||||
const { data, error } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) return { role: null, error: error.message }
|
||||
const role = (data as { role?: unknown } | null)?.role
|
||||
return { role: typeof role === 'string' ? role : null, error: null }
|
||||
}
|
||||
|
||||
@@ -28,6 +28,14 @@ export interface AuthCodePayload {
|
||||
* to ALL_SCOPES so existing Claude flows are unaffected.
|
||||
*/
|
||||
scopes?: string[]
|
||||
/**
|
||||
* Company shown on the consent page and used to cap `scopes` to the user's
|
||||
* role there. The token route binds the key to it and re-checks the role
|
||||
* cap against it. Null for an account with no company yet (issue #1814);
|
||||
* undefined on codes minted before the field existed, where the token
|
||||
* route falls back to resolving the active company itself.
|
||||
*/
|
||||
companyId?: string | null
|
||||
exp: number
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
/**
|
||||
* Bind the completion of a browser-driven OAuth/consent flow to the user who
|
||||
* started it.
|
||||
*
|
||||
* Our OAuth callbacks (Enable Banking, Stripe Connect, WooCommerce wc-auth)
|
||||
* locate the pending connection row by the single-use `oauth_state` token and
|
||||
* then finalize it for that row's `user_id` / `company_id`. The token proves
|
||||
* the callback belongs to a flow WE started; it does not prove that the
|
||||
* browser completing it belongs to the user who started it. Without this
|
||||
* check, a victim who is lured into completing a consent an attacker
|
||||
* initiated (the authorize URL is shareable) has their bank / Stripe / store
|
||||
* attached to the attacker's company.
|
||||
*
|
||||
* Consent redirects are top-level navigations, so on the legitimate path the
|
||||
* initiator's own session cookies arrive with the callback. This helper reads
|
||||
* that cookie session and compares it to the expected initiator.
|
||||
*
|
||||
* Outcomes:
|
||||
* - ok: the session user is the initiator; carry on.
|
||||
* - no_session: nobody is signed in (expired mid-flow, cookies cleared).
|
||||
* `response` redirects to /login?next=<this callback URL> so the initiator
|
||||
* can sign in and the callback re-runs with the same code + state.
|
||||
* - mismatch: a different user is signed in. `response` is a 403 in the
|
||||
* canonical error envelope; a route whose UX is a settings redirect
|
||||
* inspects `reason` and builds its own redirect instead. The mismatch is
|
||||
* logged with both ids redacted to prefixes.
|
||||
*
|
||||
* Deliberately not `requireAuth()`: this is an equality check on identity,
|
||||
* not an authorization gate. The route that STARTED the flow already ran the
|
||||
* MFA-enforcing guard for this user, and a 403 here for an aal1 session would
|
||||
* strand the user (the callback has no MFA prompt to send them to).
|
||||
*/
|
||||
|
||||
const log = createLogger('auth/oauth-flow-binding')
|
||||
|
||||
/** Swedish user-facing message for the mismatch outcome (shared by callers). */
|
||||
export const FLOW_INITIATOR_MISMATCH_MESSAGE =
|
||||
'Anslutningen kunde inte slutföras: den startades från ett annat användarkonto än det du är inloggad med. Logga in med det kontot eller starta anslutningen på nytt.'
|
||||
|
||||
export const FLOW_INITIATOR_MISMATCH_MESSAGE_EN =
|
||||
'The connection could not be completed: it was started from a different user account than the one you are signed in with. Sign in with that account or start the connection again.'
|
||||
|
||||
export type FlowInitiatorResult =
|
||||
| { ok: true; userId: string }
|
||||
| { ok: false; reason: 'no_session'; response: Response }
|
||||
| { ok: false; reason: 'mismatch'; response: Response; sessionUserId: string }
|
||||
|
||||
export interface RequireFlowInitiatorOptions {
|
||||
/** Short label for the log line, e.g. 'stripe.callback'. */
|
||||
flow?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorten a user id to a stable prefix for log lines. Enough to correlate two
|
||||
* log records, not enough to identify the account outside the database.
|
||||
*/
|
||||
export function redactUserId(id: string | null | undefined): string {
|
||||
if (!id) return '(none)'
|
||||
return id.length <= 8 ? id : `${id.slice(0, 8)}...`
|
||||
}
|
||||
|
||||
/**
|
||||
* The /login redirect for a callback reached without a session. `next` is the
|
||||
* callback's own path + query (same-origin relative, which is the only form
|
||||
* the login page's safeReturnTo accepts), so signing in resumes the flow.
|
||||
*/
|
||||
export function buildLoginRedirect(request: Request): Response {
|
||||
const current = new URL(request.url)
|
||||
const appOrigin = process.env.NEXT_PUBLIC_APP_URL || current.origin
|
||||
const next = `${current.pathname}${current.search}`
|
||||
const login = new URL('/login', appOrigin)
|
||||
login.searchParams.set('next', next)
|
||||
return NextResponse.redirect(login.toString())
|
||||
}
|
||||
|
||||
export async function requireFlowInitiator(
|
||||
request: Request,
|
||||
expectedUserId: string,
|
||||
options: RequireFlowInitiatorOptions = {},
|
||||
): Promise<FlowInitiatorResult> {
|
||||
const flow = options.flow ?? 'oauth-callback'
|
||||
const path = new URL(request.url).pathname
|
||||
|
||||
let sessionUserId: string | null = null
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (!error && data?.user?.id) sessionUserId = data.user.id
|
||||
} catch (err) {
|
||||
// Fail closed: an auth outage or a missing request scope is treated as
|
||||
// "no session". The login redirect below re-runs the callback once a
|
||||
// session can be read, nothing is finalized on a guess.
|
||||
log.error('could not read the cookie session for an OAuth callback', err as Error, {
|
||||
flow,
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
if (!sessionUserId) {
|
||||
log.warn('oauth callback reached without a session; sending to login', {
|
||||
flow,
|
||||
path,
|
||||
expectedUser: redactUserId(expectedUserId),
|
||||
})
|
||||
return { ok: false, reason: 'no_session', response: buildLoginRedirect(request) }
|
||||
}
|
||||
|
||||
if (sessionUserId !== expectedUserId) {
|
||||
log.warn('oauth callback completed by a different user than the initiator', {
|
||||
flow,
|
||||
path,
|
||||
expectedUser: redactUserId(expectedUserId),
|
||||
sessionUser: redactUserId(sessionUserId),
|
||||
alert: true,
|
||||
})
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'OAUTH_FLOW_INITIATOR_MISMATCH',
|
||||
message: FLOW_INITIATOR_MISMATCH_MESSAGE,
|
||||
message_en: FLOW_INITIATOR_MISMATCH_MESSAGE_EN,
|
||||
},
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
return { ok: false, reason: 'mismatch', response, sessionUserId }
|
||||
}
|
||||
|
||||
return { ok: true, userId: sessionUserId }
|
||||
}
|
||||
@@ -1,9 +1,22 @@
|
||||
import { Ratelimit } from '@upstash/ratelimit'
|
||||
import { Redis } from '@upstash/redis'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { isSelfHosted } from '@/lib/env/public-flags'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('auth.rate-limit')
|
||||
|
||||
let redis: Redis | null = null
|
||||
|
||||
/**
|
||||
* Whether the Upstash credentials the limiter needs are present. Exposed so
|
||||
* health / version surfaces can report the limiter's state instead of every
|
||||
* caller re-reading the env.
|
||||
*/
|
||||
export function isRateLimiterConfigured(): boolean {
|
||||
return Boolean(process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN)
|
||||
}
|
||||
|
||||
function getRedis(): Redis | null {
|
||||
if (redis) return redis
|
||||
const url = process.env.UPSTASH_REDIS_REST_URL
|
||||
@@ -33,6 +46,31 @@ function getLimiter(prefix: string, maxRequests: number, windowMs: number): Rate
|
||||
return limiter
|
||||
}
|
||||
|
||||
// Once per process: the first rate-limited request on a hosted deployment
|
||||
// without Redis produces one error record. Repeating it per request would
|
||||
// drown the logs the alert is meant to surface in.
|
||||
let reportedNotConfigured = false
|
||||
|
||||
/**
|
||||
* Fail-open is deliberate for local dev and self-hosted installs (no Redis
|
||||
* required to run the product). On the hosted product it is a
|
||||
* misconfiguration: every limiter-protected surface (MCP OAuth registration,
|
||||
* sandbox seeding, client log ingestion, webshop connects) is unthrottled.
|
||||
* Say so loudly, exactly once, at error level with the alert flag so the
|
||||
* observability sink pages on it. Never fail closed here: that would turn a
|
||||
* missing env var into a 503 on every protected route.
|
||||
*/
|
||||
function reportNotConfiguredOnce(): void {
|
||||
if (reportedNotConfigured) return
|
||||
reportedNotConfigured = true
|
||||
if (isSelfHosted()) return
|
||||
if (process.env.NODE_ENV !== 'production') return
|
||||
log.error(
|
||||
'HTTP rate limiting is disabled: UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN are not set on a hosted deployment; checkRateLimit() is failing open',
|
||||
{ alert: true, operation: 'rate-limit.not-configured' },
|
||||
)
|
||||
}
|
||||
|
||||
export interface RateLimitOptions {
|
||||
prefix: string
|
||||
identifier: string
|
||||
@@ -54,11 +92,16 @@ export interface RateLimitResult {
|
||||
* No-ops (allows the request) when Upstash env vars are not configured:
|
||||
* intentional so local dev and self-hosted deployments without Redis still work.
|
||||
* Production hosted deployments must set UPSTASH_REDIS_REST_URL/TOKEN for the
|
||||
* limit to be enforced; absence is logged once at startup by other call sites.
|
||||
* limit to be enforced; a hosted process without them logs one error-level
|
||||
* record (see reportNotConfiguredOnce) and `isRateLimiterConfigured()` reports
|
||||
* the state for health surfaces.
|
||||
*/
|
||||
export async function checkRateLimit(opts: RateLimitOptions): Promise<RateLimitResult> {
|
||||
const limiter = getLimiter(opts.prefix, opts.maxRequests, opts.windowMs)
|
||||
if (!limiter) return { ok: true }
|
||||
if (!limiter) {
|
||||
reportNotConfiguredOnce()
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const { success, reset, limit, remaining } = await limiter.limit(opts.identifier)
|
||||
if (success) return { ok: true }
|
||||
|
||||
+76
-15
@@ -1,7 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { shouldEnforceMfa } from './mfa'
|
||||
import type { User, SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { JwtPayload, User, SupabaseClient } from '@supabase/supabase-js'
|
||||
import { claimsPinned, userFromClaims } from './claims'
|
||||
|
||||
type AuthResult =
|
||||
@@ -32,19 +32,23 @@ export async function requireAuth(): Promise<AuthResult> {
|
||||
const supabase = await createClient()
|
||||
|
||||
let user: User | null = null
|
||||
// The signature-verified claims, kept for the MFA gate below: null on the
|
||||
// getUser fallback path, where no locally verified claims exist.
|
||||
let claims: JwtPayload | null = null
|
||||
try {
|
||||
// The typeof guard keeps legacy test mocks (auth object with only
|
||||
// getUser) on the old path.
|
||||
if (typeof supabase.auth.getClaims === 'function') {
|
||||
const { data } = await supabase.auth.getClaims()
|
||||
const claims = data?.claims
|
||||
if (claims?.sub) {
|
||||
if (claimsPinned(claims)) {
|
||||
user = userFromClaims(claims)
|
||||
const verified = data?.claims
|
||||
if (verified?.sub) {
|
||||
if (claimsPinned(verified)) {
|
||||
claims = verified
|
||||
user = userFromClaims(verified)
|
||||
} else {
|
||||
console.error('requireAuth: getClaims iss/aud pinning failed; falling back to getUser', {
|
||||
iss: claims.iss,
|
||||
aud: claims.aud,
|
||||
iss: verified.iss,
|
||||
aud: verified.aud,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -68,16 +72,73 @@ export async function requireAuth(): Promise<AuthResult> {
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldEnforceMfa(user)) {
|
||||
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
|
||||
if (aal?.nextLevel === 'aal2' && aal?.currentLevel !== 'aal2') {
|
||||
return {
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'MFA verification required' }, { status: 403 }),
|
||||
}
|
||||
if (shouldEnforceMfa(user) && !(await sessionIsMfaAssured(supabase, claims))) {
|
||||
return {
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'MFA verification required' }, { status: 403 }),
|
||||
}
|
||||
}
|
||||
|
||||
return { user, supabase, error: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session may pass the MFA gate.
|
||||
*
|
||||
* An AAL2 session, per the signature-verified claims, passes with no extra
|
||||
* round trip. Anything else (AAL1, no `aal` claim, or the getUser fallback
|
||||
* path where no verified claims exist) asks the auth server through
|
||||
* listFactors() (a getUser() call under the hood) whether a verified factor
|
||||
* exists: if one does, the session is stuck below the level it could reach
|
||||
* and is refused. A failed or throwing lookup is refused too (fail closed):
|
||||
* the alternative lets a transient auth error switch MFA off.
|
||||
*
|
||||
* Never `mfa.getAuthenticatorAssuranceLevel()` without a JWT: its `nextLevel`
|
||||
* is computed from `session.user.factors`, i.e. from the unsigned
|
||||
* sb-*-auth-token cookie, which whoever holds the password can edit to hide
|
||||
* the factor and turn an enrolled account into a "no MFA needed" one
|
||||
* (security audit 2026-09). The cost of the honest check is one listFactors
|
||||
* round trip per API request for AAL1 sessions of users without a factor.
|
||||
*/
|
||||
async function sessionIsMfaAssured(
|
||||
supabase: SupabaseClient,
|
||||
claims: JwtPayload | null,
|
||||
): Promise<boolean> {
|
||||
if (claims?.aal === 'aal2') return true
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.auth.mfa.listFactors()
|
||||
if (error || !data) {
|
||||
console.error('requireAuth: listFactors failed; treating session as not MFA-assured', error)
|
||||
return false
|
||||
}
|
||||
return !factorsIncludeVerified(data)
|
||||
} catch (err) {
|
||||
console.error('requireAuth: listFactors threw; treating session as not MFA-assured', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type FactorList = ReadonlyArray<{ status: string }> | undefined
|
||||
|
||||
/**
|
||||
* Whether a listFactors() payload contains a verified factor of any type.
|
||||
* `all` carries every factor; the typed arrays carry only the verified ones.
|
||||
* Both are consulted so a payload missing either shape still reads right.
|
||||
*/
|
||||
function factorsIncludeVerified(data: {
|
||||
all?: FactorList
|
||||
totp?: FactorList
|
||||
phone?: FactorList
|
||||
webauthn?: FactorList
|
||||
}): boolean {
|
||||
const verified = (list: FactorList) =>
|
||||
list?.some((factor) => factor.status === 'verified') ?? false
|
||||
return (
|
||||
verified(data.all) ||
|
||||
verified(data.totp) ||
|
||||
verified(data.phone) ||
|
||||
verified(data.webauthn)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
createPendingDocumentUpload,
|
||||
completePendingDocumentUpload,
|
||||
computeSHA256,
|
||||
resolveStoredMimeType,
|
||||
PENDING_DOCUMENT_UPLOAD_RETENTION_MS,
|
||||
SIGNED_DOCUMENT_UPLOAD_TTL_MS,
|
||||
isCompanyScopedDocumentPath,
|
||||
@@ -1243,3 +1244,170 @@ describe('verifyIntegrity', () => {
|
||||
expect(result.computedHash).not.toBe('stored-hash-abc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validated mime type persistence (stored type is what the bytes are)', () => {
|
||||
const company = '11111111-1111-4111-8111-111111111111'
|
||||
const user = '22222222-2222-4222-8222-222222222222'
|
||||
const uploadId = '33333333-3333-4333-8333-333333333333'
|
||||
|
||||
// 16-byte ISO-BMFF ftyp box with the given major brand (see the HEIC tests).
|
||||
const isoBmff = (brand: string): ArrayBuffer => {
|
||||
const bytes = new Uint8Array(16)
|
||||
bytes[3] = 16
|
||||
bytes.set([0x66, 0x74, 0x79, 0x70], 4)
|
||||
bytes.set(new TextEncoder().encode(brand), 8)
|
||||
return bytes.buffer as ArrayBuffer
|
||||
}
|
||||
const pngBytes = (): ArrayBuffer =>
|
||||
new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).buffer as ArrayBuffer
|
||||
const textBytes = (text: string): ArrayBuffer => {
|
||||
const bytes = new TextEncoder().encode(text)
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||
}
|
||||
|
||||
function insertPayloadOf(client: ReturnType<typeof makeClient>, fromIndex: number) {
|
||||
const builder = client.from.mock.results[fromIndex]?.value as { insert: ReturnType<typeof vi.fn> }
|
||||
return builder.insert.mock.calls[0]?.[0] as Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
describe('resolveStoredMimeType', () => {
|
||||
it('returns the sniffed type for binary formats, including the HEIC/HEIF family swap', () => {
|
||||
expect(resolveStoredMimeType(pdfBuffer(), 'application/pdf')).toBe('application/pdf')
|
||||
expect(resolveStoredMimeType(pngBytes(), 'image/png')).toBe('image/png')
|
||||
expect(resolveStoredMimeType(isoBmff('heic'), 'image/heif')).toBe('image/heic')
|
||||
expect(resolveStoredMimeType(isoBmff('mif1'), 'image/heic')).toBe('image/heif')
|
||||
})
|
||||
|
||||
it('keeps the declared type for the shape-checked text formats (no magic number)', () => {
|
||||
expect(resolveStoredMimeType(textBytes('<?xml version="1.0"?><Invoice/>'), 'application/xml')).toBe('application/xml')
|
||||
expect(resolveStoredMimeType(textBytes('<?xml version="1.0"?><Invoice/>'), 'text/xml')).toBe('text/xml')
|
||||
expect(resolveStoredMimeType(textBytes('<!doctype html><html></html>'), 'text/html')).toBe('text/html')
|
||||
expect(resolveStoredMimeType(textBytes('<?xml version="1.0"?><html/>'), 'application/xhtml+xml')).toBe('application/xhtml+xml')
|
||||
expect(resolveStoredMimeType(textBytes('{"a":1}'), 'application/json')).toBe('application/json')
|
||||
})
|
||||
|
||||
it('sniffs an undeclared type and stores null rather than an unverified string', () => {
|
||||
expect(resolveStoredMimeType(pdfBuffer(), undefined)).toBe('application/pdf')
|
||||
expect(resolveStoredMimeType(pdfBuffer(), '')).toBe('application/pdf')
|
||||
expect(resolveStoredMimeType(textBytes('just text'), undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('uploadDocument stores and stamps the sniffed type, not the declared one', async () => {
|
||||
results = [{ data: makeDocumentAttachment({ id: 'doc-1' }), error: null }]
|
||||
const upload = vi.fn().mockResolvedValue({ data: {}, error: null })
|
||||
const client = makeClient({ upload })
|
||||
|
||||
await uploadDocument(client as never, 'user-1', 'company-1', {
|
||||
name: 'IMG_0001.heif',
|
||||
buffer: isoBmff('heic'),
|
||||
type: 'image/heif',
|
||||
})
|
||||
|
||||
expect(insertPayloadOf(client, 0)?.mime_type).toBe('image/heic')
|
||||
expect((upload.mock.calls[0]?.[2] as { contentType: string }).contentType).toBe('image/heic')
|
||||
})
|
||||
|
||||
it('uploadDocument sniffs an undeclared type instead of storing null for a real PDF', async () => {
|
||||
results = [{ data: makeDocumentAttachment({ id: 'doc-1' }), error: null }]
|
||||
const upload = vi.fn().mockResolvedValue({ data: {}, error: null })
|
||||
const client = makeClient({ upload })
|
||||
|
||||
await uploadDocument(client as never, 'user-1', 'company-1', {
|
||||
name: 'kvitto.pdf',
|
||||
buffer: pdfBuffer('undeclared'),
|
||||
})
|
||||
|
||||
expect(insertPayloadOf(client, 0)?.mime_type).toBe('application/pdf')
|
||||
expect((upload.mock.calls[0]?.[2] as { contentType: string }).contentType).toBe('application/pdf')
|
||||
})
|
||||
|
||||
it('completePendingDocumentUpload persists the sniffed type', async () => {
|
||||
const buffer = isoBmff('heic')
|
||||
const document = makeDocumentAttachment({
|
||||
id: uploadId,
|
||||
mime_type: 'image/heic',
|
||||
sha256_hash: await computeSHA256(buffer),
|
||||
})
|
||||
results = [
|
||||
{ data: null, error: null },
|
||||
{ data: document, error: null },
|
||||
]
|
||||
serviceClientOverride = makeClient({
|
||||
download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }),
|
||||
})
|
||||
const client = makeClient()
|
||||
|
||||
await completePendingDocumentUpload(client as never, company, user, uploadId, 'IMG_0001.heif', 'image/heif')
|
||||
|
||||
expect(insertPayloadOf(client, 1)?.mime_type).toBe('image/heic')
|
||||
})
|
||||
|
||||
it('completePendingDocumentUpload retry accepts the stored family member for the declared one', async () => {
|
||||
const buffer = isoBmff('heic')
|
||||
const document = makeDocumentAttachment({
|
||||
id: uploadId,
|
||||
user_id: user,
|
||||
company_id: company,
|
||||
file_name: 'IMG_0001.heif',
|
||||
mime_type: 'image/heic',
|
||||
storage_path: buildReservedDocumentStoragePath(company, user, uploadId, 'IMG_0001.heif'),
|
||||
sha256_hash: await computeSHA256(buffer),
|
||||
})
|
||||
results = [{ data: document, error: null }]
|
||||
const move = vi.fn().mockResolvedValue({ data: {}, error: null })
|
||||
serviceClientOverride = makeClient({
|
||||
download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }),
|
||||
move,
|
||||
})
|
||||
|
||||
const completed = await completePendingDocumentUpload(
|
||||
makeClient() as never,
|
||||
company,
|
||||
user,
|
||||
uploadId,
|
||||
'IMG_0001.heif',
|
||||
'image/heif',
|
||||
)
|
||||
|
||||
expect(completed.document).toEqual(document)
|
||||
expect(move).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('completePendingDocumentUpload retry still rejects a genuinely different stored type', async () => {
|
||||
const buffer = isoBmff('heic')
|
||||
const document = makeDocumentAttachment({
|
||||
id: uploadId,
|
||||
file_name: 'IMG_0001.heif',
|
||||
mime_type: 'image/jpeg',
|
||||
sha256_hash: await computeSHA256(buffer),
|
||||
})
|
||||
results = [{ data: document, error: null }]
|
||||
serviceClientOverride = makeClient({
|
||||
download: vi.fn().mockResolvedValue({ data: new Blob([buffer]), error: null }),
|
||||
})
|
||||
|
||||
await expect(
|
||||
completePendingDocumentUpload(makeClient() as never, company, user, uploadId, 'IMG_0001.heif', 'image/heif'),
|
||||
).rejects.toThrow(/different file metadata/)
|
||||
})
|
||||
|
||||
it('createNewVersion passes the sniffed type to the versioning RPC and the storage object', async () => {
|
||||
results = [
|
||||
{ data: { company_id: 'company-1' }, error: null },
|
||||
{ data: 'doc-2', error: null },
|
||||
{ data: makeDocumentAttachment({ id: 'doc-2', version: 2 }), error: null },
|
||||
]
|
||||
const upload = vi.fn().mockResolvedValue({ data: {}, error: null })
|
||||
const client = makeClient({ upload })
|
||||
|
||||
await createNewVersion(client as never, 'user-1', 'doc-1', {
|
||||
name: 'IMG_0002.heif',
|
||||
buffer: isoBmff('heic'),
|
||||
type: 'image/heif',
|
||||
})
|
||||
|
||||
expect((client.rpc.mock.calls[0]?.[1] as { p_mime_type: string }).p_mime_type).toBe('image/heic')
|
||||
expect((upload.mock.calls[0]?.[2] as { contentType: string }).contentType).toBe('image/heic')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -362,6 +362,49 @@ export function validateDocumentMagicBytes(buffer: ArrayBuffer, declaredMimeType
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Declared types validateDocumentMagicBytes checks by content shape rather
|
||||
* than by signature. They have no magic number, so the declared type is the
|
||||
* only type there is once the shape check has passed.
|
||||
*/
|
||||
const SHAPE_CHECKED_TYPES = new Set([
|
||||
'application/xhtml+xml',
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
'text/html',
|
||||
'application/json',
|
||||
])
|
||||
|
||||
/**
|
||||
* The mime type to persist on the document row and stamp on the storage
|
||||
* object: what the bytes are, never what the client declared. Call after
|
||||
* validateDocumentMagicBytes has accepted the buffer for `declaredMimeType`.
|
||||
*
|
||||
* Binary formats take the sniffed type (an iOS capture declared image/heif
|
||||
* but branded heic lands as image/heic). The shape-checked text formats keep
|
||||
* their declared type, which the validator has already held against the
|
||||
* content. With no declared type the sniffed type is stored when there is
|
||||
* one, else null: a serving route treats null as unknown and serves it
|
||||
* opaque, whereas an unverified client string could name an active type.
|
||||
*/
|
||||
export function resolveStoredMimeType(
|
||||
buffer: ArrayBuffer,
|
||||
declaredMimeType: string | undefined,
|
||||
): string | null {
|
||||
if (declaredMimeType && SHAPE_CHECKED_TYPES.has(declaredMimeType)) return declaredMimeType
|
||||
return detectFileMagic(new Uint8Array(buffer))
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a stored type and a declared type name the same content. The
|
||||
* stored type is the validated one (resolveStoredMimeType), so the only
|
||||
* legitimate difference is the HEIC/HEIF family swap.
|
||||
*/
|
||||
function sameStoredMimeType(stored: string | null, declared: string): boolean {
|
||||
if (stored === declared) return true
|
||||
return stored !== null && HEIC_FAMILY.has(stored) && HEIC_FAMILY.has(declared)
|
||||
}
|
||||
|
||||
let bucketVerified = false
|
||||
|
||||
/** @internal Reset bucket verification flag: for testing only */
|
||||
@@ -513,7 +556,7 @@ function validateReservedDocumentMetadata(
|
||||
fileName: string,
|
||||
mimeType: string
|
||||
): void {
|
||||
if (document.file_name !== fileName || document.mime_type !== mimeType) {
|
||||
if (document.file_name !== fileName || !sameStoredMimeType(document.mime_type, mimeType)) {
|
||||
throw new Error('Upload ID was already completed with different file metadata')
|
||||
}
|
||||
}
|
||||
@@ -607,6 +650,10 @@ export async function completePendingDocumentUpload(
|
||||
await storage.remove([sourcePath])
|
||||
throw error
|
||||
}
|
||||
// Persist what the bytes are, not what the client said (see
|
||||
// resolveStoredMimeType). The storage object itself keeps the metadata
|
||||
// the PUT declared: nothing serving from this app trusts it.
|
||||
const storedMimeType = resolveStoredMimeType(buffer, mimeType)
|
||||
|
||||
if (options.dedupeByContent) {
|
||||
// Same lookup as uploadDocument: oldest current-version match wins, and
|
||||
@@ -646,7 +693,7 @@ export async function completePendingDocumentUpload(
|
||||
storage_path: permanentPath,
|
||||
file_name: fileName,
|
||||
file_size_bytes: buffer.byteLength,
|
||||
mime_type: mimeType,
|
||||
mime_type: storedMimeType,
|
||||
sha256_hash: sha256Hash,
|
||||
version: 1,
|
||||
is_current_version: true,
|
||||
@@ -754,6 +801,8 @@ export async function uploadDocument(
|
||||
const magicError = validateDocumentMagicBytes(file.buffer, file.type)
|
||||
if (magicError) throw new Error(magicError)
|
||||
}
|
||||
// Stored and stamped type is the validated one, never the raw client type.
|
||||
const storedMimeType = resolveStoredMimeType(file.buffer, file.type)
|
||||
|
||||
// Compute SHA-256 hash
|
||||
const sha256Hash = await computeSHA256(file.buffer)
|
||||
@@ -801,7 +850,7 @@ export async function uploadDocument(
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.upload(storagePath, file.buffer, {
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
contentType: storedMimeType ?? 'application/octet-stream',
|
||||
upsert: false,
|
||||
})
|
||||
|
||||
@@ -819,7 +868,7 @@ export async function uploadDocument(
|
||||
storage_path: storagePath,
|
||||
file_name: file.name,
|
||||
file_size_bytes: file.buffer.byteLength,
|
||||
mime_type: file.type || null,
|
||||
mime_type: storedMimeType,
|
||||
sha256_hash: sha256Hash,
|
||||
version: 1,
|
||||
is_current_version: true,
|
||||
@@ -916,6 +965,7 @@ export async function createNewVersion(
|
||||
const magicError = validateDocumentMagicBytes(file.buffer, file.type)
|
||||
if (magicError) throw new Error(magicError)
|
||||
}
|
||||
const storedMimeType = resolveStoredMimeType(file.buffer, file.type)
|
||||
|
||||
// Compute SHA-256 hash
|
||||
const sha256Hash = await computeSHA256(file.buffer)
|
||||
@@ -945,7 +995,7 @@ export async function createNewVersion(
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.upload(storagePath, file.buffer, {
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
contentType: storedMimeType ?? 'application/octet-stream',
|
||||
upsert: false,
|
||||
})
|
||||
|
||||
@@ -960,7 +1010,7 @@ export async function createNewVersion(
|
||||
p_storage_path: storagePath,
|
||||
p_file_name: file.name,
|
||||
p_file_size_bytes: file.buffer.byteLength,
|
||||
p_mime_type: file.type || null,
|
||||
p_mime_type: storedMimeType,
|
||||
p_sha256_hash: sha256Hash,
|
||||
})
|
||||
|
||||
|
||||
@@ -18,6 +18,67 @@
|
||||
|
||||
export const STORAGE_PROXY_ROUTE = '/api/storage'
|
||||
|
||||
/**
|
||||
* Content types a browser renders natively without a script context: PDF
|
||||
* and the raster image formats the document archive accepts. These are the
|
||||
* only types either document-serving route (the inline preview proxy and the
|
||||
* signed-URL proxy below) hands to the browser as-is. Everything else that
|
||||
* can reach the archive (text/html mail bodies, application/xhtml+xml
|
||||
* iXBRL, Peppol application/xml and text/xml, image/svg+xml, application/json,
|
||||
* unknown or legacy types) is active content when it lands on our origin:
|
||||
* an uploader-controlled <script> inside it would run with the app origin's
|
||||
* authority (stored XSS). Those types are served under OPAQUE_DOCUMENT_CSP.
|
||||
*/
|
||||
export const INLINE_SAFE_MIME_TYPES: ReadonlySet<string> = new Set([
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
])
|
||||
|
||||
/**
|
||||
* Policy for every served document that is not natively inline-safe.
|
||||
* `sandbox` (no tokens) makes the rendered document opaque-origin and
|
||||
* script-free wherever it is opened, iframe or direct tab. The source
|
||||
* directives block outbound requests on top of that: sandbox alone still
|
||||
* loads remote images, so a tracking pixel in a mail body would notify the
|
||||
* sender when the preview is opened. Inline styles and embedded data:/blob:
|
||||
* images keep working, so HTML mail previews and XML views still render.
|
||||
*/
|
||||
export const OPAQUE_DOCUMENT_CSP =
|
||||
"sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:"
|
||||
|
||||
/**
|
||||
* Canonical (lower-case, parameter-free) form of `mimeType` when it is one
|
||||
* of INLINE_SAFE_MIME_TYPES, else null. Legacy rows hold client-declared
|
||||
* strings, so parameters and case are tolerated on the way in but never
|
||||
* echoed back out.
|
||||
*/
|
||||
export function inlineSafeMimeType(mimeType: string | null | undefined): string | null {
|
||||
if (!mimeType) return null
|
||||
const essence = mimeType.split(';')[0]?.trim().toLowerCase() ?? ''
|
||||
return INLINE_SAFE_MIME_TYPES.has(essence) ? essence : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The documents-bucket object key behind a proxied download path
|
||||
* (`sign/documents/<key>`, still percent-encoded), decoded so it can be
|
||||
* matched against `document_attachments.storage_path`. Null for upload paths
|
||||
* and anything else.
|
||||
*/
|
||||
export function documentKeyFromProxyPath(objectPath: string): string | null {
|
||||
const prefix = 'sign/documents/'
|
||||
if (!objectPath.startsWith(prefix)) return null
|
||||
try {
|
||||
return decodeURIComponent(objectPath.slice(prefix.length))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Upstream prefix under the Storage API that every signed object URL shares. */
|
||||
const UPSTREAM_OBJECT_PREFIX = '/storage/v1/object/'
|
||||
|
||||
|
||||
@@ -73,6 +73,26 @@ describe('buildAuthEmail', () => {
|
||||
expect(mail.text).toContain('Kod: 123456')
|
||||
})
|
||||
|
||||
it('renders the BankID signup confirmation with the ignore-and-stay-inactive note', () => {
|
||||
// Sent to whatever address the BankID holder typed, so the copy must say
|
||||
// the account was opened with BankID and that ignoring the mail leaves it
|
||||
// inactive: a stranger must not be nudged into activating it.
|
||||
const mail = buildAuthEmail({
|
||||
actionType: 'bankid_signup',
|
||||
appName: 'Siffra',
|
||||
actionUrl: 'https://app.siffra.se/auth/callback?token_hash=abc&type=magiclink',
|
||||
})
|
||||
expect(mail.subject).toBe('Bekräfta din e-postadress')
|
||||
expect(mail.text).toContain('BankID')
|
||||
expect(mail.text).toContain('Siffra')
|
||||
expect(mail.text).toContain('förblir inaktivt')
|
||||
expect(mail.text).toContain(
|
||||
'Bekräfta e-postadress: https://app.siffra.se/auth/callback?token_hash=abc&type=magiclink',
|
||||
)
|
||||
expect(mail.html).toContain('type=magiclink')
|
||||
expect(mail.html).not.toMatch(/accounted/i)
|
||||
})
|
||||
|
||||
it('falls back to a generic mail for unknown action types', () => {
|
||||
const mail = buildAuthEmail({
|
||||
actionType: 'some_future_type',
|
||||
|
||||
@@ -20,6 +20,7 @@ export type AuthEmailActionType =
|
||||
| 'email_change'
|
||||
| 'email_change_current'
|
||||
| 'reauthentication'
|
||||
| 'bankid_signup'
|
||||
|
||||
interface AuthEmailContent {
|
||||
subject: string
|
||||
@@ -76,6 +77,20 @@ const CONTENT: Record<AuthEmailActionType, AuthEmailContent> = {
|
||||
body: (appName) => `Ange koden nedan för att bekräfta din identitet hos ${appName}.`,
|
||||
cta: '',
|
||||
},
|
||||
// Sent by the BankID signup (extensions/general/tic) to the address the
|
||||
// person typed, and again when a still-unconfirmed identity tries to log
|
||||
// in. Not a Supabase hook type: the link is minted by generateLink and only
|
||||
// ever travels by mail. The copy says plainly that the account was opened
|
||||
// with BankID and that ignoring the mail leaves it inactive, so a stranger
|
||||
// whose address was typed by mistake (or on purpose) is not nudged into
|
||||
// activating someone else's BankID login.
|
||||
bankid_signup: {
|
||||
subject: 'Bekräfta din e-postadress',
|
||||
heading: 'Bekräfta din e-postadress',
|
||||
body: (appName) =>
|
||||
`Ett konto hos ${appName} har skapats med BankID och den här e-postadressen. Klicka på knappen nedan för att bekräfta att adressen är din och aktivera kontot. Om det inte var du som skapade kontot kan du bortse från det här meddelandet: kontot förblir inaktivt och kan inte användas för att logga in.`,
|
||||
cta: 'Bekräfta e-postadress',
|
||||
},
|
||||
}
|
||||
|
||||
// Availability first: an action type this module does not know (Supabase can
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
isUnsafeUrlError,
|
||||
readBodyWithCap,
|
||||
safeFetch,
|
||||
UnsafeUrlError,
|
||||
} from '@/lib/http/safe-fetch'
|
||||
import type { validateWebhookUrl } from '@/lib/webhooks/url-guard'
|
||||
|
||||
type Validator = typeof validateWebhookUrl
|
||||
|
||||
function okValidator(addresses = ['203.0.113.10']): Validator {
|
||||
return vi.fn(async (rawUrl: string) => ({
|
||||
ok: true as const,
|
||||
hostname: new URL(rawUrl).hostname,
|
||||
resolvedAddresses: addresses,
|
||||
})) as unknown as Validator
|
||||
}
|
||||
|
||||
function refusingValidator(
|
||||
reason: 'private_address' | 'non_https_scheme' | 'metadata_address',
|
||||
detail = 'nope',
|
||||
): Validator {
|
||||
return vi.fn(async () => ({ ok: false as const, reason, detail })) as unknown as Validator
|
||||
}
|
||||
|
||||
describe('safeFetch', () => {
|
||||
it('validates the hostname, forces redirect: manual and returns the response', async () => {
|
||||
const validateUrl = okValidator()
|
||||
const response = new Response('ok', { status: 200 })
|
||||
const fetchImpl = vi.fn(async () => response)
|
||||
|
||||
const result = await safeFetch(
|
||||
'https://shop.example.se/wp-json/',
|
||||
{ headers: { Accept: 'application/json' } },
|
||||
{},
|
||||
{ validateUrl, fetchImpl },
|
||||
)
|
||||
|
||||
expect(result).toBe(response)
|
||||
expect(validateUrl).toHaveBeenCalledWith('https://shop.example.se/wp-json/', undefined)
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://shop.example.se/wp-json/',
|
||||
expect.objectContaining({ redirect: 'manual', headers: { Accept: 'application/json' } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a URL whose hostname resolves to a private address without opening a socket', async () => {
|
||||
const fetchImpl = vi.fn()
|
||||
|
||||
const error = await safeFetch(
|
||||
'https://internal.example/',
|
||||
{},
|
||||
{},
|
||||
{ validateUrl: refusingValidator('private_address', '10.0.0.4 is private'), fetchImpl },
|
||||
).catch((e) => e)
|
||||
|
||||
expect(error).toBeInstanceOf(UnsafeUrlError)
|
||||
expect(isUnsafeUrlError(error)).toBe(true)
|
||||
expect((error as UnsafeUrlError).reason).toBe('private_address')
|
||||
expect((error as UnsafeUrlError).detail).toBe('10.0.0.4 is private')
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('classifies IP-literal hostnames without DNS (the literal is the only "record")', async () => {
|
||||
const fetchImpl = vi.fn()
|
||||
const validateUrl = vi.fn(
|
||||
async (rawUrl: string, opts?: { resolve4?: (h: string) => Promise<string[]>; resolve6?: (h: string) => Promise<string[]> }) => {
|
||||
// Behave like the real validator: consume the injected resolvers.
|
||||
const v4 = await opts!.resolve4!(new URL(rawUrl).hostname).catch(() => [])
|
||||
const v6 = await opts!.resolve6!(new URL(rawUrl).hostname).catch(() => [])
|
||||
return {
|
||||
ok: false as const,
|
||||
reason: 'metadata_address' as const,
|
||||
detail: `resolved ${[...v4, ...v6].join(',')}`,
|
||||
}
|
||||
},
|
||||
) as unknown as Validator
|
||||
|
||||
const error = await safeFetch(
|
||||
'https://169.254.169.254/latest/meta-data/',
|
||||
{},
|
||||
{},
|
||||
{ validateUrl, fetchImpl },
|
||||
).catch((e) => e)
|
||||
|
||||
expect((error as UnsafeUrlError).reason).toBe('metadata_address')
|
||||
// The literal was handed to the classifier as the v4 answer and v6 had none.
|
||||
expect((error as UnsafeUrlError).detail).toBe('resolved 169.254.169.254')
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
|
||||
// IPv6 literal: brackets are stripped before the family check.
|
||||
const validateV6 = vi.fn(
|
||||
async (_rawUrl: string, opts?: { resolve4?: (h: string) => Promise<string[]>; resolve6?: (h: string) => Promise<string[]> }) => {
|
||||
const v6 = await opts!.resolve6!('x')
|
||||
const v4 = await opts!.resolve4!('x').catch((e: NodeJS.ErrnoException) => e.code)
|
||||
return { ok: false as const, reason: 'loopback_address' as const, detail: `${v6[0]}|${v4}` }
|
||||
},
|
||||
) as unknown as Validator
|
||||
const v6Error = await safeFetch('https://[::1]/', {}, {}, { validateUrl: validateV6, fetchImpl }).catch(
|
||||
(e) => e,
|
||||
)
|
||||
expect((v6Error as UnsafeUrlError).detail).toBe('::1|ENODATA')
|
||||
})
|
||||
|
||||
it('treats any 3xx as a refusal instead of following it', async () => {
|
||||
const cancel = vi.fn(async () => undefined)
|
||||
const redirect = {
|
||||
status: 302,
|
||||
type: 'basic',
|
||||
headers: new Headers({ location: 'http://169.254.169.254/' }),
|
||||
body: { cancel },
|
||||
} as unknown as Response
|
||||
const fetchImpl = vi.fn(async () => redirect)
|
||||
|
||||
const error = await safeFetch(
|
||||
'https://shop.example.se/',
|
||||
{},
|
||||
{},
|
||||
{ validateUrl: okValidator(), fetchImpl },
|
||||
).catch((e) => e)
|
||||
|
||||
expect(isUnsafeUrlError(error)).toBe(true)
|
||||
expect((error as UnsafeUrlError).reason).toBe('redirect_blocked')
|
||||
expect(cancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats an opaque-redirect response as a refusal too', async () => {
|
||||
const opaque = { status: 0, type: 'opaqueredirect', headers: new Headers(), body: null } as unknown as Response
|
||||
const error = await safeFetch(
|
||||
'https://shop.example.se/',
|
||||
{},
|
||||
{},
|
||||
{ validateUrl: okValidator(), fetchImpl: vi.fn(async () => opaque) },
|
||||
).catch((e) => e)
|
||||
|
||||
expect((error as UnsafeUrlError).reason).toBe('redirect_blocked')
|
||||
})
|
||||
|
||||
it('refuses non-http(s) schemes before validation', async () => {
|
||||
const validateUrl = okValidator()
|
||||
const error = await safeFetch('file:///etc/passwd', {}, {}, { validateUrl, fetchImpl: vi.fn() }).catch(
|
||||
(e) => e,
|
||||
)
|
||||
expect((error as UnsafeUrlError).reason).toBe('unsupported_scheme')
|
||||
expect(validateUrl).not.toHaveBeenCalled()
|
||||
|
||||
const invalid = await safeFetch('not a url', {}, {}, { validateUrl, fetchImpl: vi.fn() }).catch((e) => e)
|
||||
expect((invalid as UnsafeUrlError).reason).toBe('invalid_url')
|
||||
})
|
||||
|
||||
it('skips the address check for a trusted origin but still refuses its redirects', async () => {
|
||||
const validateUrl = refusingValidator('private_address')
|
||||
const okResponse = new Response('logo-bytes', { status: 200 })
|
||||
const fetchImpl = vi.fn(async () => okResponse)
|
||||
|
||||
const result = await safeFetch(
|
||||
'http://192.168.1.50:8000/storage/v1/object/public/logos/a.png',
|
||||
{},
|
||||
{ trustedOrigins: ['http://192.168.1.50:8000/'] },
|
||||
{ validateUrl, fetchImpl },
|
||||
)
|
||||
expect(result).toBe(okResponse)
|
||||
expect(validateUrl).not.toHaveBeenCalled()
|
||||
|
||||
// Different port is a different origin: back to the strict path.
|
||||
const otherPort = await safeFetch(
|
||||
'http://192.168.1.50:9000/x.png',
|
||||
{},
|
||||
{ trustedOrigins: ['http://192.168.1.50:8000'] },
|
||||
{ validateUrl, fetchImpl },
|
||||
).catch((e) => e)
|
||||
expect((otherPort as UnsafeUrlError).reason).toBe('private_address')
|
||||
|
||||
// Trusted origin that answers with a redirect is still refused.
|
||||
const redirect = { status: 301, type: 'basic', headers: new Headers(), body: null } as unknown as Response
|
||||
const bounced = await safeFetch(
|
||||
'http://192.168.1.50:8000/storage/v1/object/public/logos/b.png',
|
||||
{},
|
||||
{ trustedOrigins: ['http://192.168.1.50:8000'] },
|
||||
{ validateUrl, fetchImpl: vi.fn(async () => redirect) },
|
||||
).catch((e) => e)
|
||||
expect((bounced as UnsafeUrlError).reason).toBe('redirect_blocked')
|
||||
})
|
||||
|
||||
it('lets transport errors propagate unchanged so callers keep their retry semantics', async () => {
|
||||
const boom = new TypeError('fetch failed')
|
||||
await expect(
|
||||
safeFetch('https://shop.example.se/', {}, {}, {
|
||||
validateUrl: okValidator(),
|
||||
fetchImpl: vi.fn(async () => {
|
||||
throw boom
|
||||
}),
|
||||
}),
|
||||
).rejects.toBe(boom)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readBodyWithCap', () => {
|
||||
it('returns the bytes when under the cap', async () => {
|
||||
const res = new Response(Buffer.from('hello'), { status: 200 })
|
||||
const buf = await readBodyWithCap(res, 1024)
|
||||
expect(buf?.toString('utf8')).toBe('hello')
|
||||
})
|
||||
|
||||
it('rejects on a declared Content-Length over the cap without reading the body', async () => {
|
||||
const arrayBuffer = vi.fn()
|
||||
const res = {
|
||||
headers: new Headers({ 'content-length': String(3 * 1024 * 1024) }),
|
||||
body: { cancel: vi.fn(async () => undefined), getReader: vi.fn() },
|
||||
arrayBuffer,
|
||||
} as unknown as Response
|
||||
|
||||
expect(await readBodyWithCap(res, 2 * 1024 * 1024)).toBeNull()
|
||||
expect(arrayBuffer).not.toHaveBeenCalled()
|
||||
expect((res.body as unknown as { getReader: ReturnType<typeof vi.fn> }).getReader).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cuts a streamed body off at the cap when the length header is absent or lies', async () => {
|
||||
const chunk = new Uint8Array(1024)
|
||||
let pulls = 0
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
pulls += 1
|
||||
if (pulls > 100) controller.close()
|
||||
else controller.enqueue(chunk)
|
||||
},
|
||||
})
|
||||
const res = new Response(stream, { status: 200 })
|
||||
|
||||
expect(await readBodyWithCap(res, 3 * 1024)).toBeNull()
|
||||
// Stopped shortly after crossing the cap, not after draining 100 KiB.
|
||||
expect(pulls).toBeLessThan(10)
|
||||
})
|
||||
|
||||
it('falls back to arrayBuffer() for non-streaming doubles and still applies the cap', async () => {
|
||||
const small = {
|
||||
headers: { get: () => null },
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
} as unknown as Response
|
||||
expect((await readBodyWithCap(small, 10))?.length).toBe(3)
|
||||
|
||||
const big = {
|
||||
headers: { get: () => null },
|
||||
arrayBuffer: async () => new Uint8Array(11).buffer,
|
||||
} as unknown as Response
|
||||
expect(await readBodyWithCap(big, 10)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Guarded outbound `fetch` for URLs a tenant controls.
|
||||
*
|
||||
* Several server-side paths fetch a URL that a company member can write
|
||||
* straight into the database through PostgREST (RLS lets members update
|
||||
* their own rows, so app-side normalisation at create time is not a
|
||||
* boundary): `woocommerce_connections.store_url`, `shopify_connections.
|
||||
* shop_domain`, `company_settings.logo_url`. Fetching those with a bare
|
||||
* `fetch()` from the Vercel function's network position is a textbook SSRF:
|
||||
* cloud metadata (169.254.169.254), loopback, RFC 1918 ranges, and any 3xx
|
||||
* that bounces the request there AFTER a hostname check passed.
|
||||
*
|
||||
* This helper is the one place such fetches go through:
|
||||
*
|
||||
* 1. The URL must be http(s). Anything else is refused up front.
|
||||
* 2. Unless the URL's origin is explicitly trusted by the caller (e.g. the
|
||||
* deployment's own Supabase storage origin, which may legitimately be a
|
||||
* private address on a self-hosted install), EVERY A/AAAA record of the
|
||||
* hostname must be publicly routable and the scheme must be https. That
|
||||
* check is `validateWebhookUrl` from the webhook dispatcher, reused
|
||||
* verbatim so there is a single definition of "safe address" in the
|
||||
* codebase. IP-literal hostnames are classified directly, without DNS.
|
||||
* 3. Redirects are never followed: `redirect: 'manual'` is forced and any
|
||||
* 3xx (or an opaque-redirect response) is a refusal, not a response.
|
||||
*
|
||||
* Compared to `lib/webhooks/pinned-fetch.ts` this keeps the `fetch` /
|
||||
* `Response` surface that the callers (and their tests) are written against,
|
||||
* at the cost of the DNS-rebinding window between validation and connect that
|
||||
* the pinned transport closes. That window is the same one url-guard.ts
|
||||
* documents for its own callers and is acceptable for these read-only feeds;
|
||||
* a caller that needs pinning should use pinnedHttpsFetch instead.
|
||||
*
|
||||
* Timeouts stay the caller's responsibility (`signal: AbortSignal.timeout()`),
|
||||
* and body size is bounded with `readBodyWithCap` so a hostile host cannot
|
||||
* balloon memory before a size check runs.
|
||||
*/
|
||||
|
||||
import { isIP } from 'node:net'
|
||||
import {
|
||||
validateWebhookUrl as validateWebhookUrlDefault,
|
||||
type WebhookUrlValidationReason,
|
||||
} from '@/lib/webhooks/url-guard'
|
||||
|
||||
export type SafeFetchRefusalReason =
|
||||
| WebhookUrlValidationReason
|
||||
| 'unsupported_scheme'
|
||||
| 'redirect_blocked'
|
||||
|
||||
/**
|
||||
* Thrown when the guard refuses to open (or to keep) a connection. Callers
|
||||
* must treat this as terminal for the URL: retrying does not help, and the
|
||||
* stored URL should be surfaced to the user as invalid rather than fetched.
|
||||
*/
|
||||
export class UnsafeUrlError extends Error {
|
||||
readonly name = 'UnsafeUrlError'
|
||||
|
||||
constructor(
|
||||
readonly reason: SafeFetchRefusalReason,
|
||||
readonly detail: string,
|
||||
) {
|
||||
super(`Refused to fetch URL (${reason}): ${detail}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Name-based so it survives duplicate module instances (vitest isolation). */
|
||||
export function isUnsafeUrlError(error: unknown): error is UnsafeUrlError {
|
||||
return error instanceof Error && error.name === 'UnsafeUrlError'
|
||||
}
|
||||
|
||||
export interface SafeFetchOptions {
|
||||
/**
|
||||
* Origins (`scheme://host[:port]`) that skip the public-address check.
|
||||
* Reserved for infrastructure the app already talks to (the deployment's
|
||||
* own Supabase storage). Redirect refusal still applies to these.
|
||||
*/
|
||||
trustedOrigins?: readonly string[]
|
||||
}
|
||||
|
||||
export interface SafeFetchDeps {
|
||||
/** DNS validation seam. Defaults to url-guard's validateWebhookUrl. */
|
||||
validateUrl?: typeof validateWebhookUrlDefault
|
||||
/** Transport seam. Defaults to the global fetch (resolved at call time). */
|
||||
fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
type ValidateUrlOptions = NonNullable<Parameters<typeof validateWebhookUrlDefault>[1]>
|
||||
|
||||
/**
|
||||
* `dns.resolve4('10.0.0.1')` is not a lookup, it is an error (or worse, a
|
||||
* provider-dependent echo). For IP-literal hostnames, feed the literal to the
|
||||
* validator as if it were the single DNS answer so the address classifier
|
||||
* runs on it directly and the result is deterministic.
|
||||
*/
|
||||
function literalResolvers(hostname: string): ValidateUrlOptions | undefined {
|
||||
const bare =
|
||||
hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
|
||||
const family = isIP(bare)
|
||||
if (family === 0) return undefined
|
||||
|
||||
const literal = (async () => [bare]) as unknown as ValidateUrlOptions['resolve4']
|
||||
const noRecords = (async () => {
|
||||
const err = new Error(`No records for ${bare}`) as NodeJS.ErrnoException
|
||||
err.code = 'ENODATA'
|
||||
throw err
|
||||
}) as unknown as ValidateUrlOptions['resolve4']
|
||||
|
||||
return family === 4
|
||||
? { resolve4: literal, resolve6: noRecords }
|
||||
: { resolve4: noRecords, resolve6: literal }
|
||||
}
|
||||
|
||||
function originOf(value: string): string | null {
|
||||
try {
|
||||
return new URL(value).origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isTrustedOrigin(parsed: URL, trustedOrigins: readonly string[] | undefined): boolean {
|
||||
if (!trustedOrigins || trustedOrigins.length === 0) return false
|
||||
const target = parsed.origin
|
||||
return trustedOrigins.some((entry) => originOf(entry) === target)
|
||||
}
|
||||
|
||||
function isRedirectResponse(res: Response): boolean {
|
||||
if (res.type === 'opaqueredirect') return true
|
||||
return res.status >= 300 && res.status < 400
|
||||
}
|
||||
|
||||
async function discardBody(res: Response): Promise<void> {
|
||||
try {
|
||||
await res.body?.cancel()
|
||||
} catch {
|
||||
// Best-effort socket cleanup; the response is being refused anyway.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch `rawUrl` only if it is safe to connect to, never following redirects.
|
||||
* Throws `UnsafeUrlError` on refusal; transport errors propagate unchanged so
|
||||
* callers keep their existing retry semantics for the network-blip case.
|
||||
*/
|
||||
export async function safeFetch(
|
||||
rawUrl: string,
|
||||
init: RequestInit = {},
|
||||
options: SafeFetchOptions = {},
|
||||
deps: SafeFetchDeps = {},
|
||||
): Promise<Response> {
|
||||
const validateUrl = deps.validateUrl ?? validateWebhookUrlDefault
|
||||
const fetchImpl = deps.fetchImpl ?? globalThis.fetch
|
||||
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(rawUrl)
|
||||
} catch {
|
||||
throw new UnsafeUrlError('invalid_url', 'URL did not parse.')
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
throw new UnsafeUrlError(
|
||||
'unsupported_scheme',
|
||||
`Only http(s) URLs can be fetched (got ${parsed.protocol}).`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!isTrustedOrigin(parsed, options.trustedOrigins)) {
|
||||
const validation = await validateUrl(rawUrl, literalResolvers(parsed.hostname))
|
||||
if (!validation.ok) {
|
||||
throw new UnsafeUrlError(validation.reason, validation.detail)
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetchImpl(rawUrl, { ...init, redirect: 'manual' })
|
||||
|
||||
if (isRedirectResponse(response)) {
|
||||
await discardBody(response)
|
||||
throw new UnsafeUrlError(
|
||||
'redirect_blocked',
|
||||
`${parsed.hostname} answered ${response.status || 'with a redirect'}; redirects are never followed.`,
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a response body into a Buffer, refusing anything over `maxBytes`.
|
||||
*
|
||||
* Checks the declared Content-Length first (cheap, no bytes read), then
|
||||
* streams with a running total so a host that lies about (or omits) the
|
||||
* length is cut off at the cap instead of being buffered whole. Returns null
|
||||
* when the cap is exceeded; the body is cancelled in that case.
|
||||
*/
|
||||
export async function readBodyWithCap(res: Response, maxBytes: number): Promise<Buffer | null> {
|
||||
const declared = Number(res.headers.get('content-length') ?? '')
|
||||
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||
await discardBody(res)
|
||||
return null
|
||||
}
|
||||
|
||||
const body = res.body
|
||||
if (!body || typeof body.getReader !== 'function') {
|
||||
// Bodyless or non-streaming Response (test doubles, some polyfills):
|
||||
// fall back to a whole read with a post-hoc check.
|
||||
const buf = Buffer.from(await res.arrayBuffer())
|
||||
return buf.byteLength > maxBytes ? null : buf
|
||||
}
|
||||
|
||||
const reader = body.getReader()
|
||||
const chunks: Buffer[] = []
|
||||
let total = 0
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
total += value.byteLength
|
||||
if (total > maxBytes) {
|
||||
await reader.cancel().catch(() => undefined)
|
||||
return null
|
||||
}
|
||||
chunks.push(Buffer.from(value))
|
||||
}
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
@@ -28,6 +28,34 @@ vi.mock('@/lib/supabase/server', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// The logo fetch runs through the outbound URL guard, which resolves DNS.
|
||||
// Stub the validator (same seam the webhook dispatcher tests use): https hosts
|
||||
// resolve to a public address, plain http is refused like the real guard does.
|
||||
const guard = vi.hoisted(() => ({ validateUrl: vi.fn() }))
|
||||
vi.mock('@/lib/webhooks/url-guard', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/webhooks/url-guard')>()
|
||||
return {
|
||||
...actual,
|
||||
validateWebhookUrl: (...args: unknown[]) => guard.validateUrl(...args),
|
||||
}
|
||||
})
|
||||
|
||||
function guardPublicByDefault() {
|
||||
guard.validateUrl.mockReset()
|
||||
guard.validateUrl.mockImplementation(async (rawUrl: string) => {
|
||||
const parsed = new URL(rawUrl)
|
||||
if (parsed.protocol !== 'https:') {
|
||||
return { ok: false, reason: 'non_https_scheme', detail: `${parsed.protocol} refused` }
|
||||
}
|
||||
return { ok: true, hostname: parsed.hostname, resolvedAddresses: ['203.0.113.10'] }
|
||||
})
|
||||
}
|
||||
|
||||
// The deployment's own storage origin: logos uploaded through the app live
|
||||
// here, and it is exempt from the public-address check (a self-hosted NAS
|
||||
// install may legitimately serve storage from a private address).
|
||||
const STORAGE_ORIGIN = 'https://example.test'
|
||||
|
||||
const PNG_DATA_URL_PREFIX = 'data:image/png;base64,'
|
||||
|
||||
const SVG_LOGO = Buffer.from(
|
||||
@@ -60,6 +88,8 @@ describe('prepareInvoicePdfRender: logo resolution (issue #772)', () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
fontDownloadMock.mockReset()
|
||||
guardPublicByDefault()
|
||||
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', STORAGE_ORIGIN)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
@@ -124,14 +154,101 @@ describe('prepareInvoicePdfRender: logo resolution (issue #772)', () => {
|
||||
|
||||
const { company: resolved } = await prepareInvoicePdfRender(company)
|
||||
|
||||
// Fetched with a timeout signal so a slow logo host can't hang the render.
|
||||
// Fetched with a timeout signal so a slow logo host can't hang the render,
|
||||
// and with redirects disabled so the host can't bounce us elsewhere.
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://example.test/svg-logo-1.svg',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal), redirect: 'manual' }),
|
||||
)
|
||||
// Our own storage origin skips the DNS guard (it may be private on self-host).
|
||||
expect(guard.validateUrl).not.toHaveBeenCalled()
|
||||
await expectValidEmbeddedPng(resolved.logo_url)
|
||||
})
|
||||
|
||||
it('embeds a logo from a public https host once the DNS guard passes', async () => {
|
||||
const fetchMock = mockFetchOnce(SVG_LOGO, 'image/svg+xml')
|
||||
const url = 'https://cdn.example.org/public-logo-1.svg'
|
||||
const company = makeCompanySettings({ logo_url: url })
|
||||
|
||||
const { company: resolved } = await prepareInvoicePdfRender(company)
|
||||
|
||||
expect(guard.validateUrl).toHaveBeenCalledWith(url, undefined)
|
||||
expect(fetchMock).toHaveBeenCalledWith(url, expect.objectContaining({ redirect: 'manual' }))
|
||||
await expectValidEmbeddedPng(resolved.logo_url)
|
||||
})
|
||||
|
||||
it('renders without a logo when the logo host resolves to a private address (SSRF guard)', async () => {
|
||||
guard.validateUrl.mockResolvedValue({
|
||||
ok: false,
|
||||
reason: 'private_address',
|
||||
detail: 'Resolved address 10.0.0.9 for intranet.example.org is not publicly routable (private_address).',
|
||||
})
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const company = makeCompanySettings({ logo_url: 'https://intranet.example.org/logo.png' })
|
||||
|
||||
const { company: resolved } = await prepareInvoicePdfRender(company)
|
||||
|
||||
// Refused means refused: no socket, and @react-pdf is not handed the URL either.
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(resolved.logo_url).toBeNull()
|
||||
})
|
||||
|
||||
it('renders without a logo for a plain-http URL off the storage origin', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const company = makeCompanySettings({ logo_url: 'http://cdn.example.org/http-logo.png' })
|
||||
|
||||
const { company: resolved } = await prepareInvoicePdfRender(company)
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(resolved.logo_url).toBeNull()
|
||||
})
|
||||
|
||||
it('renders without a logo for a non-http(s) scheme', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const company = makeCompanySettings({ logo_url: 'file:///etc/hostname' })
|
||||
|
||||
const { company: resolved } = await prepareInvoicePdfRender(company)
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(guard.validateUrl).not.toHaveBeenCalled()
|
||||
expect(resolved.logo_url).toBeNull()
|
||||
})
|
||||
|
||||
it('renders without a logo when the logo host answers with a redirect, even from the storage origin', async () => {
|
||||
const cancel = vi.fn(async () => undefined)
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 302,
|
||||
type: 'basic',
|
||||
headers: new Headers({ location: 'http://169.254.169.254/latest/meta-data/' }),
|
||||
body: { cancel },
|
||||
}),
|
||||
)
|
||||
const company = makeCompanySettings({ logo_url: 'https://example.test/redirecting-logo.png' })
|
||||
|
||||
const { company: resolved } = await prepareInvoicePdfRender(company)
|
||||
|
||||
expect(resolved.logo_url).toBeNull()
|
||||
expect(cancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops the logo instead of handing @react-pdf a remote URL when a non-storage host fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
|
||||
const url = 'https://cdn.example.org/flaky-logo.png'
|
||||
const company = makeCompanySettings({ logo_url: url })
|
||||
|
||||
const { company: resolved } = await prepareInvoicePdfRender(company)
|
||||
|
||||
// @react-pdf's own fetch follows redirects with no guard; a tenant host
|
||||
// that fails us and then redirects it would reopen the hole.
|
||||
expect(resolved.logo_url).toBeNull()
|
||||
})
|
||||
|
||||
it('embeds a WebP logo as a PNG data URL', async () => {
|
||||
const webp = await sharp(SVG_LOGO).webp().toBuffer()
|
||||
mockFetchOnce(webp, 'image/webp')
|
||||
|
||||
@@ -25,6 +25,7 @@ import { brandingFromCompanySettings, SHOW_SWISH_ON_INVOICE, type InvoiceBrandin
|
||||
import { buildSwishQrPayload } from '@/lib/payments/swish'
|
||||
import { getAmountToPay } from '@/lib/invoices/rounding'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { isUnsafeUrlError, readBodyWithCap, safeFetch } from '@/lib/http/safe-fetch'
|
||||
import { LOGO_UPLOAD_MAX_BYTES } from '@/lib/invoices/branding-constants'
|
||||
import { prepareInvoiceFont } from '@/lib/invoices/pdf-fonts'
|
||||
import {
|
||||
@@ -34,14 +35,18 @@ import {
|
||||
|
||||
const log = createLogger('invoice.swish-qr')
|
||||
const paymentLinkLog = createLogger('invoice.payment-link-qr')
|
||||
const logoLog = createLogger('invoice.logo')
|
||||
|
||||
export interface InvoicePdfRenderExtras {
|
||||
branding: InvoiceBranding
|
||||
/**
|
||||
* The company settings to pass to InvoicePDF. Identical to the input except
|
||||
* `logo_url` is replaced by an embedded PNG data URL when the stored logo
|
||||
* could be fetched and re-encoded. Falls back to the original settings
|
||||
* unchanged on any failure, so behaviour is never worse than before.
|
||||
* could be fetched and re-encoded, or set to null when the stored URL was
|
||||
* refused by the outbound URL guard (the invoice then renders without a
|
||||
* logo). A transient failure keeps the original URL only when it points at
|
||||
* the deployment's own storage origin; @react-pdf must never be handed an
|
||||
* arbitrary remote URL to fetch unguarded.
|
||||
*/
|
||||
company: CompanySettings
|
||||
}
|
||||
@@ -65,29 +70,67 @@ const logoDataUrlCache = new Map<string, { dataUrl: string; at: number }>()
|
||||
const LOGO_MAX_PX = 600
|
||||
|
||||
// Bound the logo fetch so a slow or oversized response can't hang or balloon an
|
||||
// invoice render. logo_url is currently always a Supabase `logos`-bucket public
|
||||
// URL (set only by the upload route), so SSRF is not reachable today: these
|
||||
// caps are defense-in-depth for that invariant plus plain robustness.
|
||||
// invoice render. The upload route only ever writes Supabase `logos`-bucket
|
||||
// URLs, but company members can PATCH `company_settings.logo_url` directly
|
||||
// through PostgREST, so the stored value is tenant-controlled input that this
|
||||
// server fetches: it goes through `safeFetch` (public address only, no
|
||||
// redirects) unless it sits on the deployment's own storage origin.
|
||||
const LOGO_FETCH_TIMEOUT_MS = 5_000
|
||||
|
||||
/**
|
||||
* What became of a stored logo URL:
|
||||
* embedded: fetched, re-encoded, safe to hand to @react-pdf as a data URL
|
||||
* refused: the outbound URL guard said no (private address, non-http(s),
|
||||
* redirect); the invoice renders without a logo
|
||||
* failed: transient or decode failure after the guard passed
|
||||
*/
|
||||
type LogoResolution =
|
||||
| { kind: 'embedded'; dataUrl: string }
|
||||
| { kind: 'refused' }
|
||||
| { kind: 'failed' }
|
||||
|
||||
// Coalesce concurrent renders of the same logo (preflight + final on a send, and
|
||||
// every invoice in a recurring/batch loop) onto one in-flight fetch+encode
|
||||
// instead of each doing the full round-trip before the first result is cached.
|
||||
const logoInflight = new Map<string, Promise<string | null>>()
|
||||
const logoInflight = new Map<string, Promise<LogoResolution>>()
|
||||
|
||||
/**
|
||||
* Fetch a stored logo and re-encode it to a PNG data URL. Returns null on any
|
||||
* failure (network error, timeout, oversized payload, unreadable image, sharp
|
||||
* unavailable): the caller then keeps the original URL, which @react-pdf can
|
||||
* still fetch directly for PNG/JPEG logos. Concurrent calls for the same URL
|
||||
* share a single in-flight request.
|
||||
* The origin the app's own Supabase storage lives on. Logos uploaded through
|
||||
* the app always resolve here, and on a self-hosted install this origin may
|
||||
* legitimately be a private address (a NAS on the LAN), so it is exempt from
|
||||
* the public-address check. Redirect refusal and the size cap still apply.
|
||||
*/
|
||||
async function resolveLogoDataUrl(logoUrl: string): Promise<string | null> {
|
||||
function trustedLogoOrigins(): string[] {
|
||||
const raw = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
if (!raw) return []
|
||||
try {
|
||||
return [new URL(raw).origin]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function isTrustedLogoOrigin(logoUrl: string): boolean {
|
||||
try {
|
||||
return trustedLogoOrigins().includes(new URL(logoUrl).origin)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a stored logo and re-encode it to a PNG data URL. Concurrent calls
|
||||
* for the same URL share a single in-flight request; only successes are
|
||||
* cached so a transient blip is retried on the next render.
|
||||
*/
|
||||
async function resolveLogoDataUrl(logoUrl: string): Promise<LogoResolution> {
|
||||
// Already embedded: nothing to fetch or convert.
|
||||
if (logoUrl.startsWith('data:')) return logoUrl
|
||||
if (logoUrl.startsWith('data:')) return { kind: 'embedded', dataUrl: logoUrl }
|
||||
|
||||
const cached = logoDataUrlCache.get(logoUrl)
|
||||
if (cached && Date.now() - cached.at < LOGO_CACHE_TTL_MS) return cached.dataUrl
|
||||
if (cached && Date.now() - cached.at < LOGO_CACHE_TTL_MS) {
|
||||
return { kind: 'embedded', dataUrl: cached.dataUrl }
|
||||
}
|
||||
|
||||
const inflight = logoInflight.get(logoUrl)
|
||||
if (inflight) return inflight
|
||||
@@ -103,17 +146,32 @@ async function resolveLogoDataUrl(logoUrl: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function encodeLogo(logoUrl: string): Promise<string | null> {
|
||||
async function encodeLogo(logoUrl: string): Promise<LogoResolution> {
|
||||
let res: Response
|
||||
try {
|
||||
const res = await fetch(logoUrl, { signal: AbortSignal.timeout(LOGO_FETCH_TIMEOUT_MS) })
|
||||
if (!res.ok) return null
|
||||
res = await safeFetch(
|
||||
logoUrl,
|
||||
{ signal: AbortSignal.timeout(LOGO_FETCH_TIMEOUT_MS) },
|
||||
{ trustedOrigins: trustedLogoOrigins() },
|
||||
)
|
||||
} catch (err) {
|
||||
if (isUnsafeUrlError(err)) {
|
||||
logoLog.warn('logo URL refused by outbound URL guard; rendering without logo', {
|
||||
reason: err.reason,
|
||||
detail: err.detail,
|
||||
})
|
||||
return { kind: 'refused' }
|
||||
}
|
||||
return { kind: 'failed' }
|
||||
}
|
||||
|
||||
// Reject oversized payloads up front when the server declares a length, and
|
||||
// again after reading in case the header lied or was absent.
|
||||
const declared = Number(res.headers.get('content-length') ?? '')
|
||||
if (Number.isFinite(declared) && declared > LOGO_UPLOAD_MAX_BYTES) return null
|
||||
const input = Buffer.from(await res.arrayBuffer())
|
||||
if (input.byteLength > LOGO_UPLOAD_MAX_BYTES) return null
|
||||
try {
|
||||
if (!res.ok) return { kind: 'failed' }
|
||||
|
||||
// Declared Content-Length is checked before any byte is read, and the
|
||||
// stream is cut off at the cap in case the header lied or was absent.
|
||||
const input = await readBodyWithCap(res, LOGO_UPLOAD_MAX_BYTES)
|
||||
if (!input) return { kind: 'failed' }
|
||||
|
||||
// SVGs must be rasterized at a higher density or sharp renders them at
|
||||
// their intrinsic (often tiny) pixel size and the result looks blurry.
|
||||
@@ -123,7 +181,7 @@ async function encodeLogo(logoUrl: string): Promise<string | null> {
|
||||
input.subarray(0, 256).toString('utf8').trimStart().startsWith('<')
|
||||
|
||||
// Lazy, isolated import: if sharp ever fails to load in a given runtime we
|
||||
// degrade to the original URL instead of breaking invoice sending entirely.
|
||||
// degrade instead of breaking invoice sending entirely.
|
||||
const { default: sharp } = await import('sharp')
|
||||
const png = await sharp(input, isSvg ? { density: 288 } : {})
|
||||
.resize({
|
||||
@@ -144,12 +202,34 @@ async function encodeLogo(logoUrl: string): Promise<string | null> {
|
||||
if (oldest !== undefined) logoDataUrlCache.delete(oldest)
|
||||
}
|
||||
logoDataUrlCache.set(logoUrl, { dataUrl, at: Date.now() })
|
||||
return dataUrl
|
||||
return { kind: 'embedded', dataUrl }
|
||||
} catch {
|
||||
return null
|
||||
return { kind: 'failed' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the logo resolution to the company handed to the PDF template.
|
||||
*
|
||||
* On a transient failure the original URL is kept only when it points at our
|
||||
* own storage origin (the pre-existing "never worse than before" fallback:
|
||||
* @react-pdf can still draw a PNG/JPEG from there). For any other origin the
|
||||
* logo is dropped instead: handing @react-pdf a remote URL means it fetches
|
||||
* it with a plain, redirect-following fetch, which is exactly the unguarded
|
||||
* request this module exists to prevent.
|
||||
*/
|
||||
function applyLogoResolution(company: CompanySettings, resolution: LogoResolution): CompanySettings {
|
||||
if (resolution.kind === 'embedded') {
|
||||
return resolution.dataUrl === company.logo_url
|
||||
? company
|
||||
: { ...company, logo_url: resolution.dataUrl }
|
||||
}
|
||||
if (resolution.kind === 'refused') return { ...company, logo_url: null }
|
||||
return company.logo_url && isTrustedLogoOrigin(company.logo_url)
|
||||
? company
|
||||
: { ...company, logo_url: null }
|
||||
}
|
||||
|
||||
export async function prepareInvoicePdfRender(
|
||||
company: CompanySettings,
|
||||
currency?: Currency,
|
||||
@@ -167,12 +247,8 @@ export async function prepareInvoicePdfRender(
|
||||
: company
|
||||
if (!paymentCompany.logo_url) return { branding, company: paymentCompany }
|
||||
|
||||
const dataUrl = await resolveLogoDataUrl(paymentCompany.logo_url)
|
||||
const resolved =
|
||||
dataUrl && dataUrl !== paymentCompany.logo_url
|
||||
? { ...paymentCompany, logo_url: dataUrl }
|
||||
: paymentCompany
|
||||
return { branding, company: resolved }
|
||||
const resolution = await resolveLogoDataUrl(paymentCompany.logo_url)
|
||||
return { branding, company: applyLogoResolution(paymentCompany, resolution) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,9 @@ import { NextRequest } from 'next/server'
|
||||
*
|
||||
* Focus: every auth bounce must (a) remember where the user was heading,
|
||||
* (b) reject an off-origin destination, and (c) not leak the original query
|
||||
* string onto the auth page. MFA enforcement conditions must be unchanged.
|
||||
* string onto the auth page. MFA enforcement decides on server-authenticated
|
||||
* data only (the getUser() factor list and the signature-verified `aal`
|
||||
* claim), never on the editable cookie session.
|
||||
*/
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
@@ -14,12 +16,26 @@ const state = vi.hoisted(() => ({
|
||||
id: string
|
||||
email?: string
|
||||
app_metadata?: Record<string, unknown>
|
||||
// What GoTrue returns on /user: the server-side factor list.
|
||||
factors?: Array<{ id: string; status: string; factor_type: string }>
|
||||
},
|
||||
sessionId: 'session-1' as string | null,
|
||||
authError: null as unknown,
|
||||
aal: null as null | { currentLevel: string; nextLevel: string },
|
||||
factors: null as null | { totp: Array<{ id: string; status: string }> },
|
||||
listFactors: vi.fn(async () => ({ data: state.factors })),
|
||||
// `aal` claim of the (mock) signature-verified access token, i.e. what
|
||||
// getClaims() reports. null = the token carries no aal claim.
|
||||
jwtAal: null as string | null,
|
||||
// Make getClaims() fail: an error result or a throw.
|
||||
claimsFailure: null as null | 'error' | 'throw',
|
||||
// The cookie-derived assurance lookup and the listFactors round trip. The
|
||||
// proxy must call NEITHER any more: the first computes nextLevel from the
|
||||
// editable cookie session, the second is a getUser() the proxy has already
|
||||
// paid for. Spies so tests can prove it. getAal answers the way a cookie
|
||||
// with `user.factors` stripped would: "nothing to step up to".
|
||||
getAal: vi.fn(async () => ({
|
||||
data: { currentLevel: 'aal1', nextLevel: 'aal1' },
|
||||
error: null,
|
||||
})),
|
||||
listFactors: vi.fn(async () => ({ data: { totp: [] }, error: null })),
|
||||
company: {
|
||||
data: [{ company_id: 'company-1', locale: 'sv', used_fallback: false }],
|
||||
error: null as unknown,
|
||||
@@ -86,13 +102,32 @@ vi.mock('@supabase/ssr', () => ({
|
||||
error: state.authError,
|
||||
}
|
||||
}),
|
||||
getClaims: vi.fn(async () => ({
|
||||
data: { claims: state.sessionId ? { session_id: state.sessionId } : {} },
|
||||
})),
|
||||
getClaims: vi.fn(async () => {
|
||||
if (state.claimsFailure === 'throw') throw new Error('jwks fetch failed')
|
||||
if (state.claimsFailure === 'error') {
|
||||
return {
|
||||
data: null,
|
||||
error: { name: 'AuthInvalidJwtError', message: 'Invalid JWT signature' },
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
claims: {
|
||||
...(state.sessionId ? { session_id: state.sessionId } : {}),
|
||||
...(state.jwtAal ? { aal: state.jwtAal } : {}),
|
||||
// Satisfy the iss/aud pinning the MFA gates apply (lib/auth/claims.ts).
|
||||
iss: `${(process.env.NEXT_PUBLIC_SUPABASE_URL ?? '').replace(/\/+$/, '')}/auth/v1`,
|
||||
aud: 'authenticated',
|
||||
sub: state.user?.id,
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
}),
|
||||
signOut: state.signOut,
|
||||
mfa: {
|
||||
getAuthenticatorAssuranceLevel: vi.fn(async () => ({ data: state.aal })),
|
||||
listFactors: (...args: unknown[]) => state.listFactors(...args),
|
||||
getAuthenticatorAssuranceLevel: () => state.getAal(),
|
||||
listFactors: () => state.listFactors(),
|
||||
},
|
||||
},
|
||||
rpc: vi.fn(async () => state.company),
|
||||
@@ -172,6 +207,9 @@ import { SESSION_TIMEOUT_COOKIE } from '@/lib/auth/session-timeout-shared'
|
||||
|
||||
const ORIGIN = 'http://localhost:3000'
|
||||
const SIGNED_IN = { id: 'user-1', app_metadata: {} }
|
||||
const VERIFIED_TOTP = { id: 'f1', status: 'verified', factor_type: 'totp' }
|
||||
/** A user whose server-side record carries a verified TOTP factor. */
|
||||
const MFA_USER = { ...SIGNED_IN, factors: [VERIFIED_TOTP] }
|
||||
|
||||
function locationOf(response: Response) {
|
||||
return response.headers.get('location')
|
||||
@@ -199,12 +237,13 @@ describe('updateSession redirect destinations', () => {
|
||||
vi.clearAllMocks()
|
||||
logState.info.mockClear()
|
||||
state.listFactors.mockClear()
|
||||
state.getAal.mockClear()
|
||||
state.user = null
|
||||
state.sessionId = 'session-1'
|
||||
state.authError = null
|
||||
state.cookieWrites = []
|
||||
state.aal = null
|
||||
state.factors = null
|
||||
state.jwtAal = null
|
||||
state.claimsFailure = null
|
||||
state.company = {
|
||||
data: [{ company_id: 'company-1', locale: 'sv', used_fallback: false }],
|
||||
error: null,
|
||||
@@ -619,8 +658,9 @@ describe('updateSession redirect destinations', () => {
|
||||
describe('MFA step-up bounce to /mfa/verify', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_REQUIRE_MFA = 'true'
|
||||
state.user = SIGNED_IN
|
||||
state.aal = { currentLevel: 'aal1', nextLevel: 'aal2' }
|
||||
// Verified factor on the server-side user, single-factor token.
|
||||
state.user = MFA_USER
|
||||
state.jwtAal = 'aal1'
|
||||
})
|
||||
|
||||
it('preserves the destination as ?returnTo=', async () => {
|
||||
@@ -657,14 +697,48 @@ describe('updateSession redirect destinations', () => {
|
||||
expect(new URL(locationOf(response)!).pathname).toBe('/mfa/verify')
|
||||
expect(response.cookies.get('sb-test-auth-token')?.value).toBe('rotated')
|
||||
})
|
||||
|
||||
it('decides on the server-side factor list, never on the cookie session', async () => {
|
||||
// The attack: the sb-*-auth-token cookie is unsigned JSON, so the
|
||||
// password holder strips `user.factors` and the local assurance lookup
|
||||
// reports nextLevel aal1 ("nothing to step up to"). state.getAal is
|
||||
// that view; the proxy must not even ask for it.
|
||||
const response = await run('/invoices')
|
||||
|
||||
expect(new URL(locationOf(response)!).pathname).toBe('/mfa/verify')
|
||||
expect(state.getAal).not.toHaveBeenCalled()
|
||||
expect(state.listFactors).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['error', 'throw'] as const)(
|
||||
'fails closed when getClaims reports %s',
|
||||
async (failure) => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
state.claimsFailure = failure
|
||||
|
||||
const response = await run('/invoices')
|
||||
|
||||
expect(new URL(locationOf(response)!).pathname).toBe('/mfa/verify')
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
errorSpy.mockRestore()
|
||||
},
|
||||
)
|
||||
|
||||
it('fails closed when the verified claims carry no aal', async () => {
|
||||
state.jwtAal = null
|
||||
|
||||
const response = await run('/invoices')
|
||||
|
||||
expect(new URL(locationOf(response)!).pathname).toBe('/mfa/verify')
|
||||
})
|
||||
})
|
||||
|
||||
describe('forced enrollment bounce to /mfa/enroll', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_REQUIRE_MFA = 'true'
|
||||
// No factor on the server-side user, single-factor token.
|
||||
state.user = SIGNED_IN
|
||||
state.aal = { currentLevel: 'aal1', nextLevel: 'aal1' }
|
||||
state.factors = { totp: [] }
|
||||
state.jwtAal = 'aal1'
|
||||
})
|
||||
|
||||
it('preserves the destination as ?returnTo=', async () => {
|
||||
@@ -675,12 +749,15 @@ describe('updateSession redirect destinations', () => {
|
||||
expect(url.searchParams.get('returnTo')).toBe('/invoices/new')
|
||||
})
|
||||
|
||||
it('still forces enrollment (the gate itself is unchanged)', async () => {
|
||||
state.factors = { totp: [{ id: 'f1', status: 'verified' }] }
|
||||
it('steps up instead of enrolling when the server-side user already has a verified factor', async () => {
|
||||
// Previously this scenario (cookie says no factor, server says one
|
||||
// exists, token at aal1) rendered the page at AAL1: the verify bounce
|
||||
// trusted the cookie and the enrolment check then found the factor.
|
||||
state.user = MFA_USER
|
||||
|
||||
const response = await run('/invoices/new')
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(new URL(locationOf(response)!).pathname).toBe('/mfa/verify')
|
||||
})
|
||||
|
||||
it('skips enrollment for a user with no company, as before', async () => {
|
||||
@@ -691,18 +768,19 @@ describe('updateSession redirect destinations', () => {
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
it('still asks for the factor list at aal1/aal1 before bouncing', async () => {
|
||||
it('reads the factor list off the getUser() round trip, not a second auth call', async () => {
|
||||
const response = await run('/invoices/new')
|
||||
|
||||
expect(new URL(locationOf(response)!).pathname).toBe('/mfa/enroll')
|
||||
expect(state.listFactors).toHaveBeenCalledTimes(1)
|
||||
expect(state.listFactors).not.toHaveBeenCalled()
|
||||
expect(state.getAal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never calls listFactors once the session is at aal2 (a verified factor is implied)', async () => {
|
||||
state.aal = { currentLevel: 'aal2', nextLevel: 'aal2' }
|
||||
// Even a factor list that would read as "none" must not matter here:
|
||||
// the call is skipped, not just its result ignored.
|
||||
state.factors = { totp: [] }
|
||||
it('lets an aal2 token through even when the server-side user has no factor left', async () => {
|
||||
// A user who unenrols their last factor mid-session keeps aal2 until
|
||||
// the next token refresh; the enrolment bounce lands on the refresh,
|
||||
// not on the next click (unchanged deferral, PR #1922).
|
||||
state.jwtAal = 'aal2'
|
||||
|
||||
const response = await run('/invoices/new')
|
||||
|
||||
@@ -711,12 +789,123 @@ describe('updateSession redirect destinations', () => {
|
||||
})
|
||||
|
||||
it('does not spend an MFA lookup on RSC and prefetch requests at aal2 either', async () => {
|
||||
state.aal = { currentLevel: 'aal2', nextLevel: 'aal2' }
|
||||
state.jwtAal = 'aal2'
|
||||
|
||||
await run('/invoices', { headers: { rsc: '1' } })
|
||||
await run('/invoices', { headers: { 'next-router-prefetch': '1', rsc: '1' } })
|
||||
|
||||
expect(state.listFactors).not.toHaveBeenCalled()
|
||||
expect(state.getAal).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ── API branch: the MFA gate for cookie sessions ──────────────────────
|
||||
|
||||
describe('API MFA gate for cookie sessions', () => {
|
||||
const FORBIDDEN = { error: 'MFA-verifiering krävs.' }
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_REQUIRE_MFA = 'true'
|
||||
state.user = MFA_USER
|
||||
state.jwtAal = 'aal1'
|
||||
})
|
||||
|
||||
it('returns 403 for an AAL1 session whose server-side user has a verified factor', async () => {
|
||||
const response = await run('/api/invoices')
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
await expect(response.json()).resolves.toEqual(FORBIDDEN)
|
||||
})
|
||||
|
||||
it('never consults the cookie-derived assurance level or a second factor lookup', async () => {
|
||||
// state.getAal reports nextLevel aal1: the answer a cookie with
|
||||
// `user.factors` stripped produces. It must not be asked at all.
|
||||
const response = await run('/api/invoices')
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(state.getAal).not.toHaveBeenCalled()
|
||||
expect(state.listFactors).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets an AAL2 session through', async () => {
|
||||
state.jwtAal = 'aal2'
|
||||
|
||||
const response = await run('/api/invoices')
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
it.each(['error', 'throw'] as const)(
|
||||
'fails closed when getClaims reports %s',
|
||||
async (failure) => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
state.claimsFailure = failure
|
||||
|
||||
const response = await run('/api/invoices')
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
errorSpy.mockRestore()
|
||||
},
|
||||
)
|
||||
|
||||
it('fails closed when the verified claims carry no aal', async () => {
|
||||
state.jwtAal = null
|
||||
|
||||
const response = await run('/api/invoices')
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it("passes a user with nothing to step up to (enrolment stays the page gate's job)", async () => {
|
||||
state.user = SIGNED_IN
|
||||
|
||||
expect((await run('/api/invoices')).status).toBe(200)
|
||||
|
||||
state.user = {
|
||||
...SIGNED_IN,
|
||||
factors: [{ id: 'f2', status: 'unverified', factor_type: 'totp' }],
|
||||
}
|
||||
|
||||
expect((await run('/api/invoices')).status).toBe(200)
|
||||
expect(state.getAal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the AAL1 escape hatches and the OAuth endpoints open', async () => {
|
||||
for (const path of ['/api/account/delete', '/api/company/current', '/api/mcp-oauth/token']) {
|
||||
expect((await run(path)).status).toBe(200)
|
||||
}
|
||||
})
|
||||
|
||||
it('skips Bearer-auth surfaces by path, and only with the header present', async () => {
|
||||
const headers = { authorization: 'Bearer key' }
|
||||
|
||||
expect((await run('/api/v1/companies/c1/invoices', { headers })).status).toBe(200)
|
||||
expect((await run('/api/extensions/ext/mcp-server/mcp', { headers })).status).toBe(200)
|
||||
// A cookie session on a v1 path without the header is still a cookie session.
|
||||
expect((await run('/api/v1/companies/c1/invoices')).status).toBe(403)
|
||||
// A forged header on a cookie-authenticated route never disables the gate.
|
||||
expect((await run('/api/invoices', { headers })).status).toBe(403)
|
||||
})
|
||||
|
||||
it('does not gate BankID-linked users, nor anyone when MFA is off', async () => {
|
||||
state.user = { ...MFA_USER, app_metadata: { bankid_linked: true } }
|
||||
expect((await run('/api/invoices')).status).toBe(200)
|
||||
|
||||
state.user = MFA_USER
|
||||
delete process.env.NEXT_PUBLIC_REQUIRE_MFA
|
||||
expect((await run('/api/invoices')).status).toBe(200)
|
||||
})
|
||||
|
||||
it('carries the rotated auth cookie on the 403', async () => {
|
||||
state.cookieWrites = [
|
||||
{ name: 'sb-test-auth-token', value: 'rotated', options: { path: '/' } },
|
||||
]
|
||||
|
||||
const response = await run('/api/invoices')
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(response.cookies.get('sb-test-auth-token')?.value).toBe('rotated')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1080,8 +1269,8 @@ describe('updateSession redirect destinations', () => {
|
||||
|
||||
describe('MFA-disabled and self-hosted paths are unchanged', () => {
|
||||
it('does not redirect when NEXT_PUBLIC_REQUIRE_MFA is unset', async () => {
|
||||
state.user = SIGNED_IN
|
||||
state.aal = { currentLevel: 'aal1', nextLevel: 'aal2' }
|
||||
state.user = MFA_USER
|
||||
state.jwtAal = 'aal1'
|
||||
|
||||
const response = await run('/settings/tax')
|
||||
|
||||
@@ -1091,9 +1280,8 @@ describe('updateSession redirect destinations', () => {
|
||||
it('does not redirect on self-hosted even with MFA required', async () => {
|
||||
process.env.NEXT_PUBLIC_REQUIRE_MFA = 'true'
|
||||
process.env.NEXT_PUBLIC_SELF_HOSTED = 'true'
|
||||
state.user = SIGNED_IN
|
||||
state.aal = { currentLevel: 'aal1', nextLevel: 'aal2' }
|
||||
state.factors = { totp: [] }
|
||||
state.user = MFA_USER
|
||||
state.jwtAal = 'aal1'
|
||||
|
||||
const response = await run('/settings/tax')
|
||||
|
||||
@@ -1102,8 +1290,8 @@ describe('updateSession redirect destinations', () => {
|
||||
|
||||
it('does not redirect BankID-linked users, who are already 2FA', async () => {
|
||||
process.env.NEXT_PUBLIC_REQUIRE_MFA = 'true'
|
||||
state.user = { id: 'user-1', app_metadata: { bankid_linked: true } }
|
||||
state.aal = { currentLevel: 'aal1', nextLevel: 'aal2' }
|
||||
state.user = { ...MFA_USER, app_metadata: { bankid_linked: true } }
|
||||
state.jwtAal = 'aal1'
|
||||
|
||||
const response = await run('/settings/tax')
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
|
||||
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
|
||||
const isBuildPlaceholder = !url || url.startsWith('__')
|
||||
|
||||
// Cookie encoding must match lib/supabase/server.ts (default
|
||||
// `user-and-tokens`); see the note there before switching to `tokens-only`.
|
||||
export function createClient() {
|
||||
return createBrowserClient(
|
||||
isBuildPlaceholder ? 'https://placeholder.supabase.co' : url,
|
||||
|
||||
+100
-32
@@ -1,4 +1,5 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import type { User } from '@supabase/supabase-js'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
type ProxyTimings,
|
||||
} from '@/lib/supabase/proxy-timing'
|
||||
import { shouldEnforceMfa } from '@/lib/auth/mfa'
|
||||
import { claimsPinned } from '@/lib/auth/claims'
|
||||
import { isMultiUserEnforced } from '@/lib/entitlements/multi-user'
|
||||
import { MULTI_USER_GRACE_DAYS } from '@/lib/entitlements/multi-user-state'
|
||||
import { apiPathSkipsMfaGate } from '@/lib/auth/api-mfa-gate'
|
||||
@@ -262,11 +264,22 @@ async function updateSessionInner(
|
||||
pathname,
|
||||
hasAuthorizationHeader,
|
||||
)
|
||||
if (!skipMfaGate && user && shouldEnforceMfa(user)) {
|
||||
const { data: aal } = await timed(timing, 'mfaMs', () =>
|
||||
supabase.auth.mfa.getAuthenticatorAssuranceLevel(),
|
||||
// `user` is the getUser() result above: server-authenticated, so its
|
||||
// factor list is trustworthy. Only a session with something to step up
|
||||
// TO is gated here; forcing enrolment stays the page branch's job, as
|
||||
// before. The assurance level itself comes from the signature-verified
|
||||
// claims and fails CLOSED (see resolveVerifiedAal), never from the
|
||||
// cookie's session object.
|
||||
if (
|
||||
!skipMfaGate &&
|
||||
user &&
|
||||
shouldEnforceMfa(user) &&
|
||||
userHasVerifiedFactor(user)
|
||||
) {
|
||||
const aal = await timed(timing, 'mfaMs', () =>
|
||||
resolveVerifiedAal(supabase),
|
||||
)
|
||||
if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') {
|
||||
if (aal !== 'aal2') {
|
||||
const response = NextResponse.json(
|
||||
{ error: 'MFA-verifiering krävs.' },
|
||||
{ status: 403 },
|
||||
@@ -455,38 +468,31 @@ async function updateSessionInner(
|
||||
|
||||
// MFA enforcement (application-side only, not RLS)
|
||||
if (shouldEnforceMfa(user)) {
|
||||
const { data: aal } = await timed(timing, 'mfaMs', () =>
|
||||
supabase.auth.mfa.getAuthenticatorAssuranceLevel(),
|
||||
)
|
||||
const aal = await timed(timing, 'mfaMs', () => resolveVerifiedAal(supabase))
|
||||
|
||||
// User has MFA enrolled but hasn't verified this session → redirect to verify
|
||||
if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') {
|
||||
return bounceToAuth(request, supabaseResponse, '/mfa/verify')
|
||||
}
|
||||
// Nothing below applies at AAL2: reaching it requires having verified a
|
||||
// challenge on a verified factor. Deliberately also skipped for a user
|
||||
// who unenrols their last factor mid-session: the JWT keeps aal2 until
|
||||
// the next token refresh, so the enrolment bounce lands on the refresh
|
||||
// instead of the next click (unchanged from the listFactors-era gate,
|
||||
// PR #1922).
|
||||
if (aal !== 'aal2') {
|
||||
// The factor list is read off the server-authenticated getUser()
|
||||
// result above, never off the cookie session. A cookie edited to hide
|
||||
// the factor used to sail past this bounce, and because the enrolment
|
||||
// check below then found the factor server-side, straight onto the
|
||||
// page at AAL1. Reading it here also drops the listFactors() round
|
||||
// trip that check used to pay: auth-js implements listFactors() as
|
||||
// that very getUser() call.
|
||||
if (userHasVerifiedFactor(user)) {
|
||||
return bounceToAuth(request, supabaseResponse, '/mfa/verify')
|
||||
}
|
||||
|
||||
// MFA required but user has no factor enrolled yet → force enrollment
|
||||
// Skip for users with no companies (still setting up).
|
||||
//
|
||||
// Only worth asking when the session is NOT at AAL2: reaching AAL2
|
||||
// requires having verified a challenge on a verified factor, so the
|
||||
// factor list cannot be empty there. auth-js implements listFactors()
|
||||
// as a getUser() network round trip, and running it here on every
|
||||
// page, RSC and prefetch request for every MFA-verified user was the
|
||||
// second Supabase Auth call per request (measured via mw-mfa, PR #1922).
|
||||
// The narrow case this defers is a user who unenrols their last factor
|
||||
// mid-session: the JWT keeps aal2 until the next token refresh, so the
|
||||
// enrolment bounce lands on the refresh instead of the next click.
|
||||
if (aal?.currentLevel !== 'aal2') {
|
||||
// MFA required but no factor enrolled yet: force enrolment. Skipped
|
||||
// for users with no companies (still setting up).
|
||||
const { companyId: companyIdForMfa } = await resolveCompanyOnce()
|
||||
if (companyIdForMfa) {
|
||||
const { data: factors } = await timed(timing, 'mfaMs', () =>
|
||||
supabase.auth.mfa.listFactors(),
|
||||
)
|
||||
const hasVerifiedFactor = factors?.totp?.some(f => f.status === 'verified')
|
||||
|
||||
if (!hasVerifiedFactor) {
|
||||
return bounceToAuth(request, supabaseResponse, '/mfa/enroll')
|
||||
}
|
||||
return bounceToAuth(request, supabaseResponse, '/mfa/enroll')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -641,6 +647,68 @@ async function getSupabaseSessionId(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the SERVER-authenticated user carries a verified MFA factor.
|
||||
*
|
||||
* Only ever call this with the getUser() result: GoTrue returns `factors` on
|
||||
* /user (auth-js's listFactors() is that same call, filtered), so reading it
|
||||
* off the round trip the proxy has already paid costs nothing extra. The
|
||||
* server omits an empty list, so a missing array means "no factor", exactly
|
||||
* the reading listFactors() would give.
|
||||
*
|
||||
* Never call it with a user deserialised from the session cookie: the
|
||||
* sb-*-auth-token cookie is unsigned base64 JSON, so whoever holds the
|
||||
* password can strip `factors` from it and make an enrolled account look
|
||||
* like one with nothing to step up to.
|
||||
*/
|
||||
function userHasVerifiedFactor(user: Pick<User, 'factors'>): boolean {
|
||||
return user.factors?.some((factor) => factor.status === 'verified') ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Assurance level of the current session, read from the signature-verified
|
||||
* JWT claims: getClaims() checks the token locally against the cached JWKS
|
||||
* (server-side for HS256 projects) and the iss/aud pinning is the same one
|
||||
* require-auth applies. Returns null on ANY failure so both MFA gates fail
|
||||
* closed; each failure is logged because a spike means MFA users are being
|
||||
* refused, which must be visible in production.
|
||||
*
|
||||
* Deliberately not `mfa.getAuthenticatorAssuranceLevel()`: called without a
|
||||
* JWT it computes `nextLevel` from `session.user.factors`, and that session
|
||||
* is the editable cookie described on userHasVerifiedFactor. Its
|
||||
* `currentLevel` happens to be sound (the JWT the preceding getUser() was
|
||||
* accepted with), but the two halves come as one answer, so neither gate
|
||||
* consumes it any more (security audit 2026-09).
|
||||
*/
|
||||
async function resolveVerifiedAal(
|
||||
supabase: ReturnType<typeof createServerClient>,
|
||||
): Promise<string | null> {
|
||||
if (typeof supabase.auth.getClaims !== 'function') {
|
||||
console.error('[middleware] getClaims unavailable; treating session as not MFA-assured')
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.auth.getClaims()
|
||||
const claims = data?.claims
|
||||
if (error || !claims) {
|
||||
console.error('[middleware] getClaims failed; treating session as not MFA-assured', error)
|
||||
return null
|
||||
}
|
||||
if (!claimsPinned(claims)) {
|
||||
console.error('[middleware] getClaims iss/aud pinning failed; treating session as not MFA-assured', {
|
||||
iss: claims.iss,
|
||||
aud: claims.aud,
|
||||
})
|
||||
return null
|
||||
}
|
||||
return typeof claims.aal === 'string' ? claims.aal : null
|
||||
} catch (error) {
|
||||
console.error('[middleware] getClaims threw; treating session as not MFA-assured', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function signOutTimedOutSession(
|
||||
supabase: ReturnType<typeof createServerClient>,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -9,6 +9,25 @@ const isBuildPlaceholder = url?.startsWith('__')
|
||||
const safeUrl = isBuildPlaceholder ? 'https://placeholder.supabase.co' : url
|
||||
const safeKey = isBuildPlaceholder ? 'placeholder' : key
|
||||
|
||||
/**
|
||||
* Cookie-session client for server components, server actions and routes.
|
||||
*
|
||||
* Cookie encoding stays the default `user-and-tokens` on purpose.
|
||||
* @supabase/ssr 0.12 offers an experimental `cookies.encode: 'tokens-only'`
|
||||
* that keeps the user object out of the cookie, but auth-js then substitutes a
|
||||
* THROWING proxy for `session.user` wherever no user store holds it: every
|
||||
* fresh server request (app/api/mcp-oauth/authorize/route.ts calls
|
||||
* `mfa.getAuthenticatorAssuranceLevel()`, which reads `session.user.factors`)
|
||||
* and, in the browser, every `getSession().user` read after a session minted
|
||||
* by the server-side PKCE callback (reset-password, SendInvoiceDialog) until
|
||||
* the next token refresh. The trust problem is solved at the consumers
|
||||
* instead: lib/supabase/middleware.ts and lib/auth/require-auth.ts never read
|
||||
* MFA state off the cookie's user object (factors come from getUser() or
|
||||
* listFactors(), the level from signature-verified claims). Switch the
|
||||
* encoding only together with those call sites, and identically here, in
|
||||
* client.ts and in middleware.ts: @supabase/ssr requires the two sides to
|
||||
* match.
|
||||
*/
|
||||
export async function createClient() {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user