6 Commits
Author SHA1 Message Date
poslop b0360af79c Merge pull request 'Fix #83: create API ignores boolean public field' (#88) from fix-83-public-false into main
CI / test (push) Successful in 26s
CI / docker (push) Skipped
2026-09-09 15:59:26 +00:00
poslop 806798fae6 Merge pull request 'Rate-limit all password verification attempts (#81)' (#90) from fix-81-password-ratelimit into main
CI / test (push) Successful in 26s
CI / docker (push) Skipped
2026-09-09 15:59:24 +00:00
poslop 8977c05cda Merge pull request 'fix #84: security headers were silently dropped (set pre-handler)' (#87) from fix-84-headers-prewrite into main
CI / test (push) Successful in 21s
CI / docker (push) Skipped
2026-09-09 15:59:21 +00:00
fen 99a044e8a2 Rate-limit all password verification attempts (#81)
CI / test (pull_request) Successful in 22s
CI / docker (pull_request) Skipped
GET ?password= and X-Paste-Password header checks in handleGetPaste were
unlimited (pentest2: 8 wrong = 8x401, no 429). Gate every password
verification on the same per-IP+paste unlock limiter (5/min, 429 with
Retry-After) already used by the POST unlock form path.

Regression tests: repeated wrong passwords via GET ?password= and via
header must eventually 429; correct password within burst still 200.

Fixes #81
2026-09-09 10:58:21 -05:00
Hermes Agent ccb22a2d28 #83: accept boolean public field in create API (false->unlisted, true->public)
CI / test (pull_request) Successful in 23s
CI / docker (pull_request) Skipped
2026-09-09 10:57:56 -05:00
fen 3d415edbdc fix #84: security headers dropped because they were set post-flush; set pre-handler
CI / test (pull_request) Successful in 21s
CI / docker (pull_request) Skipped
2026-09-09 10:54:16 -05:00
7 changed files with 227 additions and 25 deletions
+4
View File
@@ -22,6 +22,10 @@ curl -X POST http://localhost:8080/api/pastes \
- `expires_in` is a Go duration string (`90m`, `6h`, `336h`). Omit for no expiry.
- `visibility` is `public` or `unlisted`.
- Alternatively (or additionally), `public` may be sent as a boolean (#83):
`false` maps to `unlisted` and `true` maps to `public`. When both fields are
present, the boolean `public` takes precedence over the string `visibility`.
Omitting both defaults to `public`.
- `burn_after_reads` sets how many reads the paste survives (default 1 when
`burn_after_read` is true). A read is counted per unique viewer session;
the same viewer returning within 15 minutes does not count again.
@@ -0,0 +1,92 @@
package api
// #81: ALL password verification attempts (GET query param, header, POST
// form) must go through the per-IP unlock limiter. Regression: N wrong
// passwords via GET ?password= must eventually yield 429.
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
)
func createPasswordPaste(t *testing.T, s *apiServer, pw string) string {
t.Helper()
h := s.routes()
body := `{"content":"secret","password":"` + pw + `"}`
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
return created.ID
}
// TestRateLimitGetPasswordQuery: repeated wrong passwords via GET
// ?password= must eventually return 429 (unlock limiter: burst 5).
func TestRateLimitGetPasswordQuery(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createPasswordPaste(t, s, "hunter2")
var saw429 bool
// more attempts than the unlock burst (5)
for i := 0; i < 10; i++ {
req := httptest.NewRequest("GET", "/api/pastes/"+id+"?password=wrong"+string(rune('a'+i)), nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == 429 {
saw429 = true
break
}
if rec.Code != 401 {
t.Fatalf("attempt %d: expected 401 before limit, got %d", i, rec.Code)
}
}
if !saw429 {
t.Fatal("expected 429 after repeated wrong ?password= attempts, never got one")
}
}
// TestRateLimitGetPasswordHeader: same guarantee for the X-Paste-Password header path.
func TestRateLimitGetPasswordHeader(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createPasswordPaste(t, s, "hunter2")
var saw429 bool
for i := 0; i < 10; i++ {
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
req.Header.Set("X-Paste-Password", "wrong"+string(rune('a'+i)))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == 429 {
saw429 = true
break
}
if rec.Code != 401 {
t.Fatalf("attempt %d: expected 401 before limit, got %d", i, rec.Code)
}
}
if !saw429 {
t.Fatal("expected 429 after repeated wrong header password attempts, never got one")
}
}
// TestRateLimitGetPasswordCorrectStillAllowed: a correct password must still
// work within the burst (the limiter gates attempts, not correctness).
func TestRateLimitGetPasswordCorrectStillAllowed(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createPasswordPaste(t, s, "hunter2")
req := httptest.NewRequest("GET", "/api/pastes/"+id+"?password=hunter2", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("expected 200 for correct password within burst, got %d", rec.Code)
}
}
+97
View File
@@ -0,0 +1,97 @@
package api
// #83 regression tests: `public` boolean in the create payload must map to
// visibility (false -> unlisted, true -> public); string `visibility` still works.
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
)
func createPasteBody(t *testing.T, h *apiServer, body string) map[string]any {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
rec := httptest.NewRecorder()
h.routes().ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create: got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("bad json: %v", err)
}
return resp
}
func getVis(t *testing.T, h *apiServer, id string) string {
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
rec := httptest.NewRecorder()
h.routes().ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("get %s: got %d", id, rec.Code)
}
var resp struct {
Visibility string `json:"visibility"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("bad json: %v", err)
}
return resp.Visibility
}
// TestPublicBooleanFalseMapsToUnlisted: {"public": false} must create an unlisted paste.
func TestPublicBooleanFalseMapsToUnlisted(t *testing.T) {
s := testServer(t)
resp := createPasteBody(t, s, `{"content":"x","public":false}`)
if v := getVis(t, s, resp["id"].(string)); v != "unlisted" {
t.Fatalf("public:false -> got visibility %q, want unlisted", v)
}
}
// TestPublicBooleanTrueMapsToPublic: {"public": true} must create a public paste.
func TestPublicBooleanTrueMapsToPublic(t *testing.T) {
s := testServer(t)
resp := createPasteBody(t, s, `{"content":"x","public":true}`)
if v := getVis(t, s, resp["id"].(string)); v != "public" {
t.Fatalf("public:true -> got visibility %q, want public", v)
}
}
// TestPublicBooleanOverridesString: boolean wins when both fields are sent.
func TestPublicBooleanOverridesString(t *testing.T) {
s := testServer(t)
resp := createPasteBody(t, s, `{"content":"x","visibility":"public","public":false}`)
if v := getVis(t, s, resp["id"].(string)); v != "unlisted" {
t.Fatalf("boolean override -> got %q, want unlisted", v)
}
}
// TestVisibilityStringStillWorks: existing string contract unchanged.
func TestVisibilityStringStillWorks(t *testing.T) {
s := testServer(t)
resp := createPasteBody(t, s, `{"content":"x","visibility":"unlisted"}`)
if v := getVis(t, s, resp["id"].(string)); v != "unlisted" {
t.Fatalf("string field -> got %q, want unlisted", v)
}
resp = createPasteBody(t, s, `{"content":"y","visibility":"public"}`)
if v := getVis(t, s, resp["id"].(string)); v != "public" {
t.Fatalf("string field -> got %q, want public", v)
}
}
// TestPublicListExcludesPublicFalse: {"public":false} pastes stay out of /api/public.
func TestPublicListExcludesPublicFalse(t *testing.T) {
s := testServer(t)
createPasteBody(t, s, `{"content":"hidden","public":false}`)
req := httptest.NewRequest("GET", "/api/public", nil)
rec := httptest.NewRecorder()
s.routes().ServeHTTP(rec, req)
var resp struct {
Total int `json:"total"`
}
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Total != 0 {
t.Fatalf("public:false paste leaked into /api/public: total=%d", resp.Total)
}
}
+7
View File
@@ -214,6 +214,13 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
return
}
if row.PasswordHash.Valid {
// #81: every password verification (header, query param, or empty)
// goes through the same per-IP+paste unlock limiter as the POST form
// path, so brute-force via GET ?password= or X-Paste-Password gets 429.
if !rateLimitUnlock(row.ID, r) {
writeRateLimited(w, 60)
return
}
// require password via header or query
pw := r.Header.Get("X-Paste-Password")
if pw == "" {
+10
View File
@@ -30,6 +30,8 @@ type Paste struct {
BurnAfterRead bool `json:"burn_after_read,omitempty"`
BurnAfterReads *int `json:"burn_after_reads,omitempty"` // #49: readable N times (default 1)
Visibility string `json:"visibility"`
// #83: accept "public": true/false as an alias for visibility.
Public *bool `json:"public,omitempty"`
CanID *string `json:"can_id,omitempty"`
CreatedAt int64 `json:"created_at"`
DeletedAt *int64 `json:"deleted_at,omitempty"`
@@ -215,6 +217,14 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
}
visibility := p.Visibility
// #83: "public": false -> unlisted, true -> public; overrides string field
if p.Public != nil {
if *p.Public {
visibility = "public"
} else {
visibility = "unlisted"
}
}
if visibility == "" {
visibility = "public"
}
+8 -18
View File
@@ -3,7 +3,6 @@ package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@@ -28,29 +27,20 @@ func TestSecurityHeaders(t *testing.T) {
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
}
// JSON response: no security headers.
// JSON/raw responses: headers are now set unconditionally BEFORE the handler
// runs. The previous post-handler approach was silently dropped once a page
// handler flushed its template output (headers must be set before WriteHeader).
// CSP/nosniff/referrer on non-HTML bodies is harmless and desirable.
jsonh := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}))
rec = httptest.NewRecorder()
jsonh.ServeHTTP(rec, httptest.NewRequest("GET", "/api/x", nil))
if got := rec.Header().Get("Content-Security-Policy"); got != "" {
t.Errorf("unexpected CSP %q on JSON response", got)
if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP {
t.Errorf("CSP missing on JSON response: got %q", got)
}
if got := rec.Header().Get("Referrer-Policy"); got != "" {
t.Errorf("unexpected Referrer-Policy %q on JSON response", got)
}
// Content type set after the first Write (as the inline can page does) is
// still picked up because headers are inspected post-handler.
lateh := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<html></html>"))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
}))
rec = httptest.NewRecorder()
lateh.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
if got := rec.Header().Get("Content-Security-Policy"); !strings.Contains(got, "frame-ancestors 'none'") {
t.Errorf("CSP = %q, want frame-ancestors 'none'", got)
if got := rec.Header().Get("Referrer-Policy"); got != "no-referrer" {
t.Errorf("Referrer-Policy missing on JSON response: got %q", got)
}
}
+5 -3
View File
@@ -304,13 +304,15 @@ func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} }
// pentest. Runs after the handler so the Content-Type is already set.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
// Set before the handler runs: once a handler writes (template render
// flushes), header mutations are silently dropped. Setting the headers
// unconditionally is safe: CSP/nosniff/referrer on JSON or /raw bodies
// is harmless and arguably desirable.
h := w.Header()
if strings.HasPrefix(h.Get("Content-Type"), "text/html") {
h.Set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors 'none'")
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Content-Type-Options", "nosniff")
}
next.ServeHTTP(w, r)
})
}