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.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
# 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](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](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)
|
||||
|
||||
```powershell
|
||||
# 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):
|
||||
|
||||
```bash
|
||||
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](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](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`.
|
||||
Reference in New Issue
Block a user