Files
descrybe/docs/live-auth-security.md
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

5.9 KiB

Live auth security verification

Date: 2026-08-04
Environment: local Descrybe v2 (API http://127.0.0.1:8080, web http://127.0.0.1:5174)
Demo credentials: see demo-user.md (local only).

Scope

Live checks against a running API + web app:

  1. Login / logout (session cookie)
  2. CSRF rejection when X-CSRF-Token is missing or wrong
  3. API key auth (Authorization: Bearer and X-API-Key)
  4. Invite flow without incorrectly leaking tokens in the UI/HTML

Related static notes: security-notes.md.

Results (2026-08-04)

23 / 23 checks passed after the accept-invite UI fix below.

Area Result Notes
CSRF cookie issued on GET under CSRF middleware PASS descrybe_csrf, 32 hex chars, not HttpOnly (readable by JS for double-submit)
POST /api/auth/login without CSRF header PASS HTTP 403 csrf token mismatch
POST /api/team/invites without CSRF (authenticated) PASS HTTP 403
POST /api/auth/logout without CSRF PASS HTTP 403
Login bad password PASS HTTP 401
Login demo user + CSRF PASS HTTP 200; sets descrybe_session HttpOnly
GET /api/auth/me after login PASS Session identity returned
Logout with CSRF PASS HTTP 200; subsequent /api/auth/me returns 401
Create invite (SMTP off) PASS mail_sent=false; token returned once in create response
GET /api/team/invites PASS Pending list has no token field
GET /api/api-keys PASS Prefix only; no plaintext dk_ secret
Authorization: Bearer demo key to /api/v1/products PASS HTTP 200
X-API-Key demo key PASS HTTP 200
Bad / missing API key PASS HTTP 401
/api/v1/* skips cookie CSRF PASS Key auth only
Accept-invite page with ?token= PASS Token not in HTML; no visible token field; shows Invite link recognized
Accept invite + reuse PASS Accept 200; second accept 400

Harness note

PowerShell WebRequestSession.Headers persists X-CSRF-Token across calls. CSRF-negative tests must call $session.Headers.Clear() (or use a client that does not cache custom headers). Without clearing, a missing-CSRF probe can false-pass.

Fix applied

Problem: /accept-invite?token=... bound the secret into a visible type="text" input (reflected into the DOM). That is incorrect for email-link acceptance.

Change: apps/web/src/routes/accept-invite/+page.svelte

  • If token is present in the query string: keep it only in JS state, show a link-recognized message (no token value in HTML), and history.replaceState to strip token from the address bar.
  • If no query token: show a masked type="password" paste field with autocomplete="off".
  • Settings: autocomplete="off" on one-time invite accept-link and new API key display fields.

Also confirmed already correct:

  • Create-invite returns token only when mail was not delivered (inviteMailResult).
  • List invites / list API keys never return full secrets.
  • New API key plaintext is shown once in a dismissible dialog; table shows key_prefix only.

How to re-run (manual)

# From repo root with API on :8080 and web on :5174
$base = "http://127.0.0.1:8080"
$s = New-Object Microsoft.PowerShell.Commands.WebRequestSession
try { Invoke-WebRequest "$base/api/auth/me" -WebSession $s -UseBasicParsing | Out-Null } catch {}
$csrf = ($s.Cookies.GetCookies([uri]$base) | ? Name -eq descrybe_csrf).Value

# Expect 403
$s.Headers.Clear()
try { Invoke-WebRequest "$base/api/auth/login" -Method POST -WebSession $s -ContentType "application/json" -Body '{"email":"x","password":"y"}' -UseBasicParsing } catch { [int]$_.Exception.Response.StatusCode }

# Login
Invoke-WebRequest "$base/api/auth/login" -Method POST -WebSession $s -Headers @{"X-CSRF-Token"=$csrf} `
  -ContentType "application/json" -Body '{"email":"demo@descrybe.local","password":"DemoPass123!"}' -UseBasicParsing

# API key
Invoke-WebRequest "$base/api/v1/products?limit=1" -Headers @{ Authorization = "Bearer dk_demo_local_descrybe_test_key_v1" } -UseBasicParsing

# Accept-invite HTML must not contain the probe token
$tok = "LIVESEC_TOKEN_SHOULD_NOT_SSR_LEAK_ABCDEF"
$html = (Invoke-WebRequest "http://127.0.0.1:5174/accept-invite?token=$tok" -UseBasicParsing).Content
$html.Contains($tok)          # expect False
$html -match "Invite token"   # expect False when ?token= present

Unit coverage (no live server):

cd apps/api && go test ./internal/httpapi/ -run "CSRF|InviteMail" -count=1

Note: as of this run, go test ./internal/httpapi failed to compile due to an unrelated missing strconv import in internal/shopify/orders_sync.go. Live HTTP checks above are the source of truth for this doc.

Residual risks

  1. Invite/reset links still use ?token= -- the first request URL (and possibly reverse-proxy access logs) can contain the secret until the client strips it. Prefer #token= or a short-lived exchange code for a future hardening pass.
  2. Operator one-time accept link on Settings (when SMTP is off) intentionally shows the full URL once so it can be copied -- dismiss after sharing; do not leave the tab open on a shared screen.
  3. Demo API key / password in demo-user.md are local-only; rotate or disable before shared staging.
  4. In-process CSRF/session controls are not a substitute for HTTPS + SessionSecure=true in production.
  5. No self-serve forgot-password. Password recovery for established accounts is operator/admin (migration set-password re-issue only). Do not relax must_set_password / SetPassword for public reset — see forgot-password.md.

Contract preserved

  • Dashboard /api/* (non-v1): session cookie + CSRF double-submit (X-CSRF-Token equals descrybe_csrf).
  • Public /api/v1/*: API key only; CSRF skipped.
  • Invite plaintext token: create response only when undelivered; never on list.
  • API key plaintext: create response only; list returns key_prefix.