Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
98 lines
11 KiB
Markdown
98 lines
11 KiB
Markdown
# 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 **500 000** 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).
|