docs/security/audit-2026-08.md
Rama master · réplica de lectura del repositorio original.
# Security Audit — 2026-08-20 (first-party, focused)
Scope: the attack surface added since the v0.10.0 generic audit —
interactive SSO (Auth0 OIDC, ADR-0062), the Stripe billing webhook loop
(ADR-0053), the public namespace, distributed rate limiting, and the
release-engineering gate. Third-party audit remains a budget-dependent
follow-up for the public launch.
## Summary
| # | Severity | Area | Finding | Status |
|---|----------|------|---------|--------|
| 1 | High | Dependencies | nanoid < 3.3.18 (GHSA-2v37-7h3g-55p8) via vitest→vite→postcss | Fixed (3.3.18, audits clean) |
| 2 | Low | SSO | `jwtVerify` without explicit algorithm pinning | Fixed (`algorithms: ['RS256']`) |
| 3 | Low | SSO | duplicated `parseCookies` threw URIError on malformed cookie → 500 | Fixed (shared safe parser) |
No critical findings. No real secrets in git history (only `sk_live_...` /
`whsec_123` placeholders in env examples and test fixtures).
## Findings
### 1. High — nanoid 3.3.17 (GHSA-2v37-7h3g-55p8)
`custom generators can loop indefinitely when size is zero`. Reachable only
through dev tooling (`vitest` → `vite` → `postcss`), not the production
runtime, but the root audit was no longer clean as claimed in the v0.10.0
roadmap. Fixed with `npm audit fix` → nanoid 3.3.18. Root and frontend both
report 0 vulnerabilities (`--audit-level=moderate`).
### 2. Low — algorithm pinning in Auth0Provider
`src/providers/auth/auth0-provider.ts` verified JWTs against the remote JWKS
without an explicit `algorithms` list. jose derives the allowed algorithms
from the key type, which already blocks symmetric-key confusion, but an
explicit `algorithms: ['RS256']` is cheap defense in depth and makes the
accepted signing family self-documenting. Fixed.
### 3. Low — cookie parser robustness (SSO + auth middleware)
Two identical `parseCookies` copies existed in
`src/api/routes/oidc.ts` and `src/api/middleware/auth.ts`. Both applied
`decodeURIComponent` unconditionally: a `Cookie: dominus_oidc=%` header
(thrown `URIError`) surfaced as a 500 on unauthenticated endpoints
(`/oidc/callback`, `/oidc/me`). Consolidated into one safe parser
(`src/utils/cookies.ts`) that skips malformed percent-encoding. No behavior
change for well-formed cookies.
## Reviewed — no change required
- **Login CSRF on `/oidc/start`** — no risk. The IdP authenticates the user
with their own credentials; the resulting session is bound to the
HMAC-signed state cookie and PKCE verifier, so an attacker cannot inject
or hijack a session. `/api/v1/auth/oidc/*` sits behind the per-IP auth
rate limiter (30 req/min, index.ts).
- **PKCE/state flow** — S256 challenge, server-side code+verifier exchange
with the client secret, state HMAC verified with `timingSafeEqual`,
10-minute transient-cookie TTL. Sound.
- **Session JWT** — HS256 pinned on verification, HKDF-derived key from
`AUTH0_CLIENT_SECRET` (rotation = global logout, documented), bounded TTL.
Logout does not invalidate the JWT server-side (stateless design,
documented deferral in ADR-0062); acceptable at this scale.
- **Stripe webhook** — raw body preserved (`express.raw` before
`express.json`), signature verified via `constructEvent`, DB-backed
idempotency claim fail-closed (duplicate transitions rejected), unknown
price IDs never overwrite the plan (`COALESCE` guard), `past_due` on
`invoice.payment_failed`, cancel on `subscription.deleted`. Solid.
- **Checkout metadata plan** — trusted from session metadata set server-side
at session creation; a misconfigured price ID could grant a plan the
customer did not pay for. Operator-config error only, not exploitable by
clients; noted for the custom-pricing work.
- **SSRF on the public namespace** — `/public/domain/:domain` validates via
`isValidDomain` before any provider call; RDAP/DNS/DoH/DoT/wayback fetch
fixed base URLs with the validated domain as path/query only. No
user-controlled URL reaches `fetch`.
- **Distributed rate limiting** — Redis fixed-window store with per-instance
MemoryStore fallback (degraded, never disabled); per-domain limiter on the
public router; `TRUST_PROXY_DEPTH` defaults to 0 (community-correct) and
is set to 1 in the cloud configmap behind Caddy. Correct as configured.
- **Security headers** — strict CSP (`script-src 'self'`), HSTS gated on
HTTPS, `frame-ancestors 'none'`, permissions policy; COEP deliberately
omitted (documented). Adequate.
- **RLS** — `ENABLE` (0030) + `FORCE` (0047) with `runWithTenant` binding;
PG-only paths tested. Adequate.
- **Git history secrets scan** — `sk_live_|AKIA|ghp_|whsec_|AIza|private
keys` pickaxe over all refs: 2 hits, both placeholders in
`terraform.tfvars.example` and test fixtures.
## Actions
- [x] `npm audit fix` (nanoid 3.3.18)
- [x] pin `algorithms: ['RS256']` in `Auth0Provider.validate`
- [x] consolidate + harden `parseCookies` in `src/utils/cookies.ts`
## Follow-up (out of scope)
- Third-party audit before the public launch (budget-dependent).
- Custom-pricing work must cross-check the paid price against the granted
plan instead of trusting checkout metadata alone.
- IdP-side logout and refresh-token rotation (ADR-0062 deferrals).