diff --git a/cans.go b/cans.go index 47ae8bb..fb622c6 100644 --- a/cans.go +++ b/cans.go @@ -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)) } diff --git a/main.go b/main.go index aa670e3..74c486a 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/pentest_cookie_test.go b/pentest_cookie_test.go new file mode 100644 index 0000000..c2183e0 --- /dev/null +++ b/pentest_cookie_test.go @@ -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_=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_=1 cookie bypassed password protection") + } + if rec.Code != 200 { + t.Logf("forged-cookie request returned %d (page still locked) — good", rec.Code) + } +} diff --git a/pentest_helpers_test.go b/pentest_helpers_test.go new file mode 100644 index 0000000..f472474 --- /dev/null +++ b/pentest_helpers_test.go @@ -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 +} diff --git a/pentest_rawct_test.go b/pentest_rawct_test.go new file mode 100644 index 0000000..2ab7b12 --- /dev/null +++ b/pentest_rawct_test.go @@ -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":"","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) + } + } +} diff --git a/web.go b/web.go index 519ec1e..aefeadb 100644 --- a/web.go +++ b/web.go @@ -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_ 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_=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 }