Files
accounted/lib/api/v1/base-url.ts
T
Jakob WennbergandClaude Fable 5 b420f3e1d9 feat(domains): dual-domain cutover to app.accounted.se (#1087)
* feat(domains): dual-domain cutover to app.accounted.se

The user-facing app moves to app.accounted.se while app.gnubok.se stays
alive for machine traffic (MCP connectors, API keys, third-party OAuth
callbacks, webhooks, crons), so no third-party callback registration is
on the critical path.

- next.config: host redirect app.gnubok.se -> NEXT_PUBLIC_APP_URL for
  page traffic only (/api, /.well-known, /_next excluded). Arms itself
  only once NEXT_PUBLIC_APP_URL leaves the legacy host, so merging this
  is inert and the cutover is a pure env flip + redeploy.
- skatteverket: redirect_uri pinned via NEXT_PUBLIC_SKV_OAUTH_BASE_URL
  (Utvecklarportalen registration is slow to change); the OAuth callback
  now resolves the flow from the state token + stored oauth_user_id via
  the service client instead of session cookies, which no longer exist
  on the OAuth host. Legacy same-domain flows fall back to the session.
- popup listeners (SkatteverketConnectPanel, AGIPanel) accept postMessage
  from the pinned OAuth origin; event.source identity check unchanged.
- /.well-known discovery docs reflect the allowlisted request host so
  existing MCP connectors on app.gnubok.se keep a self-consistent
  issuer/resource after the flip; spoofed hosts fall back to canonical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: log dual-domain cutover decision

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): recency-bound SKV state lookup, exact localhost match in discovery allowlist

- The oauth_state lookup now only considers rows updated in the last 10
  minutes: bounds how long a leaked/phished authorize URL stays
  completable, keeps the row set far below PostgREST's 1000-row cap, and
  surfaces query errors instead of misreporting them as CSRF.
- resolveDiscoveryBaseUrl matches localhost/127.0.0.1 exactly; the
  prefix check reflected spoofed hosts like localhost.evil.example.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 11:39:42 +02:00

59 lines
2.3 KiB
TypeScript

/**
* Canonical base URL for v1 surfaces.
*
* Use this, NOT `new URL(request.url).host`, when assembling URLs that go
* into discovery files, OpenAPI specs, or any response a 3rd-party agent
* caches. The inbound `Host` header is attacker-controlled at the edge; a
* spoofed value would otherwise poison agent discovery and redirect them
* to attacker-controlled endpoints.
*
* Honours `NEXT_PUBLIC_APP_URL` (the same env var the rest of the codebase
* uses for absolute links). Falls back to `https://localhost:3000` for
* local development when the env var is unset: this is a deliberate
* fail-closed default that any production deploy will override.
*/
export function getCanonicalBaseUrl(): string {
const fromEnv = process.env.NEXT_PUBLIC_APP_URL?.trim()
if (fromEnv) return fromEnv.replace(/\/$/, '')
return 'http://localhost:3000'
}
/**
* Base URL for the OAuth/MCP discovery documents (/.well-known/*).
*
* The app is served on two domains since the accounted.se cutover:
* app.accounted.se for humans and app.gnubok.se for machine traffic
* (existing MCP connectors and API clients were configured against the
* legacy host and it can never be retired). RFC 8414/9728 clients validate
* that the metadata they fetch is self-consistent with the host they
* fetched it from, so discovery must reflect the host the client actually
* used. The Host header stays attacker-controlled at the edge, so only
* allowlisted hosts are reflected; anything else falls back to the
* canonical base URL.
*/
const LEGACY_DISCOVERY_HOSTS = new Set(['app.gnubok.se'])
export function resolveDiscoveryBaseUrl(request: Request): string {
const canonical = getCanonicalBaseUrl()
const host = request.headers.get('host')?.trim().toLowerCase()
if (!host) return canonical
let canonicalHost: string | null = null
try {
canonicalHost = new URL(canonical).host.toLowerCase()
} catch {
canonicalHost = null
}
if (host === canonicalHost) return canonical
if (LEGACY_DISCOVERY_HOSTS.has(host)) return `https://${host}`
// Exact hostname match: a prefix check would reflect spoofed hosts like
// localhost.evil.example into the discovery documents.
const hostname = host.replace(/:\d+$/, '')
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return `http://${host}`
}
return canonical
}