Files
descrybe/docs/security-notes.md
T
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

98 lines
11 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Security notes (Descrybe v2 API)
Last reviewed: production-readiness harden pass — 2026-08-04; Aug-8 refresh (Stripe boot honesty, public export opaque 404, email dry-run); Aug-9 Product 10 (email login lockout, TRUSTED_PROXIES docs, single-node rate-limit ASSUMPTION).
## Mitigations in place
| Area | Control |
|------|---------|
| CSRF | Double-submit cookie `descrybe_csrf` + `X-CSRF-Token` on dashboard `/api/*`. Skipped for `/api/v1`, `/api/public/*`, `/api/webhooks/*`. Cookie: non-HttpOnly, `Secure` follows `SESSION_SECURE`, `SameSite=Lax`, Max-Age 7d. |
| Sessions | `alexedwards/scs` + Postgres store; cookie HttpOnly, `Secure`=`SESSION_SECURE`, `SameSite=Lax`, idle timeout (`SESSION_IDLE_HOURS`, default 24h), absolute lifetime 7d. |
| Production fail-closed | `APP_ENV=production` requires `SESSION_SECURE=true`, https `WEB_ORIGIN`, `APP_ENCRYPTION_KEY`, `TOKEN_SIGNING_SECRET`, and `STRIPE_MOCK=false`. Stripe secret/webhook keys are **not** required at boot (`validate`); they may live in admin `platform_settings` (env fallback) and fail closed at Checkout/webhook use. |
| CORS | Single allowlist origin = `WEB_ORIGIN` (absolute http(s) origin only; `*` rejected). `AllowCredentials=true`. |
| Feed URL SSRF | `internal/feeds/download.go` blocks private/loopback/link-local/CGNAT/metadata ranges; re-checks on redirect and at `DialContext`; optional bypass via `FEED_URL_PRIVATE_ALLOWLIST`. |
| WooCommerce SSRF | Store URL normalize blocks private/metadata; dial-time SSRF via `security.SafeHTTPClient` (loopback allowed for local mock stores only). HTTPS required except localhost. Body cap **8 MiB**; client timeout **30s**. |
| Shopify SSRF | `security.ValidateShopifyShopDomain` forces `*.myshopify.com` (no custom-domain Admin base). Dial with `SafeHTTPClient(allowLoopback=false)`. Until native OAuth ships, Shopify catalogs use feed URL path (same feed SSRF). |
| Feed download size | Hard cap **50 MiB** (`io.LimitReader` + `Content-Length` check). |
| Feed download timeout | Client timeout **60s**; dial **10s**; response headers **30s**. |
| JSON request bodies | `DecodeJSON` caps at **2 MiB**. |
| Uploads | Catalog CSV: parse budget **6 MiB**, stored file **5 MiB**, `.csv` only, path sanitized under `UPLOAD_DIR/{company_id}/`. Brand logo: **2 MiB**, image types only. |
| SQL | App queries use parameterized `$n` / `ANY($n)` binds (sqlc + pgx). Migrator table names are allowlisted separately. |
| AuthZ (tenant) | Session `RequireCompany` + API key company binding; brand / SEO / email / Woo handlers take `company_id` from session context only. Catalog/campaign queries filter `WHERE company_id = $n`. |
| Auth rate limit | HTTP: **10 POSTs / minute / IP** on login / invite / set-password / sales-contact; **5 / minute / IP** on register (separate buckets). Keys use `RemoteAddr` after `TrustedRealIP` when `TRUSTED_PROXIES` is set. |
| Login email lockout | In-process: **5** failed password attempts per normalized email → **15 min** lock (**429** + `Retry-After`). Clears on success. Complements IP RPM (rotating IPs still hit the email budget). Captcha deferred. |
| Heavy sync/process/export rate limit | HTTP `RateLimitV1Process` (session `/api/*` + API-key `/api/v1`): **30 POSTs / minute / company** on feed sync, extract-schema, sync-process-sample, process starts (`/api/v1/process`, `/api/v1/products/process`, `/api/processing/jobs`), process retries, and export-feeds `generate` / `export-products`. **Counts requests, not products** in a bulk body (product caps: `maxJobProducts` + billing). Woo/Shopify connector syncs are **not** in this bucket. Service: `StartLimiter` **20 starts/retries / minute / company** (second gate for process jobs). All in-process — see remaining risks. |
| Marketing rate limits | HTTP `RateLimitMarketing` on session `/api/*`: **10/min** generate / SEO apply; **30/min** send/schedule. Service-layer limits mirror. In-process only. |
| Campaign prompts | Cap **8000 runes**; soft-filter injection via `campaigns.SanitizePrompt` / `security.SanitizePrompt`. |
| Email HTML | `security.SanitizeEmailHTML` strips script/style/iframe/handlers/`javascript:`/`data:`. Max **500000** runes. |
| Brand kit | Voice fields sanitized + length-capped; `logo_url` via `security.ValidatePublicHTTPSURL`. |
| Secret encryption | Woo + email + AI provider secrets at rest with AES-GCM (`enc:v1:`). Prefer **`APP_ENCRYPTION_KEY`**. Never log API keys, SMTP passwords, Stripe secrets, or Woo/Shopify tokens. API responses return `has_*` / masked last4 only — never plaintext secrets. |
| CLI secrets vs UI | Demo passwords / API keys / `go run` / seed commands live in `docs/*` and CLI stdout only — not in Svelte customer UI. |
| SMTP / webhook SSRF | `security.AssertDialableSMTPHost` blocks private/link-local/metadata (loopback OK for Mailhog). |
| EPREL HTTP | Timeout, **2 MiB** cap, ID validation; private literal `EPREL_BASE_URL` rejected when enabled. |
| OpenAI / Pinecone | Client timeouts; response bodies limited to **1 MiB**. |
| Stripe webhooks | `POST /api/webhooks/stripe` unauthenticated; **always** verifies `Stripe-Signature` when webhook secret is set (env or admin settings; including if `STRIPE_MOCK=true`). Unsigned only with `STRIPE_MOCK=true` **and** empty webhook secret (local). Body capped **1 MiB**. Idempotent via `stripe_webhook_events`. |
| Store webhooks (Shopify/Woo) | **Not mounted.** No `/api/webhooks/shopify` or `/api/webhooks/woocommerce` receive path. Sync is poll/queue only. Do **not** add CSRF-exempt stub/HMAC-placeholder routes under `/api/webhooks/*` — see `docs/store-connectors.md`. |
| Stripe Checkout / Portal | Session routes; `company_id` from session. Live Checkout uses fixed `https://api.stripe.com`. Mock plan grants require explicit `STRIPE_MOCK=true`. |
| Public export feeds | `GET /api/public/export-feeds/{token}.xml|.csv` — wrong extension on a valid token returns opaque **404** (same body as unknown token); closes token-existence oracle. Tokens case-normalized; list/create responses advertise only the matching format URL. **BREAKING** vs prior **400** format mismatch. |
| Billing admin | Platform-admin only for plan/credit mutations. |
## Shared helpers
| Package | Use for |
|---------|---------|
| `internal/security` | Prompt/HTML sanitize, public URL / SMTP / Shopify shop SSRF checks, `SafeHTTPClient` dial guards |
| `internal/email` crypto | `DeriveKey` / `EncryptSecret` / `DecryptSecret` |
| `internal/woocommerce` crypto | Same AES-GCM scheme for Woo consumer secrets |
| `internal/aiprovider` crypto | BYOK OpenAI-compatible keys |
## Remaining risks / gaps
1. **DNS rebinding / TOCTOU residual** — Dial checks the IP at connect time; multi-A races remain. Prefer egress allowlists at the network layer.
2. **`FEED_URL_PRIVATE_ALLOWLIST` is powerful** — Misconfiguration (broad RFC1918) re-enables SSRF. Default empty.
3. **In-process rate limits do not cluster** — HTTP + login email lockout + `StartLimiter` + AI auto-reply + email send limits are per-process memory. Without edge caps, effective limit ≈ `N × replicas`. Use edge/API-gateway limits when running multiple API replicas; do not treat app RPM as a global quota.
- **Optional `RATE_LIMIT_REPLICAS=N`:** divides **HTTP middleware only** (`httpapi/ratelimit.go` / `rateLimitEffectiveCap`, ceil) under even load — not lockout / StartLimiter / AIRateLimiter / email, and not a shared store. Still not a substitute for edge hard caps.
- **ASSUMPTION (Product 10):** Single API instance is an accepted product posture. Shared Redis/edge caps remain **ops cutover**, not a Product 10 code blocker.
- **Edge config:** [deploy/examples/edge-rate-limit.md](../deploy/examples/edge-rate-limit.md) (nginx/Caddy + `RATE_LIMIT_REPLICAS` note).
4. **Chi `Timeout(60s)` vs long sync** — Prefer enqueue-and-worker under load.
5. **Full feed still buffered in memory** — Cap 50 MiB; streaming parse is future work.
6. **EPREL hostname DNS** — Operator-set URL; keep the default EU host in prod.
7. **Public export / unsubscribe tokens** — Unauthenticated by design; entropy + rotation are the auth boundary. Format mismatch is opaque 404 (Aug-8); clients must not rely on 400 to probe tokens.
8. **No WAF / egress proxy** — Pair app controls with VPC egress denying link-local/metadata.
9. **Credit race** — Soft oversell under concurrent jobs unless stricter ledger locking is required.
10. **FTP/FTPS feeds** — Explicitly rejected today.
11. **Woo order/review PII** — Emails stored plaintext; consider encrypt-at-rest before heavy marketing use.
12. **Stripe mock misconfig**`STRIPE_MOCK=true` without webhook secret accepts unsigned payloads (local only). Blocked by `APP_ENV=production` validation.
13. **Demo seed credentials** — Local-only; rotate/disable before shared staging with real data.
14. **Native Shopify connector** — Domain SSRF + encrypted tokens + SafeHTTPClient wired; worker calls `SyncCompany` for product push (orders sync works). Native OAuth still outstanding.
## Ops knobs
```text
APP_ENV=production
SESSION_SECURE=true
WEB_ORIGIN=https://app.example.com
APP_ENCRYPTION_KEY=<32-byte hex or passphrase>
CREDENTIALS_ENCRYPTION_KEY=<legacy alias; APP_ENCRYPTION_KEY preferred>
TOKEN_SIGNING_SECRET=<random>
STRIPE_SECRET_KEY=sk_live_or_test_ # optional env fallback; prefer /admin/settings
STRIPE_WEBHOOK_SECRET=whsec_ # optional env fallback; prefer /admin/settings
STRIPE_MOCK=false
EMAIL_DRY_RUN=false # default dry-run is safer until SMTP proven
FEED_URL_PRIVATE_ALLOWLIST=
TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12 # CDN/LB peers only — see below
RATE_LIMIT_REPLICAS=1 # optional; divides HTTP middleware caps only when N>1; edge still required for hard global RPM
RATE_LIMIT_MULTI_REPLICA=false # set true when N>1 without shared store (boot warns)
# RATE_LIMIT_BACKEND=memory # redis|postgres accepted as docs-only; forced to memory
```
Multi-replica edge snippets (shared nginx/Caddy zones): [deploy/examples/edge-rate-limit.md](../deploy/examples/edge-rate-limit.md).
### `TRUSTED_PROXIES`
Comma-separated CIDRs or single IPs of **immediate reverse-proxy / load-balancer peers**. When set, `TrustedRealIP` rewrites `RemoteAddr` from `X-Forwarded-For` / `X-Real-IP` **only** if the connecting peer is on the allowlist. Empty (default) ignores client IP headers — rate limits and lockout keys stay on the TCP peer (safe behind no proxy; wrong behind a proxy without this set). Never list end-user networks; only hop-1 proxies you control.
Never log `EPREL_API_KEY`, OpenAI, Pinecone, Resend, SMTP, Stripe secret/webhook keys, WooCommerce secrets, or Shopify Admin tokens.
See [production-readiness.md](production-readiness.md) for Aug-8 boot vs runtime honesty (Stripe, dry-run mail, export 404).