Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
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:
- Login / logout (session cookie)
- CSRF rejection when
X-CSRF-Tokenis missing or wrong - API key auth (
Authorization: BearerandX-API-Key) - 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
tokenis present in the query string: keep it only in JS state, show a link-recognized message (no token value in HTML), andhistory.replaceStateto striptokenfrom the address bar. - If no query token: show a masked
type="password"paste field withautocomplete="off". - Settings:
autocomplete="off"on one-time invite accept-link and new API key display fields.
Also confirmed already correct:
- Create-invite returns
tokenonly 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_prefixonly.
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
- 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. - 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.
- Demo API key / password in demo-user.md are local-only; rotate or disable before shared staging.
- In-process CSRF/session controls are not a substitute for HTTPS +
SessionSecure=truein production. - 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/SetPasswordfor public reset — see forgot-password.md.
Contract preserved
- Dashboard
/api/*(non-v1): session cookie + CSRF double-submit (X-CSRF-Tokenequalsdescrybe_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.