Skip to content

Commit cdc4a37

Browse files
bootstrap: add configurable ReadHeaderTimeout to the HTTP server
The *http.Server built by Runner.newServer left ReadHeaderTimeout unset, so request-header reads were unbounded -- a Slowloris-style connection exhaustion vector (gosec G112). The exporter-toolkit web package does not set server timeouts either, so every bootstrap-based exporter inherited the gap. Add a ReadHeaderTimeout field to bootstrap.Config, defaulting to one minute when unset. It bounds only header reading, not the metrics handler, so it never affects legitimate scrapes, and -- like every other Config field -- downstream exporters can tune it to their own needs. Follow-up to prometheus/blackbox_exporter#1626, which made the same change on the caller side; setting it here fixes it once for all toolkit users. go build, go vet, and go test ./... (incl. -race) pass; gosec G112 clears. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: randomizedcoder dave.seddon.ca@gmail.com <dave.seddon.ca@gmail.com>
1 parent 72861ea commit cdc4a37

2 files changed

Lines changed: 132 additions & 1 deletion

File tree

bootstrap/bootstrap.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"log/slog"
2323
"net/http"
2424
"os"
25+
"time"
2526

2627
"github.com/alecthomas/kingpin/v2"
2728
"github.com/prometheus/common/promslog"
@@ -43,6 +44,9 @@ var (
4344
errNegativeMaxRequests = errors.New("web max requests must be greater than or equal to zero")
4445
)
4546

47+
// defaultReadHeaderTimeout applies when Config.ReadHeaderTimeout is left unset.
48+
const defaultReadHeaderTimeout = time.Minute
49+
4650
// MetricsHandlerFactory builds an exporter-specific metrics handler after the
4751
// common toolkit flags have been parsed.
4852
type MetricsHandlerFactory func(*Bootstrap) (http.Handler, error)
@@ -82,6 +86,9 @@ type Config struct {
8286
MetricsHandler http.Handler
8387
// MetricsHandlerFactory builds the metrics handler after flags are parsed.
8488
MetricsHandlerFactory MetricsHandlerFactory
89+
// ReadHeaderTimeout bounds request-header reads, mitigating Slowloris
90+
// (gosec G112). Defaults to one minute when zero.
91+
ReadHeaderTimeout time.Duration
8592
}
8693

8794
// Runner manages generic exporter startup around flag parsing, landing page
@@ -250,7 +257,15 @@ func (t *Runner) newServer(metricsHandler http.Handler) (*http.Server, error) {
250257
mux.Handle("/", landingPage)
251258
}
252259

253-
return &http.Server{Handler: mux}, nil
260+
readHeaderTimeout := defaultReadHeaderTimeout
261+
if t.provided.ReadHeaderTimeout > 0 {
262+
readHeaderTimeout = t.provided.ReadHeaderTimeout
263+
}
264+
265+
return &http.Server{
266+
Handler: mux,
267+
ReadHeaderTimeout: readHeaderTimeout,
268+
}, nil
254269
}
255270

256271
func (t *Runner) defaultLandingConfig() web.LandingConfig {

bootstrap/bootstrap_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414
package bootstrap
1515

1616
import (
17+
"net"
1718
"net/http"
1819
"net/http/httptest"
1920
"strings"
2021
"testing"
22+
"time"
2123

2224
"github.com/alecthomas/kingpin/v2"
2325
"github.com/prometheus/common/promslog"
@@ -129,3 +131,117 @@ func TestNewServerRegistersMetricsAndLandingPage(t *testing.T) {
129131
t.Fatalf("unexpected landing body: %q", body)
130132
}
131133
}
134+
135+
// TestNewServerReadHeaderTimeout checks newServer maps Config.ReadHeaderTimeout
136+
// onto the server, defaulting to one minute when unset.
137+
func TestNewServerReadHeaderTimeout(t *testing.T) {
138+
for _, tc := range []struct {
139+
name string
140+
configured time.Duration
141+
want time.Duration
142+
}{
143+
{name: "unset defaults", configured: 0, want: defaultReadHeaderTimeout},
144+
{name: "explicit default", configured: time.Minute, want: time.Minute},
145+
{name: "sub-second value", configured: 250 * time.Millisecond, want: 250 * time.Millisecond},
146+
{name: "smallest positive value", configured: time.Nanosecond, want: time.Nanosecond},
147+
{name: "large value", configured: time.Hour, want: time.Hour},
148+
{name: "negative defaults", configured: -1, want: defaultReadHeaderTimeout},
149+
{name: "large negative defaults", configured: -time.Hour, want: defaultReadHeaderTimeout},
150+
} {
151+
t.Run(tc.name, func(t *testing.T) {
152+
tk := New(Config{
153+
App: kingpin.New("test", ""),
154+
DefaultAddress: ":9100",
155+
Logger: promslog.NewNopLogger(),
156+
ReadHeaderTimeout: tc.configured,
157+
MetricsHandler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
158+
w.WriteHeader(http.StatusOK)
159+
}),
160+
})
161+
if err := tk.parse([]string{"--web.listen-address=:0"}); err != nil {
162+
t.Fatalf("unexpected parse error: %v", err)
163+
}
164+
handler, err := tk.resolveMetricsHandler()
165+
if err != nil {
166+
t.Fatalf("unexpected handler resolution error: %v", err)
167+
}
168+
server, err := tk.newServer(handler)
169+
if err != nil {
170+
t.Fatalf("unexpected server creation error: %v", err)
171+
}
172+
if server.ReadHeaderTimeout != tc.want {
173+
t.Fatalf("unexpected ReadHeaderTimeout: got %v, want %v", server.ReadHeaderTimeout, tc.want)
174+
}
175+
})
176+
}
177+
}
178+
179+
// TestNewServerReadHeaderTimeoutClosesStalledConnection checks the timeout is
180+
// effective end-to-end: a connection with incomplete headers is closed while a
181+
// well-formed request succeeds.
182+
func TestNewServerReadHeaderTimeoutClosesStalledConnection(t *testing.T) {
183+
const readHeaderTimeout = 250 * time.Millisecond
184+
185+
tk := New(Config{
186+
App: kingpin.New("test", ""),
187+
Name: "test_exporter",
188+
Description: "test description",
189+
DefaultAddress: ":9100",
190+
Logger: promslog.NewNopLogger(),
191+
ReadHeaderTimeout: readHeaderTimeout,
192+
MetricsHandler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
193+
w.WriteHeader(http.StatusOK)
194+
}),
195+
})
196+
if err := tk.parse([]string{"--web.listen-address=:0"}); err != nil {
197+
t.Fatalf("unexpected parse error: %v", err)
198+
}
199+
handler, err := tk.resolveMetricsHandler()
200+
if err != nil {
201+
t.Fatalf("unexpected handler resolution error: %v", err)
202+
}
203+
server, err := tk.newServer(handler)
204+
if err != nil {
205+
t.Fatalf("unexpected server creation error: %v", err)
206+
}
207+
if server.ReadHeaderTimeout != readHeaderTimeout {
208+
t.Fatalf("unexpected ReadHeaderTimeout: got %v, want %v", server.ReadHeaderTimeout, readHeaderTimeout)
209+
}
210+
211+
ln, err := net.Listen("tcp", "127.0.0.1:0")
212+
if err != nil {
213+
t.Fatalf("listen: %v", err)
214+
}
215+
t.Cleanup(func() { _ = ln.Close() })
216+
go func() { _ = server.Serve(ln) }()
217+
t.Cleanup(func() { _ = server.Close() })
218+
219+
// A well-formed request still succeeds.
220+
resp, err := http.Get("http://" + ln.Addr().String() + "/metrics")
221+
if err != nil {
222+
t.Fatalf("well-formed request failed: %v", err)
223+
}
224+
_ = resp.Body.Close()
225+
226+
// Start request headers but never terminate them (no final CRLF).
227+
conn, err := net.Dial("tcp", ln.Addr().String())
228+
if err != nil {
229+
t.Fatalf("dial: %v", err)
230+
}
231+
t.Cleanup(func() { _ = conn.Close() })
232+
if _, err := conn.Write([]byte("GET /metrics HTTP/1.1\r\nHost: localhost\r\n")); err != nil {
233+
t.Fatalf("write partial request: %v", err)
234+
}
235+
236+
// Read in a goroutine so the test never hangs if the server keeps it open.
237+
done := make(chan struct{})
238+
go func() {
239+
_, _ = conn.Read(make([]byte, 1)) // unblocks on server-side close
240+
close(done)
241+
}()
242+
select {
243+
case <-done:
244+
case <-time.After(5 * time.Second):
245+
t.Fatal("server did not close the stalled connection within 5s; ReadHeaderTimeout not effective")
246+
}
247+
}

0 commit comments

Comments
 (0)