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) + } +}