Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
11 KiB
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 |
| 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
- DNS rebinding / TOCTOU residual — Dial checks the IP at connect time; multi-A races remain. Prefer egress allowlists at the network layer.
FEED_URL_PRIVATE_ALLOWLISTis powerful — Misconfiguration (broad RFC1918) re-enables SSRF. Default empty.- 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 (nginx/Caddy +
RATE_LIMIT_REPLICASnote).
- Optional
- Chi
Timeout(60s)vs long sync — Prefer enqueue-and-worker under load. - Full feed still buffered in memory — Cap 50 MiB; streaming parse is future work.
- EPREL hostname DNS — Operator-set URL; keep the default EU host in prod.
- 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.
- No WAF / egress proxy — Pair app controls with VPC egress denying link-local/metadata.
- Credit race — Soft oversell under concurrent jobs unless stricter ledger locking is required.
- FTP/FTPS feeds — Explicitly rejected today.
- Woo order/review PII — Emails stored plaintext; consider encrypt-at-rest before heavy marketing use.
- Stripe mock misconfig —
STRIPE_MOCK=truewithout webhook secret accepts unsigned payloads (local only). Blocked byAPP_ENV=productionvalidation. - Demo seed credentials — Local-only; rotate/disable before shared staging with real data.
- Native Shopify connector — Domain SSRF + encrypted tokens + SafeHTTPClient wired; worker calls
SyncCompanyfor product push (orders sync works). Native OAuth still outstanding.
Ops knobs
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.
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 for Aug-8 boot vs runtime honesty (Stripe, dry-run mail, export 404).