Rate-limit all password verification attempts (#81) #90

Merged
poslop merged 1 commits from fix-81-password-ratelimit into main 2026-09-09 15:59:24 +00:00
2 changed files with 99 additions and 0 deletions
Showing only changes of commit 99a044e8a2 - Show all commits
@@ -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)
}
}
+7
View File
@@ -214,6 +214,13 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
return return
} }
if row.PasswordHash.Valid { 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 // require password via header or query
pw := r.Header.Get("X-Paste-Password") pw := r.Header.Get("X-Paste-Password")
if pw == "" { if pw == "" {