fix(backend): rate-limit and cap report ingest, drop wildcard CORS (closes #20)
check / check (push) Successful in 42s
check / check (push) Successful in 42s
POST /api/v1/reports stays unauthenticated but is bounded. Each client address, as the trusted-proxy logic resolves it, may send REPORTS_PER_MINUTE reports a minute (default 60, counted by go-chi/httprate over a sliding minute); past that it gets 429 with Retry-After. reportbuf refuses a report that would take the report files past DATA_DIR_MAX_BYTES (default 1 GiB), counting the files already in DATA_DIR and unwritten reports at their uncompressed size; the handler answers 507. CORS adds nothing unless CORS_ALLOWED_ORIGINS lists origins. A limit that is not a positive number, or an origin that is not a plain scheme://host[:port], stops the server from starting. Model: opus-5-5
This commit is contained in:
@@ -25,7 +25,7 @@ func (s *Server) SetupRoutes() {
|
||||
s.router.Use(middleware.RequestID)
|
||||
s.router.Use(s.mw.Logging())
|
||||
s.router.Use(s.mw.SecurityHeaders())
|
||||
s.router.Use(s.mw.CORS())
|
||||
s.router.Use(s.mw.CORS(s.params.Config.CORSAllowedOrigins))
|
||||
s.router.Use(s.mw.MaxBodyBytes(maxRequestBodyBytes))
|
||||
s.router.Use(middleware.Timeout(requestTimeout))
|
||||
|
||||
@@ -35,6 +35,7 @@ func (s *Server) SetupRoutes() {
|
||||
)
|
||||
|
||||
s.router.Route("/api/v1", func(r chi.Router) {
|
||||
r.Post("/reports", s.h.HandleReport())
|
||||
r.With(s.mw.RateLimit(s.params.Config.ReportsPerMinute)).
|
||||
Post("/reports", s.h.HandleReport())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,6 +49,64 @@ func newServer(t *testing.T) *server.Server {
|
||||
return srv
|
||||
}
|
||||
|
||||
// TestReportsAreRateLimited checks that POST /api/v1/reports is
|
||||
// behind the per-address rate limit, set here to two a minute.
|
||||
func TestReportsAreRateLimited(t *testing.T) {
|
||||
t.Setenv("REPORTS_PER_MINUTE", "2")
|
||||
|
||||
srv := newServer(t)
|
||||
srv.SetupRoutes()
|
||||
|
||||
post := func() int {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(`{"clientId":"c1","hosts":[]}`),
|
||||
)
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
for i := range 2 {
|
||||
if code := post(); code != http.StatusOK {
|
||||
t.Fatalf("report %d: status = %d, want %d",
|
||||
i+1, code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
if code := post(); code != http.StatusTooManyRequests {
|
||||
t.Fatalf("third report in a minute: status = %d, want %d",
|
||||
code, http.StatusTooManyRequests)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSAllowedOriginsReachTheRouter checks that an origin listed in
|
||||
// CORS_ALLOWED_ORIGINS is allowed by the router, not only when handed
|
||||
// to the CORS middleware directly.
|
||||
func TestCORSAllowedOriginsReachTheRouter(t *testing.T) {
|
||||
const origin = "https://netwatch.example:8443"
|
||||
|
||||
t.Setenv("CORS_ALLOWED_ORIGINS", origin)
|
||||
|
||||
srv := newServer(t)
|
||||
srv.SetupRoutes()
|
||||
|
||||
// The preflight a browser sends before it POSTs JSON from origin.
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodOptions, "/api/v1/reports", http.NoBody)
|
||||
req.Header.Set("Origin", origin)
|
||||
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
||||
req.Header.Set("Access-Control-Request-Headers", "content-type")
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
got := rec.Header().Get("Access-Control-Allow-Origin")
|
||||
if got != origin {
|
||||
t.Fatalf("Access-Control-Allow-Origin = %q, want %q", got, origin)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthCheckRejectsOversizeBody sends the health check, which
|
||||
// never reads its body, a body one byte over the limit. Only the
|
||||
// router-wide body limit can reject it.
|
||||
|
||||
Reference in New Issue
Block a user