A Reverse Proxy Is Defined by What It Refuses to Do

Five refusals in the nurago httpreverseproxy package: no followed redirects, no whole-request timeout, no base-path escapes, no 502 for a client that went away, and no logging of successful round trips.


The engine of a Go reverse proxy is net/http/httputil.ReverseProxy, and it is a good engine. What distinguishes one proxy deployment from another is almost entirely policy: what gets forwarded, what gets rewritten, what gets timed out, what gets logged. And in a proxy, the characteristic failure mode is helpfulness. Following a redirect is helpful; it is also a request-forgery vector. A whole-request timeout is helpful; it also truncates every long download. Logging everything is helpful; it also drowns the one entry that matters.

The httpreverseproxy package in nurago wraps ReverseProxy behind a small client, and its most instructive decisions are refusals. This post goes through them one at a time.

import "github.com/tecnickcom/nurago/pkg/httpreverseproxy"

proxy, err := httpreverseproxy.New("https://api.internal.example:8443/v2")
if err != nil {
    return err
}

// e.g. bound on a route registered as "/proxy/*path"
mux.Handle("/proxy/", http.HandlerFunc(proxy.ForwardRequest))

The default rewrite targets the configured upstream, appending the catch-all path route parameter to any base path the address carries (a differently named parameter is set with WithPathParam; with the wrong name the upstream path silently becomes /, which the documentation flags). It sets the outbound Host to the upstream host and the standard X-Forwarded-* headers. One trust note comes with that: the rewrite appends to inbound X-Forwarded-* values rather than replacing them, so those headers are only as trustworthy as the hop in front of the proxy.


It refuses to follow redirects

If an upstream answers 301, the helpful thing is to follow it and return the final response. For a proxy that is a Server-Side Request Forgery (SSRF) hazard: the upstream, or anyone able to influence its responses, would be choosing URLs for the proxy to fetch from inside the network. The default upstream client pins CheckRedirect to http.ErrUseLastResponse, so a 3xx is forwarded verbatim to the real client, which can decide for itself; a test pins the forwarding.

The refusal extends to the upstream address itself. url.Parse accepts strings like localhost:8080 without error by parsing the host as a scheme, leaving no host at all, so a proxy built on it would fail only at request time. New rejects any address without an http or https scheme and a host up front with ErrInvalidAddress, and uses only the scheme, host, and base path of what remains: userinfo and query strings in the configured address are dropped.


It refuses a whole-request timeout

An http.Client.Timeout on the upstream client would be the obvious safety net, and it would cap the entire exchange, response body included. A proxy forwards Server-Sent Events, long file downloads, and slow uploads; a whole-request timeout truncates all of them at an arbitrary point. So the default transport bounds only the wait for response headers (ResponseHeaderTimeout, one minute): a stuck upstream is detected, while a healthy stream flows for as long as it needs. A client that disconnects still cancels the upstream request through context propagation, so abandoned transfers do not run on.

The rest of the default transport mirrors the sibling httpclient tuning: a private clone of http.DefaultTransport, so per-proxy settings do not reach other consumers of the process-wide transport, with the per-host idle-connection pool raised from 2 to 100, since a reverse proxy by design concentrates traffic on a small set of upstream hosts. For latency-sensitive streaming of content types ReverseProxy does not already flush eagerly (it always flushes text/event-stream), WithFlushInterval adjusts the flush cadence, with a negative value flushing after each write.


It refuses paths that escape the base

When the upstream address carries a base path, say /v2, that prefix is the slice of the upstream the proxy is meant to expose. A request whose forwarded path resolves outside it through . and .. segments (/proxy/../admin) would quietly widen that exposure. By default the proxy resolves the outbound path with path.Clean and rejects anything that lands outside the base with HTTP 400, before the upstream is contacted, logging the inbound path under a distinct proxy_path_rejected message.

The boundaries of this defence are documented with some care, because a path check that overpromises is worse than none:

  • The check only decides; an accepted request is forwarded verbatim, trailing slash and in-bounds dot segments intact, so the upstream sees what the client sent and normalises it itself. The boundary therefore assumes the upstream resolves paths the same way path.Clean does.
  • Multiply percent-encoded traversal (%252e%252e) survives a single decode as a literal segment and is not caught, so untrusted-input defences belong at the upstream as well.
  • WithLaxBasePath switches the check off for pass-through deployments where the upstream is itself the authorisation boundary, and the check does not apply with a custom rewrite or an address with no base path.

A related normalisation happens at construction: the configured base path is cleaned once, since a raw base like /a/../b would otherwise never match any cleaned outbound path and every request would be rejected.


It refuses to call a client disconnect an upstream failure

When forwarding fails, ReverseProxy invokes the error handler, and the naive handler answers 502 and logs an error. But one common “failure” is nothing of the sort: the client went away before the upstream responded. Blaming the upstream for that pollutes error budgets and pages people for user behaviour.

The default error handler separates the two cases by inspecting the inbound request context, not the error value. That distinction is load-bearing: an upstream that exceeds ResponseHeaderTimeout also surfaces as a deadline error, while the client is still connected and deserves its 502, so the error’s type alone cannot tell the cases apart; the tests pin both. A genuine upstream failure logs at Error level and answers 502. A gone client logs at Info level, under a separate message, with the non-standard 499 code that nginx popularised, and writes nothing at all to a connection nobody is reading.


It refuses to log the successful case

Only transport failures and base-path rejections produce log entries here. A forwarded request whose upstream answered 404 or 500 produces none, which surprises people until the framing clicks: from the proxy’s point of view, that was a successful round trip. The upstream’s opinion of the request belongs to access logging, which has its own home: middleware around ForwardRequest, or ModifyResponse on a proxy supplied via WithReverseProxy.

What is logged is redacted first. The query string, and the URL embedded in a *url.Error, pass through the configured redaction function (the redact package’s shared redactor by default) so query-parameter secrets stay out of the logs; the request path is not redacted, and an error from a custom upstream client that does not wrap *url.Error is logged as-is, so custom clients redact their own messages.


It refuses to fight your configuration

New fills only what is unset. The default rewrite is installed only when the supplied proxy has neither Rewrite nor Director configured, and checking the deprecated Director is deliberate: ReverseProxy requires exactly one of the two, so installing a default Rewrite over a caller’s Director would fail every request. The default transport appears only when Transport is nil, the default error log and handler only when those are nil, and fields New does not touch (FlushInterval, ModifyResponse, a non-nil ErrorHandler) pass through as configured; tests pin the preserved cases. One adapter detail rounds it out: a custom upstream client is wrapped in a RoundTripper that clears RequestURI, which the standard library requires to be unset on client requests.


Each refusal has the same shape: the convenient behaviour is also the wrong one, in a way that only shows up in production, under an attacker, a slow stream, or an impatient user. A proxy earns trust not by doing more but by being precise about what it will not do, and saying so where you can read it.