# 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= TOKEN_SIGNING_SECRET= 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).