Rate-limit all password verification attempts (#81) #90
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 == "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user