From c6c0772a7a028100fd85641bdd62e30c7980acc3 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Wed, 1 Jul 2026 21:12:03 +0900 Subject: [PATCH] fix(api): set read/idle timeouts on the felis-api listeners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three felis-api http.Servers (internal, external, https) were built with only Addr and Handler, leaving ReadHeaderTimeout, IdleTimeout, and ReadTimeout at zero. A zero ReadHeaderTimeout is a Slowloris hole — a client trickling header bytes pins a connection indefinitely — and a zero IdleTimeout lets kept-alive connections accumulate (gosec G112). Route all three listeners through a newAPIServer factory that sets a 10s ReadHeaderTimeout and a 120s IdleTimeout. WriteTimeout and ReadTimeout are left unset on purpose: the external and https faces stream Server-Sent Events (console / build logs) for the lifetime of a client attachment, and a WriteTimeout would sever a healthy long-lived stream. Slowloris is closed by ReadHeaderTimeout, which bounds only the header phase. --- cmd/felis/api.go | 33 ++++++++++++++++++++++++++++++--- cmd/felis/api_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 cmd/felis/api_test.go diff --git a/cmd/felis/api.go b/cmd/felis/api.go index d3a9ab4..24c2160 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -198,15 +198,15 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { } externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain) - internalSrv := &http.Server{Addr: *internalAddr, Handler: a.InternalHandler()} - externalSrv := &http.Server{Addr: cfg.Server.Listen, Handler: externalHandler} + internalSrv := newAPIServer(*internalAddr, a.InternalHandler()) + externalSrv := newAPIServer(cfg.Server.Listen, externalHandler) errc := make(chan error, 3) go func() { errc <- internalSrv.ListenAndServe() }() go func() { errc <- externalSrv.ListenAndServe() }() var httpsSrv *http.Server if *httpsAddr != "" { - httpsSrv = &http.Server{Addr: *httpsAddr, Handler: externalHandler} + httpsSrv = newAPIServer(*httpsAddr, externalHandler) go func() { errc <- httpsSrv.ListenAndServeTLS(*tlsCert, *tlsKey) }() } if httpsSrv != nil { @@ -239,6 +239,33 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { } } +const ( + // apiReadHeaderTimeout caps how long a client may take to send its request + // headers, defeating a Slowloris that trickles a header line forever to pin a + // connection open. It bounds only the header phase, so it is safe on every face — + // including the SSE streaming one, whose response, not its request, is long-lived. + apiReadHeaderTimeout = 10 * time.Second + // apiIdleTimeout caps how long a kept-alive connection may sit idle between + // requests before the server closes it, bounding idle-connection exhaustion. + apiIdleTimeout = 120 * time.Second +) + +// newAPIServer builds an http.Server with hardened header/idle timeouts (gosec +// G112) shared by all three felis-api listeners (internal, external, https). +// WriteTimeout and ReadTimeout are deliberately LEFT UNSET: the external and https +// faces stream Server-Sent Events (console / build logs, spec §8) for the lifetime +// of a client's attachment, and a WriteTimeout would sever a healthy long-lived +// stream mid-flight. Slowloris is closed by ReadHeaderTimeout, which bounds only the +// header phase and never touches the response. +func newAPIServer(addr string, handler http.Handler) *http.Server { + return &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: apiReadHeaderTimeout, + IdleTimeout: apiIdleTimeout, + } +} + // buildConfig projects felis.toml onto the build subsystem config (spec §16, // §24). Unset fields fall back to the build package's hardened defaults // (felis-build namespace + weak SA, 30m deadline, resource limits). diff --git a/cmd/felis/api_test.go b/cmd/felis/api_test.go new file mode 100644 index 0000000..7b6c7a3 --- /dev/null +++ b/cmd/felis/api_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "net/http" + "testing" +) + +// TestNewAPIServerSetsHardenedTimeouts pins the gosec-G112 hardening on every +// felis-api listener: the shared factory must bound the header and idle phases +// (Slowloris + idle-connection exhaustion) while leaving WriteTimeout UNSET, because +// the external and https faces stream Server-Sent Events for the life of a client's +// console/build-log attachment and a WriteTimeout would sever a healthy long stream. +func TestNewAPIServerSetsHardenedTimeouts(t *testing.T) { + srv := newAPIServer(":0", http.NewServeMux()) + + if srv.ReadHeaderTimeout <= 0 { + t.Errorf("ReadHeaderTimeout = %v, want a positive Slowloris bound", srv.ReadHeaderTimeout) + } + if srv.IdleTimeout <= 0 { + t.Errorf("IdleTimeout = %v, want a positive idle-connection bound", srv.IdleTimeout) + } + if srv.WriteTimeout != 0 { + t.Errorf("WriteTimeout = %v, want 0 (unset) so long-lived SSE streams are not severed", srv.WriteTimeout) + } + if srv.ReadTimeout != 0 { + t.Errorf("ReadTimeout = %v, want 0 (unset) so a slow SSE attach is not capped", srv.ReadTimeout) + } +}