Skip to content

TLS and Listener Limits

wiki edited this page Sep 4, 2026 · 1 revision

TLS and listener limits

TLS is opt-in

Resolution order, per router:

  1. ListenSSL == falseplain HTTP, whatever else is set. The router logs a warning if certificates are configured but unused.
  2. TLSConfig != nil → used verbatim. CertFile/KeyFile are ignored.
  3. CertFile and KeyFile both set → TLS from those files.
  4. Anything else with ListenSSL enabled is an error — not a silent downgrade to plaintext.
certFile, keyFile := "cert.pem", "key.pem"

app := rex.New(rex.WithConfig(&rex.Config{
	DefaultRouter: rex.RouterConfig{
		Addr:      ":443",
		ListenSSL: true,
		CertFile:  &certFile,
		KeyFile:   &keyFile,
	},
}))

TLSConfig: rotation and mTLS

TLSConfig is the injection point for everything the two file paths cannot express.

Certificate rotation without a restart — set GetCertificate, and the listener calls it once per handshake instead of reading a file once at start:

TLSConfig: &tls.Config{
	GetCertificate: certManager.GetCertificate,
	MinVersion:     tls.VersionTLS12,
}

Client certificate verification (mTLS) — this is the only place it is configured:

TLSConfig: &tls.Config{
	Certificates: []tls.Certificate{serverCert},
	ClientAuth:   tls.RequireAndVerifyClientCert,
	ClientCAs:    caPool,
}

SSLVerify was removed, not deprecated. It configured nothing — the value was stored on the router and never read, and there was no code path that could have used it. The name invited the mistake more than once: InsecureSkipVerify is read by tls.Client, never tls.Server, so a listener has no such setting. What people reached for it expecting is ClientAuth plus ClientCAs, above. That always worked; SSLVerify was a second door onto the same room, and it did not open.

Listener limits

net/http applies no timeouts and no body limit of its own. Left unset, every one of these is effectively unlimited: a client holding a connection open without completing its request headers occupies a goroutine for as long as it cares to, and a request body is read until the client stops sending.

rex fills them in. Zero takes the default; negative disables.

Field Default Bounds
ReadHeaderTimeout 10s reading request headers
ReadTimeout 30s reading the whole request
WriteTimeout 0 — unset writing the response (absolute)
IdleTimeout 120s an unused keep-alive connection
MaxHeaderBytes 1 MiB total header size
MaxHeaderValueCount 0 → net/http's 500 number of header values
MaxBodyBytes 4 MiB request body size

ReadHeaderTimeout closes Slowloris

Slowloris works by trickling headers so the request never completes and the connection is never released. This is the field that stops it, and it is the reason the framework sets a default rather than leaving the decision to you.

WriteTimeout is deliberately unset

No framework default fills it in. WriteTimeout is an absolute deadline on the whole response, not an idle timeout — so any non-zero value truncates responses that are legitimately long-lived: server-sent events, long polling, large downloads, and any slow client on a fast endpoint.

Slowloris is a read attack, already closed by ReadHeaderTimeout, so setting this buys no protection that is not already in place. Set it only on a router you know serves nothing streaming.

MaxHeaderValueCount catches what a byte cap does not

A few thousand tiny headers stay well inside 1 MiB while still forcing the server to allocate and hash every one of them. net/http applies its own limit of 500 whether or not this is set, so leaving it zero is already safe — the field exists so the limit is visible and adjustable alongside the others rather than being the one bound nothing in the configuration mentions.

Body limits, per route

The router enforces the cap two ways, because one is not enough:

  1. A declared Content-Length over the cap is rejected without reading a byte — a clean 413 carrying max_bytes, so a client that knows the limit can chunk or compress rather than guess.
  2. http.MaxBytesReader covers chunked bodies and clients that under-declare. The read fails once the cap is passed, so a handler decoding the body gets an error rather than buffering whatever the client cared to send.

A route overrides its router's limit by implementing BodyLimitedRoute:

func (r *UploadRoute) MaxBodyBytes() int64 { return 64 << 20 } // 64 MiB
func (r *IngestRoute) MaxBodyBytes() int64 { return -1 }       // streaming, no cap

The value is read once, when the route table is built, not per request.

Sensible per-router shapes

// Public API.
rex.RouterConfig{
	Addr:              ":443",
	ListenSSL:         true,
	TLSConfig:         tlsCfg,
	ReadHeaderTimeout: 5 * time.Second,
	ReadTimeout:       20 * time.Second,
	IdleTimeout:       60 * time.Second,
	MaxBodyBytes:      1 << 20,
}

// Operational listener: no bodies worth speaking of, internal only.
rex.RouterConfig{
	Addr:         "127.0.0.1:9091",
	ReadTimeout:  5 * time.Second,
	MaxBodyBytes: 4 << 10,
}

// Upload listener: long reads, big bodies, and no WriteTimeout.
rex.RouterConfig{
	Addr:         ":8081",
	ReadTimeout:  10 * time.Minute,
	MaxBodyBytes: 512 << 20,
}

Clone this wiki locally