From c28aa152d29add97756a0c59cecad4e6cb125fa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20R=C3=BCger?= Date: Wed, 2 Sep 2026 14:29:11 +0200 Subject: [PATCH] web: set the configured headers on rate limited responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rate limiter was checked before the loop that applies http_server_config.headers, so a 429 was written without them. A request that was served carried the operator's Strict-Transport-Security, Content-Security-Policy and X-Frame-Options, and a request from the same client a moment later did not. A header policy is meant to hold for every response from the endpoint, so apply the headers first and turn the request away afterwards. Note when testing this by hand that X-Content-Type-Options hides the difference: http.Error sets nosniff itself, so it was present on the 429 either way. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Manuel RĂ¼ger --- web/handler.go | 12 ++-- web/headers_test.go | 65 +++++++++++++++++++ .../web_config_rate_limiter_headers.yaml | 7 ++ 3 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 web/headers_test.go create mode 100644 web/testdata/web_config_rate_limiter_headers.yaml diff --git a/web/handler.go b/web/handler.go index 0a2718d5..22931c59 100644 --- a/web/handler.go +++ b/web/handler.go @@ -95,16 +95,18 @@ func (u *webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Configure http headers. This happens before any request is turned away, + // so that the configured headers are present on every response and not + // only on the ones that reach the wrapped handler. + for k, v := range c.HTTPConfig.Header { + w.Header().Set(k, v) + } + if u.limiter != nil && !u.limiter.Allow() { http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests) return } - // Configure http headers. - for k, v := range c.HTTPConfig.Header { - w.Header().Set(k, v) - } - if len(c.Users) == 0 { u.handler.ServeHTTP(w, r) return diff --git a/web/headers_test.go b/web/headers_test.go new file mode 100644 index 00000000..a25e51a6 --- /dev/null +++ b/web/headers_test.go @@ -0,0 +1,65 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package web + +import ( + "net/http" + "testing" +) + +// TestHeadersOnRateLimitedResponse checks that the configured headers are set +// on a response that the rate limiter turns away. A header policy such as +// Strict-Transport-Security is meant to hold for every response from the +// endpoint, not only for the ones that are served. +func TestHeadersOnRateLimitedResponse(t *testing.T) { + server := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte("Hello World!")) + }), + } + + addr := startTLSProtocolServer(t, server, "testdata/web_config_rate_limiter_headers.yaml") + + // The burst is 1 over an interval of an hour, so the first request is + // served and every one after it is rate limited. + get := func() *http.Response { + t.Helper() + r, err := http.Get("http://" + addr) + if err != nil { + t.Fatal(err) + } + r.Body.Close() + return r + } + + served := get() + if served.StatusCode != http.StatusOK { + t.Fatalf("first request: got status %d, expected %d", served.StatusCode, http.StatusOK) + } + + limited := get() + if limited.StatusCode != http.StatusTooManyRequests { + t.Fatalf("second request: got status %d, expected %d", limited.StatusCode, http.StatusTooManyRequests) + } + + for _, header := range []string{"X-Frame-Options", "Strict-Transport-Security"} { + want := served.Header.Get(header) + if want == "" { + t.Fatalf("%s was not set on the served response", header) + } + if got := limited.Header.Get(header); got != want { + t.Errorf("%s on the rate limited response = %q, expected %q", header, got, want) + } + } +} diff --git a/web/testdata/web_config_rate_limiter_headers.yaml b/web/testdata/web_config_rate_limiter_headers.yaml new file mode 100644 index 00000000..6a8c64f4 --- /dev/null +++ b/web/testdata/web_config_rate_limiter_headers.yaml @@ -0,0 +1,7 @@ +rate_limit: + interval: 1h + burst: 1 +http_server_config: + headers: + X-Frame-Options: deny + Strict-Transport-Security: max-age=31536000; includeSubDomains