pentest: bind unlock cookie to HMAC per-paste token; serve only safe content types on /raw and can items with nosniff (#34)
CI / test (push) Successful in 21s
CI / docker (push) Skipped

This commit is contained in:
2026-09-09 00:14:01 -05:00
parent 15ce7ff011
commit cb23707125
6 changed files with 195 additions and 5 deletions
+36 -3
View File
@@ -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
}