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
|
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))
|
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
|
// #49 decision: raw reads count against the read budget too, with the
|
||||||
// same per-viewer 15-minute dedupe window as page views.
|
// same per-viewer 15-minute dedupe window as page views.
|
||||||
a.store.registerRead(row, currentViewerID(r))
|
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)
|
a.store.IncrementViews(row.ID)
|
||||||
w.Write([]byte(row.Content))
|
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) {
|
func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
can, err := a.store.GetCan(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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
cryptorand "crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -58,6 +64,29 @@ func renderPage(w http.ResponseWriter, name string, data any) {
|
|||||||
|
|
||||||
var webUIInstance *webUI
|
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) {
|
func (a *apiServer) handleNewPage(w http.ResponseWriter, r *http.Request) {
|
||||||
renderPage(w, "new.html", map[string]any{"Page": "new"})
|
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()
|
r.ParseForm()
|
||||||
pw := r.FormValue("password")
|
pw := r.FormValue("password")
|
||||||
if pw != "" && checkPassword(row.PasswordHash.String, pw) {
|
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{
|
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,
|
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
// re-render without lock, or redirect if ?next= was given (#26)
|
// 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})
|
renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// check cookie
|
// check cookie — must carry the valid per-paste unlock token (#34)
|
||||||
c, err := r.Cookie("pw_" + row.ID)
|
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})
|
renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": false, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user