pentest: bind unlock cookie to HMAC per-paste token; serve only safe content types on /raw and can items with nosniff (#34)
This commit is contained in:
@@ -258,6 +258,12 @@ func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", row.ContentType)
|
||||
// #34: same content-type guard as /raw — never serve active content types.
|
||||
ct := row.ContentType
|
||||
if !safeRawContentType(ct) {
|
||||
ct = "text/plain; charset=utf-8"
|
||||
}
|
||||
w.Header().Set("Content-Type", ct)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Write([]byte(row.Content))
|
||||
}
|
||||
|
||||
@@ -668,11 +668,39 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
|
||||
// #49 decision: raw reads count against the read budget too, with the
|
||||
// same per-viewer 15-minute dedupe window as page views.
|
||||
a.store.registerRead(row, currentViewerID(r))
|
||||
w.Header().Set("Content-Type", row.ContentType)
|
||||
// #34: content_type is attacker-controlled via the create API. Serving it
|
||||
// verbatim let a paste be stored with text/html (or image/svg+xml) and
|
||||
// render as active content on this origin when fetched from /raw —
|
||||
// stored XSS. Only pass through a fixed safe set; anything else is
|
||||
// served as plain text with nosniff.
|
||||
ct := row.ContentType
|
||||
if !safeRawContentType(ct) {
|
||||
ct = "text/plain; charset=utf-8"
|
||||
}
|
||||
w.Header().Set("Content-Type", ct)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
a.store.IncrementViews(row.ID)
|
||||
w.Write([]byte(row.Content))
|
||||
}
|
||||
|
||||
// safeRawContentType reports whether ct is in the fixed set of types that are
|
||||
// safe to serve verbatim on /raw (no active-content execution contexts).
|
||||
func safeRawContentType(ct string) bool {
|
||||
base := ct
|
||||
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
||||
base = ct[:i]
|
||||
}
|
||||
base = strings.ToLower(strings.TrimSpace(base))
|
||||
switch base {
|
||||
case "text/plain", "text/markdown", "text/x-markdown",
|
||||
"application/json", "application/pdf",
|
||||
"image/png", "image/jpeg", "image/gif", "image/webp",
|
||||
"application/octet-stream":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
can, err := a.store.GetCan(id)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// #34: the unlock cookie must be bound to the paste it unlocks, not a
|
||||
// forgeable static value. A forged 'pw_<id>=1' cookie must not bypass the
|
||||
// password check on the paste page.
|
||||
func TestForgedUnlockCookieDoesNotBypassPassword(t *testing.T) {
|
||||
globalLimiter = newLimiter()
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"SECRETPASTECONTENT","password":"hunter2"}`))
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create: got %d", rec.Code)
|
||||
}
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
id := created.ID
|
||||
|
||||
// request the page with a forged unlock cookie in the old format
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("GET", "/"+id, nil)
|
||||
req.AddCookie(&http.Cookie{Name: "pw_" + id, Value: "1"})
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == 200 && strings.Contains(rec.Body.String(), "SECRETPASTECONTENT") {
|
||||
t.Fatal("forged pw_<id>=1 cookie bypassed password protection")
|
||||
}
|
||||
if rec.Code != 200 {
|
||||
t.Logf("forged-cookie request returned %d (page still locked) — good", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// test helpers for pentest tests (#34)
|
||||
func jsonField(tb testing.TB, body, field string) string {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(body), &m); err != nil {
|
||||
tb.Fatalf("bad json: %v", err)
|
||||
}
|
||||
v, _ := m[field].(string)
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// #34: attacker-controlled content_type must not let a paste be served as
|
||||
// HTML/SVG/XML from /raw (stored XSS). Only a fixed safe set passes through.
|
||||
func TestRawRejectsHTMLContentType(t *testing.T) {
|
||||
globalLimiter = newLimiter() // fresh rate-limit buckets
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
for _, ct := range []string{
|
||||
"text/html", "TEXT/HTML", "text/html;charset=utf-8", "text/html;x=1",
|
||||
"application/xhtml+xml", "image/svg+xml", "text/html,",
|
||||
} {
|
||||
globalLimiter = newLimiter() // burst 5, loop makes 7 creates
|
||||
body := `{"content":"<script>alert(1)</script>","content_type":"` + ct + `"}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("ct %q: create got %d: %s", ct, rec.Code, rec.Body.String())
|
||||
}
|
||||
id := jsonField(t, rec.Body.String(), "id")
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("GET", "/raw/"+id, nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
if got := rec.Header().Get("Content-Type"); got == ct {
|
||||
t.Errorf("ct %q was served verbatim from /raw (stored XSS vector)", ct)
|
||||
}
|
||||
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Errorf("ct %q: /raw missing X-Content-Type-Options: nosniff", ct)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawAllowsSafeContentType(t *testing.T) {
|
||||
globalLimiter = newLimiter()
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
for _, ct := range []string{"text/plain", "image/png", "application/pdf", "application/octet-stream"} {
|
||||
globalLimiter = newLimiter()
|
||||
body := `{"content":"hi","content_type":"` + ct + `"}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("ct %q: create got %d", ct, rec.Code)
|
||||
}
|
||||
id := jsonField(t, rec.Body.String(), "id")
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest("GET", "/raw/"+id, nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
if got := rec.Header().Get("Content-Type"); got != ct {
|
||||
t.Errorf("ct %q: got %q", ct, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
cryptorand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -58,6 +64,29 @@ func renderPage(w http.ResponseWriter, name string, data any) {
|
||||
|
||||
var webUIInstance *webUI
|
||||
|
||||
// #34: per-paste unlock tokens. unlockSecret is generated once at startup
|
||||
// (also derivable from PALETTE_UNLOCK_SECRET for multi-instance deploys) and
|
||||
// used to HMAC paste ids, so a client can only hold a valid pw_<id> cookie by
|
||||
// actually submitting the correct password for that paste.
|
||||
var unlockSecret = resolveUnlockSecret()
|
||||
|
||||
func resolveUnlockSecret() []byte {
|
||||
if v := os.Getenv("PALETTE_UNLOCK_SECRET"); v != "" {
|
||||
return []byte(v)
|
||||
}
|
||||
b := make([]byte, 32)
|
||||
if _, err := cryptorand.Read(b); err != nil {
|
||||
log.Fatal("cannot generate unlock secret: ", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func unlockToken(id string) string {
|
||||
mac := hmac.New(sha256.New, unlockSecret)
|
||||
mac.Write([]byte("unlock:" + id))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (a *apiServer) handleNewPage(w http.ResponseWriter, r *http.Request) {
|
||||
renderPage(w, "new.html", map[string]any{"Page": "new"})
|
||||
}
|
||||
@@ -172,8 +201,12 @@ func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
pw := r.FormValue("password")
|
||||
if pw != "" && checkPassword(row.PasswordHash.String, pw) {
|
||||
// #34: the unlock cookie must be bound to this specific paste and
|
||||
// unforgable. A static value ("1") let anyone bypass the password
|
||||
// by setting pw_<id>=1 for any paste id. The token is an HMAC of
|
||||
// the paste id under the server's random secret.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "pw_" + row.ID, Value: "1", Path: "/",
|
||||
Name: "pw_" + row.ID, Value: unlockToken(row.ID), Path: "/",
|
||||
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
// re-render without lock, or redirect if ?next= was given (#26)
|
||||
@@ -190,9 +223,9 @@ func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
|
||||
renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
|
||||
return
|
||||
}
|
||||
// check cookie
|
||||
// check cookie — must carry the valid per-paste unlock token (#34)
|
||||
c, err := r.Cookie("pw_" + row.ID)
|
||||
if err != nil || c.Value != "1" {
|
||||
if err != nil || c.Value != unlockToken(row.ID) {
|
||||
renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": false, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user