Limit each client's requests to the image routes (closes #236)
check / check (push) Successful in 54m15s
check / check (push) Successful in 54m15s
The new setting image_requests_per_minute (PIXA_IMAGE_REQUESTS_PER_MINUTE), default 600, caps the GET and HEAD requests one client sends to /v1/image/ and /v1/e/ per minute; the next is answered 429 with Retry-After and the image routes' JSON error body. It uses the login limit's RateLimit middleware, so clients are told apart the same way, behind the CORS middleware, so a preflight is not counted. 0 turns it off, as script/loadtest does; a negative or non-integer value stops startup. TestEnvironmentSetsEveryKey gains the new variable, under the standing approval in #64. To stay within the linters' limits, the sentry_dsn check moves unchanged into validateSentryDSN, and the cache_max_bytes explicit flag is set in one line. Model: opus-5-5
This commit was merged in pull request #237.
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
)
|
||||
|
||||
// requestImageFrom sends method path to s from remoteAddr and returns the
|
||||
// response. The image handlers refuse an unsigned image URL with 401 and a
|
||||
// token they cannot decrypt with 400, so either status shows the limit let
|
||||
// the request through.
|
||||
func requestImageFrom(
|
||||
t *testing.T, s *Server, method, path, remoteAddr string,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), method, path, nil)
|
||||
req.RemoteAddr = remoteAddr
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.ServeHTTP(rec, req)
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestImageRequestLimitRefusesRequestOverLimit verifies that GET and HEAD on
|
||||
// both image routes count toward one limit per client, and that the request
|
||||
// after image_requests_per_minute of them is refused with 429 and a
|
||||
// Retry-After header.
|
||||
func TestImageRequestLimitRefusesRequestOverLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
requests := []struct {
|
||||
method string
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{http.MethodGet, unsignedImagePath, http.StatusUnauthorized},
|
||||
{http.MethodHead, unsignedImagePath, http.StatusUnauthorized},
|
||||
{http.MethodGet, encryptedImagePath, http.StatusBadRequest},
|
||||
}
|
||||
|
||||
s := newTestServer(t)
|
||||
s.config.ImageRequestsPerMinute = len(requests)
|
||||
s.SetupRoutes()
|
||||
|
||||
for _, tc := range requests {
|
||||
rec := requestImageFrom(t, s, tc.method, tc.path, firstClient)
|
||||
if rec.Code != tc.want {
|
||||
t.Fatalf("%s %s within the limit status = %d, want %d",
|
||||
tc.method, tc.path, rec.Code, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
rec := requestImageFrom(t, s, http.MethodHead, encryptedImagePath, firstClient)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("request over the limit status = %d, want %d",
|
||||
rec.Code, http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
retryAfter := rec.Header().Get("Retry-After")
|
||||
|
||||
seconds, err := strconv.Atoi(retryAfter)
|
||||
if err != nil || seconds <= 0 {
|
||||
t.Errorf("Retry-After = %q, want a positive number of seconds",
|
||||
retryAfter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageRequestLimitAnswersWithJSONError verifies that the 429 for a
|
||||
// request over the limit carries Retry-After and the JSON error body the
|
||||
// image routes send for their other errors.
|
||||
func TestImageRequestLimitAnswersWithJSONError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestServer(t)
|
||||
s.config.ImageRequestsPerMinute = 1
|
||||
s.SetupRoutes()
|
||||
|
||||
requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
||||
|
||||
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
||||
t.Logf("status %d, body %s", rec.Code, rec.Body.String())
|
||||
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("request over the limit status = %d, want %d",
|
||||
rec.Code, http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
retryAfter := rec.Header().Get("Retry-After")
|
||||
|
||||
seconds, err := strconv.Atoi(retryAfter)
|
||||
if err != nil || seconds <= 0 {
|
||||
t.Errorf("Retry-After = %q, want a positive number of seconds",
|
||||
retryAfter)
|
||||
}
|
||||
|
||||
contentType := rec.Header().Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want %q", contentType, "application/json")
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
Status int `json:"status"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
err = json.NewDecoder(rec.Body).Decode(&body)
|
||||
if err != nil {
|
||||
t.Fatalf("body is not JSON: %v", err)
|
||||
}
|
||||
|
||||
if body.Error == "" || body.Status != http.StatusTooManyRequests ||
|
||||
body.Timestamp == "" {
|
||||
t.Errorf("body = %+v, want an error, status %d and a timestamp",
|
||||
body, http.StatusTooManyRequests)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageRequestLimitCountsClientsSeparately verifies that one client going
|
||||
// over the limit does not limit another.
|
||||
func TestImageRequestLimitCountsClientsSeparately(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestServer(t)
|
||||
s.config.ImageRequestsPerMinute = 1
|
||||
s.SetupRoutes()
|
||||
|
||||
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("first client's first request status = %d, want %d",
|
||||
rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
rec = requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("first client's second request status = %d, want %d",
|
||||
rec.Code, http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
rec = requestImageFrom(t, s, http.MethodGet, unsignedImagePath, secondClient)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("second client's first request status = %d, want %d",
|
||||
rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageRequestLimitZeroLetsEveryRequestThrough verifies that with
|
||||
// image_requests_per_minute set to 0 no image request is refused with 429,
|
||||
// not even past the default limit.
|
||||
func TestImageRequestLimitZeroLetsEveryRequestThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestServer(t)
|
||||
s.config.ImageRequestsPerMinute = 0
|
||||
s.SetupRoutes()
|
||||
|
||||
for request := range config.DefaultImageRequestsPerMinute + 1 {
|
||||
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("request %d status = %d, want %d",
|
||||
request+1, rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageRequestLimitDoesNotCountPreflight verifies that a browser's CORS
|
||||
// preflight request to an image route is answered without being counted, so
|
||||
// a client that has sent several still gets its image request through.
|
||||
func TestImageRequestLimitDoesNotCountPreflight(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestServer(t)
|
||||
s.config.AccessControlAllowOrigin = config.DefaultAccessControlAllowOrigin
|
||||
s.config.ImageRequestsPerMinute = 1
|
||||
s.SetupRoutes()
|
||||
|
||||
for range 3 {
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodOptions, unsignedImagePath, nil)
|
||||
req.RemoteAddr = firstClient
|
||||
req.Header.Set("Origin", "https://app.example.com")
|
||||
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("preflight status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
rec := requestImageFrom(t, s, http.MethodGet, unsignedImagePath, firstClient)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("image request after preflights status = %d, want %d",
|
||||
rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func (s *Server) SetupRoutes() {
|
||||
r.Use(s.h.LimitBody(handlers.MaxFormBytes))
|
||||
r.Use(s.h.CSRF())
|
||||
r.Get("/", s.h.HandleRoot())
|
||||
r.With(s.mw.RateLimit(LoginAttemptsPerMinute, time.Minute)).
|
||||
r.With(s.mw.RateLimit(LoginAttemptsPerMinute, time.Minute, nil)).
|
||||
Post("/", s.h.HandleRoot())
|
||||
r.Post("/generate", s.h.HandleGenerateURL())
|
||||
})
|
||||
@@ -88,6 +88,15 @@ func (s *Server) SetupRoutes() {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.refuseDuringMaintenance)
|
||||
|
||||
// Limited per client, as the login POST is, but the 429 has
|
||||
// the image routes' JSON error body; 0 turns the limit off.
|
||||
// The CORS middleware answers a preflight before it gets
|
||||
// here, so a preflight is not counted.
|
||||
if s.config.ImageRequestsPerMinute > 0 {
|
||||
r.Use(s.mw.RateLimit(s.config.ImageRequestsPerMinute,
|
||||
time.Minute, s.h.HandleTooManyRequests()))
|
||||
}
|
||||
|
||||
// Main image proxy route
|
||||
// /v1/image/<host>/<path>/<width>x<height>.<format>, or with no
|
||||
// format /v1/image/<host>/<path>/<width>x<height>
|
||||
|
||||
Reference in New Issue
Block a user