# Security pre-launch audit — v1.0.9 W5 Day 21 > **Status** : in progress. Re-run before each release candidate ; update the table below with new findings + their resolution commit. > **Scope** : automated scans (ZAP baseline, nuclei) + manual OWASP audit on the surface added in v1.0.9. > **Out of scope** : the external pentest (Day 25) which exercises business-logic abuse paths the scanners can't model. The acceptance gate before flipping a release is **0 finding HIGH** in the automated reports + every manual finding either fixed or explicitly accepted with a justification. ## Automated scans ### OWASP ZAP baseline ```bash TARGET=https://staging.veza.fr bash scripts/security/zap-baseline-scan.sh ``` Wrapper around `zap-baseline.py`. Produces an HTML report + JSON summary in `./security-reports/`. Exits non-zero when any finding is at or above the configured floor (default HIGH). FAIL_ON=MEDIUM tightens the gate when we want a clean report before an external review. What ZAP catches reliably : missing security headers, mixed-content warnings, basic XSS reflections, clickjacking-prone responses, cookies without `Secure`/`HttpOnly`, exposed `.git`/`.env`. What it misses : business-logic flaws, authenticated paths (no creds passed), TLS protocol-level issues. ### nuclei ```bash TARGET=https://staging.veza.fr bash scripts/security/nuclei-scan.sh ``` Runs the `cves`, `vulnerabilities`, `exposures` template families. JSONL output ; failure floor is `high` by default. What nuclei catches : known CVEs against framework versions visible from response headers, exposed admin panels, default credentials, leaked Git directories. Like ZAP it doesn't authenticate. ## Manual OWASP audit — v1.0.9 surface The new endpoints added during W2-W4 carry the highest residual risk because the automated scanners haven't seen them yet. Each row below is a deliberate inspection ; "resolution" is a code reference (commit SHA + file + line) when the finding required a fix, or a justification when we accept the surface as-is. ### `/api/v1/dmca/notice` (Day 14) | OWASP category | Finding | Resolution | | ---------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | | A03 Injection | `work_description` is free text up to 5000 chars. Could carry stored XSS if rendered raw. | **Mitigated.** Storage is parameterised GORM ; the admin queue rendering happens in React (auto-escaped). No backend HTML render. | | A05 Misconfig | Endpoint is public (no auth). DDoS via repeated submissions. | **Accepted.** Rate-limited by the global per-IP limiter (`internal/middleware/rate_limiter.go`). Roadmap §Day 14 set the budget at 5/IP/h. | | A08 Integrity | `sworn_statement` is a boolean we trust. Could be forged. | **Accepted.** The DMCA framework requires the claimant be verifiable ; we capture identity (name + address + email) and the sworn-statement timestamp goes into the audit_log. Falsehood is a § 512(f) issue, not a tech control. | | SSRF | `infringing_track_id` is a UUID we look up server-side. Not a URL, no SSRF surface. | **Not applicable.** | | CSRF | Endpoint is public + idempotent on submission (creates a row, no destructive read-after-write). Cookie-less requests work via Bearer or anonymous. | **Not applicable.** Public POST endpoints with no auth context don't need CSRF tokens — there's no session to forge against. | ### `/embed/track/:id` (Day 15) | OWASP category | Finding | Resolution | | ---------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | | A03 Injection (XSS) | Track title + artist are interpolated into the HTML body + OG meta tags. Stored XSS if escapes are missed. | **Fixed at design time.** `internal/handlers/embed_handler.go::renderEmbed` wraps every interpolation in `html.EscapeString`. Verified by inspection. | | Clickjacking | Page is iframable by design (`X-Frame-Options: ALLOWALL`, `CSP frame-ancestors *`). | **Accepted.** This is the embed widget's contract. The host page is responsible for not framing untrusted content of its own. | | DMCA bypass | Could the embed serve a track that's been DMCA-blocked ? | **Mitigated.** `fetchPublicTrack` returns 451 when `track.dmca_blocked = true` (Day 14 gate also covers the embed path). | | Private bypass | Could the embed leak existence of a private track via 404 vs 200 ? | **Accepted.** Private tracks return 404 (not 403) on the embed path so the response shape doesn't distinguish "doesn't exist" from "private" — the existence check is performed by the caller (track owner). | ### `/api/v1/config/webrtc` (Day 1, item 1.2) | OWASP category | Finding | Resolution | | ---------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | | A05 Misconfig | Endpoint exposes `iceServers` config (TURN URLs + temporary credentials). | **Accepted by design.** WebRTC's ICE protocol requires the client see the TURN credentials to negotiate. We rotate the TURN secret hourly via the coturn role + use short-lived credentials so a leaked one expires fast. The endpoint is intentionally public. | | A01 Auth | Should this require auth ? | **Accepted as-is.** Adding auth would force every page that might do a WebRTC call to fetch credentials post-login, doubling the latency on the call setup. The credentials themselves are short-lived so the exposure window is bounded. | ### `/api/v1/tracks/share/:token` and `/tracks/shared/:token` (pre-existing, audited Day 21) | OWASP category | Finding | Resolution | | ---------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | | A01 Enumeration | Pre-Day-21 : `ErrShareNotFound` returned 404 (or generic 403 in some paths) ; `ErrShareExpired` returned 403 with a different message. Status + message split let an attacker walk a list of past tokens and learn which ever existed. | **Fixed.** v1.0.9 W5 Day 21 unifies both error paths : single 403 with `"invalid or expired share token"` message. Test `TestTrackHandler_GetSharedTrack_InvalidToken` updated to assert 403 (was 404). Files : `internal/core/track/track_hls_handler.go`, `internal/core/track/track_social_handler.go`. | | Timing oracle | `ValidateShareToken` does a GORM `Where(share_token = ?).First(...)` which is B-tree indexed, so the latency difference between "found-then-expired" and "not found" is tiny but present. | **Accepted (low impact).** B-tree index lookup is O(log n) ; the timing delta below 1 ms is dwarfed by network jitter at the LB. Adding constant-time padding here would add complexity for a marginal gain ; the unification of error messages above is the meaningful gate. | | Token entropy | Tokens are 32-byte hex (`crypto/rand`) → 256 bits of entropy. Brute-force infeasible. | **No change needed.** | ## Findings to fix before launch | # | Severity | Endpoint | Status | | - | -------- | ----------------------------------------- | ------------- | | 1 | MED | Share-token enumeration via status split | ✅ Fixed Day 21 | | 2 | _TBD_ | Run automated ZAP scan on staging | ⏳ Pending real run | | 3 | _TBD_ | Run nuclei on staging | ⏳ Pending real run | When the automated runs land, append a row per finding with severity + the commit that fixed (or accepted) it. ## Next steps - Day 22 : game day with the failure scenarios from the runbooks (W2 Day 10). - Day 25 : external pentest kick-off. The internal audit above is the briefing handed to the external team so they can skip the gates we've already cleared.