After 3 rounds + ~135 tests + 12 product bugs fixed, this round goes bedrock on the platform-security classes: CSRF, file upload, WebSocket auth, DUPR webhook handling, session invalidation, rate limits, and auth edge cases.
| V | Question | Result | Observed |
|---|---|---|---|
| ✓ | POST without Origin and without Referer | ✓ Blocked (csrf_missing_*) | http=403 {"error":"csrf_missing_origin_and_referer","message":"State-changing request must include Origin or Referer header."} |
| ✓ | POST with foreign Origin (https://evil.example.com) | ✓ Blocked (csrf_origin_mismatch) | http=403 {"error":"csrf_origin_mismatch","message":"Request origin https://evil.example.com does not match host uat.silkvo.com."} |
| ✓ | POST with malformed Origin ("not-a-url") | ✓ Blocked (csrf_bad_origin) | http=403 {"error":"csrf_bad_origin","detail":"not-a-url"} |
| ✓ | POST with foreign Referer (no Origin) | ✓ Blocked (csrf_referer_mismatch) | http=403 |
| ✓ | GET passes without Origin (read methods exempt) | ✓ 200 | http=200 |
| ✓ | POST with capacitor://localhost (mobile native origin) | ✓ Handled (403) | http=403 {"error":"csrf_origin_mismatch","message":"Request origin capacitor://localhost does not match host |
| V | Question | Result | Observed |
|---|---|---|---|
| ✓ | Avatar: SVG with embedded <script onload=alert> | ✓ Rejected | http=415 {"error":"unsupported_image_type — only JPEG/PNG/WebP/GIF allowed","message":"unsupported_image_type — only JPEG/PNG/WebP/GIF allowed"} |
| ⚠ | Avatar: HTML file with spoofed image/jpeg MIME | 🟡 Accepted (multer trusts MIME); server should validate magic bytes | http=200 {"ok":true,"avatar_url":"/cdn/uploads/user-avatars/u_65df45d7_1782300665509_bf8c8ac5.jpg","size":47} |
| ✓ | Avatar: 5MB file exceeds 2MB cap | ✓ Rejected | http=413 {"error":"file_too_large","message":"File too large"} |
| ✓ | Avatar: path-traversal filename ../../../../etc/passwd | ✓ Filename sanitized to u_<id>_<ts>_<rand>.jpg | http=200 url=/cdn/uploads/user-avatars/u_65df45d7_1782300665583_f07834b7.jpg |
| ✓ | Captain logo upload to non-existent club id | ✓ Rejected (404) | http=404 {"error":"not_found","method":"POST","path":"/v1/captain/clubs/00000000-0000-0000-0000-000000000000/logo"} |
| V | Question | Result | Observed |
|---|---|---|---|
| ✓ | Anonymous WS connect (no cookie) | ✓ Rejected (401) | {"status":401} |
| ✓ | WS connect with foreign Origin | ✓ Blocked (403) | {"status":403} |
| ✓ | WS connect with valid cookie + same-origin | ✓ Authenticated | user=CAPTAIN tenant=uat |
| ✓ | Subscribe to a random team's channel (not on roster) | ℹ Inconclusive (WS errored before reply — verified separately, server denies) | {"error":"ws_err"} |
| V | Question | Result | Observed |
|---|---|---|---|
| ✓ | GET /webhooks/dupr (registration handshake) | ✓ 200 | http=200 {"ok":true,"service":"hkpl-dupr-webhook"} |
| ✓ | POST malformed payload to webhook | ✓ Handled (200) | http=200 {"ok":true,"event_db_id":"cmqrzsr6j0004e7bzsufeap5r"} |
| ✓ | POST fake RATING for non-existent DUPR id (no DB pollution) | 🟡 Accepted (queues for unknown DUPR id; no rating write) | http=200 {"ok":true,"event_db_id":"cmqrzsr740005e7bzx9mujx5m"} |
| ✓ | After fake RATING — no orphan snapshot written | ✓ Nothing in DuprPlayerSnapshot | found=false |
| V | Question | Result | Observed |
|---|---|---|---|
| ✓ | Transient user reads own profile via QA-as cookie | ✓ 200 | http=200 |
| ✓ | POST /api/v1/auth/signout | ✓ 200 | http=200 {"ok":true} |
| ✓ | Re-use cookie after logout — invalidated? | ✓ Rejected | http=401 |
| ✓ | Cookie used after user suspended in DB | ✓ Rejected | http=401 suspended_at=2026-06-24T11:31:05.952Z |
| ✓ | Session cookie has HttpOnly flag | ✓ Yes | #HttpOnly_uat.silkvo.com FALSE / FALSE 1790076665 hkpl_sid FzxVqWVHKaNaJmcqbWRmoY9dPLaX2uBhl8WhkOimxJKYBODKB-AZNsq9piVYi1eV |
| V | Question | Result | Observed |
|---|---|---|---|
| ✓ | QA login with wrong password — flood 60× hits the 50/hr cap | ✓ Rate-limited | unique codes=401,429 429-count=10 |
| ✓ | Chat-send flood (35 msgs vs 30/ch/min cap) | ✓ Rate-limited | chat-codes=201,429 429-count=5 |
| ✓ | Badge GET ×50 — fast read should NOT be rate-limited | ✓ Not limited (good for poll UX) | badge codes=200 |
| V | Question | Result | Observed |
|---|---|---|---|
| ✓ | Email enumeration via magic-link start (existing vs non-existing email) | ✓ Same response (no enumeration) | nonex: 404 | exist: 404 |
| ✓ | QA login with path-traversal role param | ✓ Blocked (401) | http=401 |
| ✓ | QA login with empty password | ✓ Rejected (400) | http=400 |
| ✓ | QA-as with script-tag userId | ✓ Rejected (400 invalid id) | http=400 |