Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions authbridge/authlib/observe/statserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"time"

Expand Down Expand Up @@ -126,6 +127,12 @@ func (s *StatServer) ListenAndServe() error {
return s.server.ListenAndServe()
}

// Serve serves on an already-bound listener, letting the caller bind first (and
// handle a bind error synchronously) before the server starts accepting.
func (s *StatServer) Serve(l net.Listener) error {
return s.server.Serve(l)
}

func (s *StatServer) Shutdown(ctx context.Context) error {
return s.server.Shutdown(ctx)
}
178 changes: 178 additions & 0 deletions authbridge/authlib/runtimeutil/runtimeutil.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// Package runtimeutil holds process-level helpers shared by the authbridge
// binaries (authbridge-proxy, authbridge-cpex, authbridge-envoy). Each binary
// has its own main() orchestration and listener wiring; only the byte-identical
// plumbing — logging setup, the SIGUSR1 log-level toggle, the health and stats
// servers, and the HTTP-listener helpers — lives here so a fix has to be made
// once rather than three times.
package runtimeutil

import (
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"

"github.com/rossoctl/cortex/authbridge/authlib/config"
"github.com/rossoctl/cortex/authbridge/authlib/listener/reverseproxy"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — The package doc (line 8) says this package is "intentionally listener-agnostic", but this import is the reverse-proxy listener. The claim and the code disagree.

The practical cost lands on authbridge-envoy: it uses only InitLogging, StartSignalToggle, StartStatServer, LogLevel, and StartHealthServer — none of the reverse-proxy helpers — and it does not import reverseproxy itself. By importing runtime it now transitively links listener/reverseproxy, listener/httpx, listener/internal/tlssniff, listener/internal/sseframe, and authlib/tls for nothing. That matters a bit more than usual given the authbridge-lite trimming comment in authbridge-proxy/main.go.

Two ways out. Either drop the "listener-agnostic" sentence and accept the coupling, or keep the claim true with a small local interface:

// Listenable is the subset of a listener server that StartReverseProxyServer
// needs, so this package stays free of concrete listener imports.
type Listenable interface {
	Handler() http.Handler
	Listen(addr string) (net.Listener, error)
	MTLSEnabled() bool
}

*reverseproxy.Server satisfies that structurally, so both call sites pass rpSrv unchanged and the import goes away.

"github.com/rossoctl/cortex/authbridge/authlib/observe"
"github.com/rossoctl/cortex/authbridge/authlib/pipeline"
)

// logLevel is the process-wide slog level, mutated live by StartSignalToggle.
var logLevel = new(slog.LevelVar)

// LogLevel returns the current process-wide slog level, for binaries that log
// their configured level at startup.
func LogLevel() slog.Level { return logLevel.Level() }

// InitLogging sets the process log level from the LOG_LEVEL env var (debug /
// warn / error, default info) and installs a slog text handler on stderr. The
// binaryName is attached to every record as the "binary" attribute so logs from
// co-located sidecars stay distinguishable.
func InitLogging(binaryName string) {
switch strings.ToLower(os.Getenv("LOG_LEVEL")) {
case "debug":
logLevel.Set(slog.LevelDebug)
case "warn":
logLevel.Set(slog.LevelWarn)
case "error":
logLevel.Set(slog.LevelError)
default:
logLevel.Set(slog.LevelInfo)
}
h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})
slog.SetDefault(slog.New(h).With("binary", binaryName))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// StartSignalToggle installs a SIGUSR1 handler that toggles the process log
// level between DEBUG and INFO, so operators can flip verbose logging on a
// running pod without a restart.
func StartSignalToggle() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGUSR1)
go func() {
for range sigCh {
if logLevel.Level() == slog.LevelDebug {
logLevel.Set(slog.LevelInfo)
slog.Info("log level toggled to INFO (send SIGUSR1 to switch back to DEBUG)")
} else {
logLevel.Set(slog.LevelDebug)
slog.Info("log level toggled to DEBUG (send SIGUSR1 to switch back to INFO)")
}
}
}()
}

// StartHealthServer binds addr, serves liveness (/healthz) and readiness
// (/readyz) in a goroutine, and returns the server for graceful shutdown.
// Readiness reports 503 while any inbound or outbound plugin is still waiting on
// a dependency (e.g. a credential file that hasn't landed yet). A bind failure
// is returned so the caller can decide how to handle it; a serve-time failure
// after bind is logged.
func StartHealthServer(inboundH, outboundH *pipeline.Holder, addr string) (*http.Server, error) {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
if name := inboundH.NotReadyPlugin(); name != "" {
http.Error(w, "inbound plugin not ready: "+name, http.StatusServiceUnavailable)
return
}
if name := outboundH.NotReadyPlugin(); name != "" {
http.Error(w, "outbound plugin not ready: "+name, http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
srv := &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
}
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
go func() {
slog.Info("health server listening", "addr", listener.Addr().String())
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
slog.Error("health server failed", "error", err)
}
}()
return srv, nil
}

// StartStatServer binds addr for the stats/config-inspection server, serves it
// in a goroutine, and returns the server for graceful shutdown. A bind failure
// is returned so the caller can decide how to handle it (the mains log.Fatalf);
// a serve-time failure after bind is logged.
func StartStatServer(cfg *config.Config, cfgProvider observe.ConfigProvider, statsProvider observe.StatsProvider, reloadStatus http.Handler, addr string) (*observe.StatServer, error) {
srv := observe.NewStatServer(addr, cfgProvider, statsProvider,
observe.WithReloadStatus(reloadStatus))
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
go func() {
slog.Info("stat server listening", "addr", listener.Addr().String())
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
slog.Error("stat server failed", "error", err)
}
}()
return srv, nil
}

// StartHTTPServer binds addr, serves handler in a goroutine, and returns the
// server for graceful shutdown. It logs the concrete bound address (resolving
// an ephemeral ":0" to the OS-assigned port). A bind failure is returned so the
// caller can decide how to handle it; a serve-time failure after bind is logged.
func StartHTTPServer(name string, handler http.Handler, addr string) (*http.Server, error) {
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
Comment on lines +134 to +138

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file existence and line count =="
wc -l authbridge/authlib/runtimeutil/runtimeutil.go

echo "== relevant source =="
cat -n authbridge/authlib/runtimeutil/runtimeutil.go | sed -n '1,220p'

echo "== searches for runtimeutil exports/usages =="
rg -n "StartHTTPServer|StartReverseProxyServer|ReadHeaderTimeout|ReadTimeout|WriteTimeout" authbridge -S

echo "== reverse proxy server source references in authbridge =="
fd -a 'server\.go$|proxy' authbridge/authlib authbridge | sed 's#^\./##' | head -200
rg -n "type Server|func \(.*\) Handler|ReadHeaderTimeout|ReadTimeout|WriteTimeout|body" authbridge/authlib/authbridge -S | head -300

Repository: rossoctl/cortex

Length of output: 11201


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== reverseproxy source =="
cat -n authbridge/authlib/listener/reverseproxy/server.go | sed -n '1,260p'

echo "== forwardproxy source snippets =="
cat -n authbridge/authlib/listener/forwardproxy/server.go | sed -n '1,260p'

echo "== config definitions for listener section =="
rg -n "type Listener|ReverseProxyAddr|ForwardProxyAddr|MaxUpload|Upload|Buffer|size" authbridge/authlib/authlib authbridge/authlib -S | head -200
for f in $(fd -a 'config.*\.go$' authbridge/authlib | head -20); do
  echo "--- $f"
  cat -n "$f" | sed -n '1,220p'
done

echo "== request body read uses in listener packages =="
rg -n "\.Body|\.Request|ReadAll|io\.Copy|MaxMemory|WriteTimeout|ReadTimeout" authbridge/authlib/listener authbridge/authlib -S | head -300

echo "== static verifier: http.Server defaults and timeout fields in runtimeutil helper constructors =="
python3 - <<'PY'
from pathlib import Path
p = Path("authbridge/authlib/runtimeutil/runtimeutil.go")
text = p.read_text()
for start, fn in [
    ("StartHTTPServer", "func StartHTTPServer"),
    ("StartReverseProxyServer", "func StartReverseProxyServer"),
]:
    idx = text.index(fn)
    # until the next top-level func
    nxt = text.find("\nfunc ", idx + 1)
    if nxt == -1:
        nxt = len(text)
    body = text[idx:nxt]
    print(f"== {start} ==")
    print("has ReadHeaderTimeout:", "ReadHeaderTimeout:" in body)
    print("has ReadTimeout:", "ReadTimeout:" in body)
    print("has WriteTimeout:", "WriteTimeout:" in body)
PY

echo "== Go http.Server documentation in local tool if available =="
if command -v go >/dev/null 2>&1; then
  go doc net/http.Server 2>/dev/null | sed -n '1,120p' || true
else
  echo "go not available"
fi

Repository: rossoctl/cortex

Length of output: 37495


Add bounded request timeouts to both listener helpers.

ReadHeaderTimeout only covers headers. Clients can still stall body reads for the lifetime of the connection because ReadTimeout is unset. Set a nonzero body timeout on both StartHTTPServer and StartReverseProxyServer; this helper is used by both proxy listeners.

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 121-125: This http.Server is constructed without a ReadTimeout. Without a read timeout, a slow or malicious client can hold connections open indefinitely (e.g. a Slowloris attack), exhausting server resources and causing a denial of service. Set ReadTimeout (and ideally ReadHeaderTimeout, WriteTimeout, and IdleTimeout) on the http.Server to bound how long the server waits while reading a request.
Context: http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
}
Note: [CWE-400] Uncontrolled Resource Consumption.

(http-server-missing-read-timeout-go)

📍 Affects 1 file
  • authbridge/authlib/runtimeutil/runtimeutil.go#L121-L125 (this comment)
  • authbridge/authlib/runtimeutil/runtimeutil.go#L148-L152
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@authbridge/authlib/runtimeutil/runtimeutil.go` around lines 121 - 125, Set a
nonzero ReadTimeout alongside ReadHeaderTimeout in both StartHTTPServer and
StartReverseProxyServer, using the same bounded body-read timeout for each
listener while preserving their existing server configuration.

Source: Linters/SAST tools

}
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
go func() {
slog.Info("HTTP server listening", "name", name, "addr", listener.Addr().String())
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
slog.Error("HTTP server failed", "name", name, "error", err)
}
}()
return srv, nil
}

// StartReverseProxyServer mirrors StartHTTPServer but uses the
// reverseproxy.Server's Listen() method so the byte-peek TLS-sniffing
// listener is wired in when mTLS is enabled. With mTLS off, Listen
// returns a plain net.Listen and behavior matches StartHTTPServer.
//
// Logged "mtls" attribute makes the listener mode visible at startup;
// operators expecting a separate :8443 port for TLS get a clear hint
// that this is the same :8080 with byte-peek detection.
func StartReverseProxyServer(name string, rp *reverseproxy.Server, addr string) (*http.Server, error) {
srv := &http.Server{
Addr: addr,
Handler: rp.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
listener, err := rp.Listen(addr)
if err != nil {
return nil, err
}
go func() {
slog.Info("Reverse server listening", "name", name, "addr", listener.Addr().String(), "mtls", rp.MTLSEnabled())
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
slog.Error("Reverse server failed", "name", name, "error", err)
}
}()
return srv, nil
}
72 changes: 72 additions & 0 deletions authbridge/authlib/runtimeutil/runtimeutil_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package runtimeutil

import (
"log/slog"
"syscall"
"testing"
"time"
)

// TestInitLogging verifies InitLogging maps LOG_LEVEL to the process level and
// defaults to INFO for unset/unknown values.
func TestInitLogging(t *testing.T) {
cases := []struct {
env string
want slog.Level
}{
{"debug", slog.LevelDebug},
{"DEBUG", slog.LevelDebug}, // case-insensitive
{"warn", slog.LevelWarn},
{"error", slog.LevelError},
{"info", slog.LevelInfo},
{"", slog.LevelInfo}, // unset -> default
{"nonsense", slog.LevelInfo}, // unknown -> default
}
for _, tc := range cases {
t.Run(tc.env, func(t *testing.T) {
t.Setenv("LOG_LEVEL", tc.env)
InitLogging("test-binary")
if got := LogLevel(); got != tc.want {
t.Fatalf("LOG_LEVEL=%q: LogLevel()=%v, want %v", tc.env, got, tc.want)
}
})
}
}

// TestStartSignalToggle verifies a SIGUSR1 flips the level between DEBUG and
// INFO on each delivery.
func TestStartSignalToggle(t *testing.T) {
t.Setenv("LOG_LEVEL", "info")
InitLogging("test-binary")
if LogLevel() != slog.LevelInfo {
t.Fatalf("precondition: want INFO, got %v", LogLevel())
}

StartSignalToggle()

// INFO -> DEBUG
if err := syscall.Kill(syscall.Getpid(), syscall.SIGUSR1); err != nil {
t.Fatalf("kill SIGUSR1: %v", err)
}
waitForLevel(t, slog.LevelDebug)

// DEBUG -> INFO
if err := syscall.Kill(syscall.Getpid(), syscall.SIGUSR1); err != nil {
t.Fatalf("kill SIGUSR1: %v", err)
}
waitForLevel(t, slog.LevelInfo)
}

// waitForLevel polls until the process log level reaches want, since the signal
// is handled asynchronously by the goroutine StartSignalToggle launched.
func waitForLevel(t *testing.T, want slog.Level) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if LogLevel() == want {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for level %v (still %v)", want, LogLevel())
}
Loading
Loading