Documentation
¶
Overview ¶
Package httpreverseproxy provides a reusable reverse-proxy client built on top of net/http/httputil.ReverseProxy.
Problem ¶
Service gateways and edge handlers often need to forward requests to upstream services while preserving consistent forwarding semantics, transport setup, and error observability. Rebuilding this plumbing for each proxy endpoint leads to drift and duplicated maintenance.
Solution ¶
This package wraps ReverseProxy behind a focused Client API:
- New configures proxy behavior from an upstream base address.
- Client.ForwardRequest forwards incoming HTTP requests to the target.
When no custom rewrite function is provided, requests are rewritten to the configured upstream URL and the wildcard `path` segment is forwarded as the proxied path. Standard `X-Forwarded-*` headers are set automatically.
Features ¶
- Rewrite-based upstream routing with sensible defaults.
- Pluggable reverse proxy instance via WithReverseProxy.
- Pluggable HTTP transport client via WithHTTPClient.
- Structured proxy error logging with request metadata and response timing.
- Default error handler returning HTTP 502 Bad Gateway on upstream failures.
- Middleware-friendly forwarding entry point for integration with routers.
Defaults and Behavior ¶
The default upstream client never follows redirects (3xx responses are forwarded verbatim, avoiding an SSRF vector) and uses a private, tuned transport that raises the per-host idle-connection pool and bounds only the wait for response headers (via ResponseHeaderTimeout). Because there is no whole-request timeout, streaming responses (Server-Sent Events, long downloads) and slow uploads are forwarded without being truncated; a disconnecting client still cancels the upstream request.
The default rewrite sets the outbound Host header to the upstream host and appends to any inbound `X-Forwarded-*` headers, so those should be trusted only behind a trusted hop. Only the scheme, host, and base path of the configured upstream address are used; any userinfo or query string in it is dropped. Percent-encoded reserved characters in the path (notably `%2F`) are decoded before forwarding, because routing operates on the decoded path.
When the address carries a base path, that base path acts as a boundary by default: a request whose path resolves outside it (via `.` / `..`) is rejected with HTTP 400 before the upstream is contacted. Pass WithLaxBasePath to restore transparent forwarding of `.` / `..` segments (a pass-through proxy where the upstream is the authorization boundary). The check is a no-op with a custom rewrite/director or when the address carries no base path.
The check resolves `.` / `..` (via path.Clean) only to make the accept/reject decision; a request that is accepted is still forwarded verbatim (its trailing slash and any in-bounds `.` / `..` segments are preserved for the upstream to normalize). The boundary therefore assumes the upstream resolves paths the same way, and it does not defend against multiply percent-encoded traversal (e.g. `%252e%252e`), which survives a single decode as a literal segment — put untrusted-input defenses at the upstream as well.
Observability Behavior ¶
The default error handler logs request method/path/query, trace ID, response code/status, request/response timing, and the underlying proxy error. The logged path and query are those of the outbound (rewritten) upstream request, since ReverseProxy passes the outbound request to the handler. The query and the URL embedded in the error are redacted via the configured WithRedactFn (default redact.HTTPDataString) so query-parameter secrets do not leak into logs. If request start time is present in context (via httputil request-time helpers), response duration is computed from it.
A genuine upstream failure is logged at Error level and answered with HTTP 502. When the client went away before the upstream responded (the inbound request context is canceled or its deadline elapsed), it is not an upstream fault: it is logged at Info level under a distinct message with the non-standard 499 code and no response is written to the abandoned connection.
Only transport failures and base-path rejections are logged; a successfully forwarded request — including one where the upstream returns a 4xx or 5xx response — produces no entry here, because that is a successful round trip. Add access logging by wrapping Client.ForwardRequest in middleware, or set ModifyResponse on a proxy passed via WithReverseProxy.
Benefits ¶
httpreverseproxy reduces reverse-proxy boilerplate, keeps forwarding behavior consistent across services, and improves operational visibility of upstream failures.
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ErrInvalidAddress = errors.New("httpreverseproxy: invalid upstream address")
ErrInvalidAddress is returned by New when the upstream base address used by the default rewrite lacks a http/https scheme or a host. It wraps the offending address for context.
Functions ¶
This section is empty.
Types ¶
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client implements the Reverse Proxy.
func New ¶
New constructs reverse proxy client forwarding requests to upstream address with default rewrite and X-Forwarded-* headers.
The default rewrite builds the upstream path from a catch-all route parameter named "path" (e.g. a route registered as "/proxy/*path"). If the router registers the catch-all under a different name, configure it via WithPathParam, otherwise the upstream path silently becomes "/".
The default rewrite is installed only when the supplied reverse proxy has neither a Rewrite nor a Director configured, so a custom WithReverseProxy using either mechanism is left intact (ReverseProxy rejects having both set). The upstream address is validated only when the default rewrite is used; an address without a http/https scheme or a host yields ErrInvalidAddress.
The default rewrite sets the outbound Host header to the upstream host and, via SetXForwarded, appends to any inbound X-Forwarded-* headers, so trust those only behind a trusted hop. Any userinfo or query string in the configured upstream address is dropped; only its scheme, host, and base path are used. Percent-encoded reserved characters in the path (notably %2F) are decoded before forwarding, because routing operates on the decoded path. When the address carries a base path, requests whose path resolves outside it (via "." / "..") are rejected with HTTP 400 by default; pass WithLaxBasePath to forward them verbatim instead.
func (*Client) CloseIdleConnections ¶
func (c *Client) CloseIdleConnections()
CloseIdleConnections closes any idle upstream connections held by the underlying transport. It is safe for concurrent use and no-ops for transports that do not implement the optional CloseIdleConnections method.
func (*Client) ForwardRequest ¶
func (c *Client) ForwardRequest(w http.ResponseWriter, r *http.Request)
ForwardRequest forwards HTTP request to configured upstream service via reverse proxy.
When the upstream address carries a base path, a request whose path would resolve outside that base path (e.g. via "..") is rejected with HTTP 400 before the upstream is contacted. Pass WithLaxBasePath to disable this check and forward such paths verbatim.
Example ¶
ExampleClient_ForwardRequest wires the reverse-proxy client to a catch-all route and forwards an incoming request to an upstream service. The catch-all parameter must be named "path" (the default) so the segment after "/proxy/" becomes the upstream path; use httpreverseproxy.WithPathParam to change the name.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"github.com/julienschmidt/httprouter"
"github.com/tecnickcom/nurago/pkg/httpreverseproxy"
)
func main() {
// Stand-in upstream service that echoes the path it received.
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The example intentionally reflects the request path to show how it is
// rewritten; a real upstream would not echo untrusted input.
fmt.Fprintf(w, "upstream received %s", r.URL.Path) //nolint:gosec // G705: illustrative echo
}))
defer upstream.Close()
client, err := httpreverseproxy.New(upstream.URL + "/v2")
if err != nil {
panic(err)
}
// Register the proxy under a catch-all route; httprouter injects the matched
// parameters into the request context that the default rewrite reads.
router := httprouter.New()
router.HandlerFunc(http.MethodGet, "/proxy/*path", client.ForwardRequest)
edge := httptest.NewServer(router)
defer edge.Close()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, edge.URL+"/proxy/users", nil)
if err != nil {
panic(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))
}
Output: upstream received /v2/users
type HTTPClient ¶
HTTPClient is the transport used by Client for outgoing proxied requests.
type Option ¶
type Option func(c *Client)
Option is the interface that allows to set client options.
func WithFlushInterval ¶
WithFlushInterval sets httputil.ReverseProxy.FlushInterval, the flush cadence when copying the upstream response body to the client.
A negative value flushes immediately after each write (useful for low-latency streaming of content types ReverseProxy does not already flush eagerly; it always flushes text/event-stream). The zero default leaves ReverseProxy's behavior unchanged and does not override a value set via WithReverseProxy.
func WithHTTPClient ¶
func WithHTTPClient(h HTTPClient) Option
WithHTTPClient customizes HTTP client for upstream forwarding requests.
func WithLaxBasePath ¶
func WithLaxBasePath() Option
WithLaxBasePath disables the default base-path boundary check, forwarding "." and ".." path segments verbatim.
By default, when the upstream address carries a base path, a proxied request whose path resolves outside that base path is rejected with HTTP 400 before the upstream is contacted, so the base path acts as a boundary. This option restores transparent forwarding: use it only for a pass-through proxy where the upstream itself is the authorization boundary, since a client can then traverse to sibling upstream paths.
It only affects the default rewrite with a non-empty base path; it is a no-op with a custom rewrite/director or when the address carries no base path.
func WithLogger ¶
WithLogger overrides default logger for proxy event logging.
func WithPathParam ¶
WithPathParam sets the name of the catch-all route parameter that holds the upstream path used by the default rewrite (e.g. "path" for a route registered as "/proxy/*path"). An empty value falls back to the default ("path").
It has no effect when a custom rewrite is provided via WithReverseProxy.
func WithRedactFn ¶
WithRedactFn customizes redaction of sensitive data written to logs, namely the request query string and the upstream URL embedded in error entries.
A nil fn is ignored, keeping the default redaction, so the option can never install a nil function that would panic while logging. fn may be called concurrently by simultaneous forwarded requests, so it must be safe for concurrent use. The default redact.HTTPDataString is.
func WithReverseProxy ¶
func WithReverseProxy(p *httputil.ReverseProxy) Option
WithReverseProxy replaces the default httputil.ReverseProxy.
New auto-configures only the fields left unset: it installs the default rewrite when both Rewrite and Director are nil, a default upstream transport when Transport is nil, and a default ErrorLog/ErrorHandler when those are nil. Provide a Rewrite or a Director (never both, which ReverseProxy rejects) to fully control request rewriting; leave them nil for the default upstream routing. Advanced fields New never touches — FlushInterval, ModifyResponse, and a non-nil ErrorHandler — are honored as configured, so pass a fully-built proxy to customize response streaming or mutation.