diff --git a/internal/api/issue81_password_ratelimit_test.go b/internal/api/issue81_password_ratelimit_test.go new file mode 100644 index 0000000..f33d91f --- /dev/null +++ b/internal/api/issue81_password_ratelimit_test.go @@ -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) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 8a5c78b..abde902 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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 == "" {