Security pentest sweep #34

Closed
opened 2026-09-09 01:50:46 +00:00 by poslop · 2 comments
Owner

Adversarial security review of the running app and codebase. Attack surfaces: API input validation (oversized payloads, malformed JSON, type confusion), SQL injection (all queries, esp. search/sort params), XSS (paste content rendering, title, custom slug reflected anywhere, stored vs reflected), path traversal (static handler, raw endpoints), auth bypass (password cookie forging, deletion token guessing/brute force, burn-after-read races), CSRF on state-changing endpoints, rate limiting absence (brute force password pastes), header handling (CSP, content-type sniffing on /raw), information disclosure (error messages, deletion tokens in URLs/logs, timing attacks on password/token compares). Document findings with severity, fix critical ones in-sweep, file the rest.

Adversarial security review of the running app and codebase. Attack surfaces: API input validation (oversized payloads, malformed JSON, type confusion), SQL injection (all queries, esp. search/sort params), XSS (paste content rendering, title, custom slug reflected anywhere, stored vs reflected), path traversal (static handler, raw endpoints), auth bypass (password cookie forging, deletion token guessing/brute force, burn-after-read races), CSRF on state-changing endpoints, rate limiting absence (brute force password pastes), header handling (CSP, content-type sniffing on /raw), information disclosure (error messages, deletion tokens in URLs/logs, timing attacks on password/token compares). Document findings with severity, fix critical ones in-sweep, file the rest.
Author
Owner

Security pentest sweep complete. Findings (live-verified against localhost:8080):

CRITICAL — Auth bypass: forgeable password unlock cookie — FIXED (cb23707)
The pw_<id> cookie was a static value 1 checked with c.Value != "1". Anyone could read ANY password-protected paste by sending Cookie: pw_<paste-id>=1. Repro: create password paste → curl -H 'Cookie: pw_<id>=1' /<id> → full content rendered. Fix: cookie value is now an HMAC-SHA256 of the paste id under a per-instance random secret (PALETTE_UNLOCK_SECRET env override for multi-instance deploys) — unlockToken() in web.go. Tests: pentest_cookie_test.go.

CRITICAL — Stored XSS: attacker-controlled content_type on /raw — FIXED (cb23707)
content_type in the create API was served verbatim from /raw/{id}. A paste stored with text/html (verified live) or image/svg+xml renders as active script on this origin when anyone opens the raw URL. Repro: POST /api/pastes {"content":"<script>alert(1)</script>","content_type":"text/html"}GET /raw/<id> served with Content-Type: text/html. Fix: fixed allowlist (safeRawContentType) — text/plain, markdown, json, pdf, raster images, octet-stream; everything else downgraded to text/plain; charset=utf-8 with X-Content-Type-Options: nosniff. Same guard applied to /api/cans/{id}/items/{item}. Tests: pentest_rawct_test.go.

MED — Burn-after-read race window — OPEN (accepted for single-user beta)
Concurrent reads of a burn_after_read: true paste can all succeed before any SoftDelete lands (verified: 4/6 concurrent API readers received content). The check-then-delete in registerRead is not atomic. Practical impact low (paste is deleted right after; concurrent fetchers within the same instant may each get one copy). Proper fix = atomic conditional UPDATE pastes SET deleted_at=? WHERE id=? AND deleted_at IS NULL checked via RowsAffected before returning content, or a reads_used conditional UPDATE guard.

MED — No security headers on HTML pages — OPEN
No CSP, X-Frame-Options, or Referrer-Policy on any rendered page. Rendering is safely escaped (verified: pasted HTML/script content is inert on the paste page, titles/slug/history all escaped), so CSP would be defense-in-depth, not a fix for an active bug. Suggest a middleware: default-src 'self'; script-src 'self' 'unsafe-inline' (page scripts are inline) + frame-ancestors 'none' + Referrer-Policy: no-referrer.

LOW — CORS: no /api responses declare Access-Control-Allow-Origin — OPEN (safe by default)
Simple cross-origin GETs are still readable via no-cors fetches only for opaque responses; JSON API responses are not readable cross-origin since ACAO is absent. DELETEs from other origins would need preflight (no ACAO → blocked). No action required; document that any future CORS headers must be origin-allowlisted.

LOW — expires_in accepts negative/zero durations — OPEN
-1h/0s create already-expired pastes (deleted by sweeper within a minute). No DoS or data exposure; cosmetic. Also accepted 87600h (10y) — a cap (e.g. 1y, as the UI enforces) would be nice server-side.

Verified NOT exploitable:

  • SQL injection: all queries parameterized; limit/offset coerced via Atoi to bounded ints; fuzzed inputs inert.
  • Highlighter XSS: highlight.go escapes all non-token text and only emits fixed class attributes; pasted HTML/script inert in highlighted and plain modes.
  • Template XSS: html/template everywhere; titles, custom slugs (strict regex, no unicode), unlock page ID all escaped.
  • Deletion tokens: 24 crypto-random bytes (~190 bits), constant-time compare, brute force infeasible; not logged.
  • Password hashing: argon2id m=64MB t=1 p=4, constant-time compare; unlock endpoint rate-limited (5/min/IP/paste) so online brute force impractical.
  • /raw on password pastes: 401 before content, correct.
  • /mine + viewer-cookie deletes: delete requires matching vwr cookie when the paste has one; spoofing another viewer's vwr is a bearer-cookie model by design (accepted risk, cookie is HttpOnly).
  • limit/offset: clamped (≤100 / default 25); negative offsets harmless (SQLite clamps).
  • Duplicate custom_slug race: UNIQUE constraint backstops the check; no duplicate rows possible (verified across concurrent bursts).
  • Path traversal on /static and /raw: chi + http.FileServer / exact-id lookups, no filesystem traversal.
  • Error messages: generic ('db error', 'not found'); no token/stack disclosure.

Build green (go build, go test ./... ok), deployed via systemctl --user restart palette, fixes committed/pushed as cb23707. Test pastes created during the sweep were deleted.

Security pentest sweep complete. Findings (live-verified against localhost:8080): **CRITICAL — Auth bypass: forgeable password unlock cookie — FIXED (cb23707)** The `pw_<id>` cookie was a static value `1` checked with `c.Value != "1"`. Anyone could read ANY password-protected paste by sending `Cookie: pw_<paste-id>=1`. Repro: create password paste → `curl -H 'Cookie: pw_<id>=1' /<id>` → full content rendered. Fix: cookie value is now an HMAC-SHA256 of the paste id under a per-instance random secret (`PALETTE_UNLOCK_SECRET` env override for multi-instance deploys) — `unlockToken()` in web.go. Tests: pentest_cookie_test.go. **CRITICAL — Stored XSS: attacker-controlled content_type on /raw — FIXED (cb23707)** `content_type` in the create API was served verbatim from `/raw/{id}`. A paste stored with `text/html` (verified live) or `image/svg+xml` renders as active script on this origin when anyone opens the raw URL. Repro: `POST /api/pastes {"content":"<script>alert(1)</script>","content_type":"text/html"}` → `GET /raw/<id>` served with `Content-Type: text/html`. Fix: fixed allowlist (`safeRawContentType`) — text/plain, markdown, json, pdf, raster images, octet-stream; everything else downgraded to `text/plain; charset=utf-8` with `X-Content-Type-Options: nosniff`. Same guard applied to `/api/cans/{id}/items/{item}`. Tests: pentest_rawct_test.go. **MED — Burn-after-read race window — OPEN (accepted for single-user beta)** Concurrent reads of a `burn_after_read: true` paste can all succeed before any SoftDelete lands (verified: 4/6 concurrent API readers received content). The check-then-delete in `registerRead` is not atomic. Practical impact low (paste is deleted right after; concurrent fetchers within the same instant may each get one copy). Proper fix = atomic conditional `UPDATE pastes SET deleted_at=? WHERE id=? AND deleted_at IS NULL` checked via RowsAffected before returning content, or a reads_used conditional UPDATE guard. **MED — No security headers on HTML pages — OPEN** No CSP, X-Frame-Options, or Referrer-Policy on any rendered page. Rendering is safely escaped (verified: pasted HTML/script content is inert on the paste page, titles/slug/history all escaped), so CSP would be defense-in-depth, not a fix for an active bug. Suggest a middleware: `default-src 'self'; script-src 'self' 'unsafe-inline'` (page scripts are inline) + `frame-ancestors 'none'` + `Referrer-Policy: no-referrer`. **LOW — CORS: no /api responses declare Access-Control-Allow-Origin — OPEN (safe by default)** Simple cross-origin GETs are still readable via no-cors fetches only for opaque responses; JSON API responses are not readable cross-origin since ACAO is absent. DELETEs from other origins would need preflight (no ACAO → blocked). No action required; document that any future CORS headers must be origin-allowlisted. **LOW — expires_in accepts negative/zero durations — OPEN** `-1h`/`0s` create already-expired pastes (deleted by sweeper within a minute). No DoS or data exposure; cosmetic. Also accepted `87600h` (10y) — a cap (e.g. 1y, as the UI enforces) would be nice server-side. **Verified NOT exploitable:** - SQL injection: all queries parameterized; limit/offset coerced via Atoi to bounded ints; fuzzed inputs inert. - Highlighter XSS: highlight.go escapes all non-token text and only emits fixed class attributes; pasted HTML/script inert in highlighted and plain modes. - Template XSS: html/template everywhere; titles, custom slugs (strict regex, no unicode), unlock page ID all escaped. - Deletion tokens: 24 crypto-random bytes (~190 bits), constant-time compare, brute force infeasible; not logged. - Password hashing: argon2id m=64MB t=1 p=4, constant-time compare; unlock endpoint rate-limited (5/min/IP/paste) so online brute force impractical. - /raw on password pastes: 401 before content, correct. - /mine + viewer-cookie deletes: delete requires matching vwr cookie when the paste has one; spoofing another viewer's vwr is a bearer-cookie model by design (accepted risk, cookie is HttpOnly). - limit/offset: clamped (≤100 / default 25); negative offsets harmless (SQLite clamps). - Duplicate custom_slug race: UNIQUE constraint backstops the check; no duplicate rows possible (verified across concurrent bursts). - Path traversal on /static and /raw: chi + http.FileServer / exact-id lookups, no filesystem traversal. - Error messages: generic ('db error', 'not found'); no token/stack disclosure. Build green (`go build`, `go test ./...` ok), deployed via `systemctl --user restart palette`, fixes committed/pushed as cb23707. Test pastes created during the sweep were deleted.
poslop added spent time 14 seconds 2026-09-09 05:15:37 +00:00
Author
Owner

Findings split into followup issues: #58 (burn race, MED), #59 (security headers, MED), #60 (expires_in clamp, LOW). Both CRITICALs fixed in cb23707 and deployed in v0.2.0. Non-exploitable items verified and documented in the report above. Closing as complete.

Findings split into followup issues: #58 (burn race, MED), #59 (security headers, MED), #60 (expires_in clamp, LOW). Both CRITICALs fixed in cb23707 and deployed in v0.2.0. Non-exploitable items verified and documented in the report above. Closing as complete.
Sign in to join this conversation.
1 Participants
Notifications
Total Time Spent: 14 seconds
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: poslop/palette#34