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

11 KiB
Raw Permalink Blame History

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
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 (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 misconfigSTRIPE_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

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).