snapshot of https://github.com/go-chi/chi.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Test / test (1.24.x, ubuntu-latest) (push) Successful in 2m56s
Test / test (1.25.x, ubuntu-latest) (push) Successful in 2m15s
Test / test (1.26.x, ubuntu-latest) (push) Successful in 2m23s
Test / test (1.27.x, ubuntu-latest) (push) Failing after 3m56s
Test / test (1.24.x, windows-latest) (push) Has been cancelled
Test / test (1.25.x, windows-latest) (push) Has been cancelled
Test / test (1.26.x, windows-latest) (push) Has been cancelled
Test / test (1.27.x, windows-latest) (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 21:48:17 +05:00
commit 72c9f2ee75
105 changed files with 14011 additions and 0 deletions

33
middleware/basic_auth.go Normal file
View File

@@ -0,0 +1,33 @@
package middleware
import (
"crypto/subtle"
"fmt"
"net/http"
)
// BasicAuth implements a simple middleware handler for adding basic http auth to a route.
func BasicAuth(realm string, creds map[string]string) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok {
basicAuthFailed(w, realm)
return
}
credPass, credUserOk := creds[user]
if !credUserOk || subtle.ConstantTimeCompare([]byte(pass), []byte(credPass)) != 1 {
basicAuthFailed(w, realm)
return
}
next.ServeHTTP(w, r)
})
}
}
func basicAuthFailed(w http.ResponseWriter, realm string) {
w.Header().Add("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, realm))
w.WriteHeader(http.StatusUnauthorized)
}

View File

@@ -0,0 +1,59 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
var testAuthCreds = map[string]string{
"testUser": "testPassword",
}
func TestBasicAuth(t *testing.T) {
endpoint := "/secure"
r := chi.NewRouter()
r.Use(BasicAuth("localhost", testAuthCreds))
r.Get(endpoint, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("authentication accepted"))
}))
cases := []struct {
name string
digest string
expected int
}{
{
name: "no authorization header",
expected: http.StatusUnauthorized,
},
{
name: "invalid basic auth credentials",
digest: "Basic dGVzdFVzZXI6d3JvbmdwYXNzd29yZA==",
expected: http.StatusUnauthorized,
},
{
name: "valid basic auth credentials",
digest: "Basic dGVzdFVzZXI6dGVzdFBhc3N3b3Jk",
expected: http.StatusOK,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, endpoint, http.NoBody)
if c.digest != "" {
req.Header.Set("Authorization", c.digest)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if got := w.Code; got != c.expected {
t.Errorf("status code = %d, expected %d", got, c.expected)
}
})
}
}

28
middleware/clean_path.go Normal file
View File

@@ -0,0 +1,28 @@
package middleware
import (
"net/http"
"path"
"github.com/go-chi/chi/v5"
)
// CleanPath middleware will clean out double slash mistakes from a user's request path.
// For example, if a user requests /users//1 or //users////1 will both be treated as: /users/1
func CleanPath(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rctx := chi.RouteContext(r.Context())
routePath := rctx.RoutePath
if routePath == "" {
if r.URL.RawPath != "" {
routePath = r.URL.RawPath
} else {
routePath = r.URL.Path
}
rctx.RoutePath = path.Clean(routePath)
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,23 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
func TestCleanPath(t *testing.T) {
r := chi.NewRouter()
r.Use(CleanPath)
r.Get("/users/1", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/users////1", nil)
r.ServeHTTP(w, req)
if w.Result().StatusCode != http.StatusOK {
t.Errorf("clean path: unexpected response code: %v", w.Result().StatusCode)
}
}

274
middleware/client_ip.go Normal file
View File

@@ -0,0 +1,274 @@
package middleware
import (
"context"
"net"
"net/http"
"net/netip"
"slices"
"strings"
)
// clientIPCtxKey stores the client IP set by any of the ClientIPFrom* middlewares.
var clientIPCtxKey = &contextKey{"clientIP"}
// xForwardedForHeader is the canonical form of the X-Forwarded-For header
// name, used by the XFF-based middlewares.
const xForwardedForHeader = "X-Forwarded-For"
// ClientIPFromHeader stores the client IP from a single-IP header set by
// your reverse proxy. Read it with [GetClientIP].
//
// Only safe with headers your proxy unconditionally OVERWRITES on every
// request, e.g.:
//
// - X-Real-IP — Nginx with ngx_http_realip_module
// - X-Client-IP — Apache with mod_remoteip
// - CF-Connecting-IP — Cloudflare
//
// True-Client-IP, X-Azure-ClientIP, and Fastly-Client-IP look similar but
// pass through from the client by default in those products; don't use them
// unless your edge strips the inbound value.
//
// If the header reaches us with multiple values (misconfigured proxy that
// appends, or a downstream proxy not stripping a client-supplied value),
// the LAST value wins — that's the one set by the hop closest to us, and
// therefore the most trusted. Fail-closed if the last value doesn't parse:
// no client IP is set rather than falling back to earlier (less-trusted)
// values.
//
// v4-mapped IPv6 (::ffff:a.b.c.d) folds to plain v4 and IPv6 zones are
// stripped before storage.
func ClientIPFromHeader(trustedHeader string) func(http.Handler) http.Handler {
header := http.CanonicalHeaderKey(trustedHeader)
return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
values := r.Header.Values(header)
if len(values) > 0 {
if ip, ok := parseHeaderAddr(values[len(values)-1]); ok {
r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, ip))
}
}
h.ServeHTTP(w, r)
})
}
}
// ClientIPFromXFF stores the client IP read from the X-Forwarded-For header,
// walking the chain right-to-left and skipping any IP that falls within one
// of the given trusted CIDR prefixes. The first IP that is not trusted is
// the client. Read it with [GetClientIP].
//
// An unparseable entry mid-chain aborts the walk and leaves no client IP
// set (fail-closed) — we can't safely trust anything left of garbage.
//
// Use this when you sit behind one or more reverse proxies whose IP ranges
// you can enumerate as CIDRs. Most CDNs publish their IPs:
//
// Cloudflare: https://www.cloudflare.com/ips/
// AWS: https://ip-ranges.amazonaws.com/ip-ranges.json
// Fastly: https://api.fastly.com/public-ip-list
// Google Cloud: https://www.gstatic.com/ipranges/cloud.json
//
// Example (CloudFront):
//
// r.Use(middleware.ClientIPFromXFF(
// "13.32.0.0/15", // CloudFront IPv4
// "52.46.0.0/18", // CloudFront IPv4
// "2600:9000::/28", // CloudFront IPv6
// ))
//
// Calling with no arguments returns the rightmost XFF entry, or no IP if
// that entry doesn't parse (fail-closed) — safe only if you have exactly
// one trusted hop directly in front of this server (e.g., nginx on localhost).
//
// v4-mapped IPv6 (::ffff:a.b.c.d) folds to plain v4 and IPv6 zones are
// stripped before the prefix check and storage; otherwise an attacker
// could use either notation to alias a trusted IP past the check.
//
// If you know the number of trusted proxies but not their IPs, use
// [ClientIPFromXFFTrustedProxies] instead.
//
// Panics at startup if any prefix is invalid.
func ClientIPFromXFF(trustedIPPrefixes ...string) func(http.Handler) http.Handler {
prefixes := make([]netip.Prefix, len(trustedIPPrefixes))
for i, p := range trustedIPPrefixes {
prefixes[i] = netip.MustParsePrefix(p)
}
return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var found netip.Addr
walkXFF(r.Header[xForwardedForHeader], func(v string) bool {
ip, ok := parseHeaderAddr(v)
if !ok {
return true // fail-closed; leave found unset
}
if inAnyPrefix(ip, prefixes) {
return false // trusted hop; keep walking left
}
found = ip
return true
})
if found.IsValid() {
r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, found))
}
h.ServeHTTP(w, r)
})
}
}
// ClientIPFromXFFTrustedProxies stores the client IP read from
// X-Forwarded-For, given the exact number of trusted reverse proxies
// between this server and the public internet. Read it with [GetClientIP].
//
// PREFER [ClientIPFromXFF] with explicit CIDRs whenever you can — it
// cannot off-by-one and is robust to architecture changes. Most CDNs
// publish their IP ranges (Cloudflare, AWS, Fastly, Google Cloud). Use
// this counting variant only when proxy IPs are dynamic and unpublishable.
//
// numTrustedProxies = total proxy hops between the client and this server.
// Count every hop in the request path:
//
// Single proxy (one LB / nginx / Heroku / Fly.io / Render) ....... 1
// Two proxies (Cloudflare → ALB, CloudFront → ALB) .............. 2
// Three proxies (CDN → API gateway → LB) ......................... 3
//
// VERIFY BEFORE GOING LIVE: send a request from a known IP and confirm
// [GetClientIP] returns that IP. If it returns a proxy IP, your count is
// too LOW — a client can spoof their IP, fix immediately. If it returns
// "", your count is too HIGH — no leak, but no client IP either.
//
// This middleware reads ONLY X-Forwarded-For; it does not inspect
// r.RemoteAddr. Guarantee at the network layer (security group / firewall)
// that only your proxies can reach this server.
//
// If the XFF chain has fewer than numTrustedProxies entries, no client IP
// is set (fail-closed). Like [ClientIPFromXFF], v4-mapped IPv6 folds to v4
// and IPv6 zones are stripped before storage.
//
// Panics at startup if numTrustedProxies < 1.
func ClientIPFromXFFTrustedProxies(numTrustedProxies int) func(http.Handler) http.Handler {
if numTrustedProxies < 1 {
panic("middleware.ClientIPFromXFFTrustedProxies: numTrustedProxies must be >= 1")
}
return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := numTrustedProxies
var entry string
walkXFF(r.Header[xForwardedForHeader], func(v string) bool {
n--
if n == 0 {
entry = v
return true
}
return false
})
if entry != "" {
if ip, ok := parseHeaderAddr(entry); ok {
r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, ip))
}
}
h.ServeHTTP(w, r)
})
}
}
// ClientIPFromRemoteAddr stores the client IP read from the TCP RemoteAddr
// of the incoming request — the IP address of whoever opened the connection
// to this server. Read it with [GetClientIP].
//
// Use this when this server is directly connected to the public internet
// with NO reverse proxy in front of it. Behind a reverse proxy, RemoteAddr
// is the proxy's IP, not the client's — use [ClientIPFromHeader] or
// [ClientIPFromXFF] instead.
//
// IPv4 clients on a dual-stack listener surface as ::ffff:a.b.c.d; they
// fold to plain v4 before storage so one logical client maps to one key.
// IPv6 zones are preserved (link-local connections may legitimately have one).
func ClientIPFromRemoteAddr(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr // RemoteAddr may already be a bare IP (e.g. in tests).
}
if ip, err := netip.ParseAddr(host); err == nil {
r = r.WithContext(context.WithValue(r.Context(), clientIPCtxKey, ip.Unmap()))
}
h.ServeHTTP(w, r)
})
}
// GetClientIP returns the client IP as a string, as set by one of the
// ClientIPFrom* middlewares. Returns "" if no valid IP was set.
// Convenient for logging, rate-limit keys, etc.
func GetClientIP(ctx context.Context) string {
ip := GetClientIPAddr(ctx)
if !ip.IsValid() {
return ""
}
return ip.String()
}
// GetClientIPAddr returns the client IP as a [netip.Addr], as set by one of
// the ClientIPFrom* middlewares. The returned Addr is the zero value if not
// set; use [netip.Addr.IsValid] to check. Useful when you need typed work —
// prefix containment, Is4/Is6, etc. — without re-parsing the string.
func GetClientIPAddr(ctx context.Context) netip.Addr {
ip, _ := ctx.Value(clientIPCtxKey).(netip.Addr)
return ip
}
// walkXFF walks the entries of the merged X-Forwarded-For chain
// RIGHT-TO-LEFT, invoking visit on each trimmed non-empty entry. visit
// returns true to stop the walk. Lazy walk, zero allocations (entries
// are substrings of the input headers).
//
// Multiple XFF headers are merged per RFC 2616 — each header's
// comma-separated entries in order received — so an attacker cannot pick
// which value security logic sees by sending a duplicate header.
func walkXFF(headers []string, visit func(entry string) bool) {
for _, h := range slices.Backward(headers) {
for h != "" {
var v string
if i := strings.LastIndexByte(h, ','); i >= 0 {
v, h = h[i+1:], h[:i]
} else {
v, h = h, ""
}
v = strings.TrimSpace(v)
if v == "" {
continue
}
if visit(v) {
return
}
}
}
}
// inAnyPrefix reports whether ip falls within any of the given prefixes.
func inAnyPrefix(ip netip.Addr, prefixes []netip.Prefix) bool {
for _, p := range prefixes {
if p.Contains(ip) {
return true
}
}
return false
}
// parseHeaderAddr parses s and normalizes for storage: v4-mapped IPv6
// (::ffff:a.b.c.d) folds to plain v4, IPv6 zone is stripped. Both defend the
// trust-prefix check against attacker-injected aliases — [netip.Prefix.Contains]
// returns false for v4-mapped addresses vs v4 prefixes and for any zoned
// address, so without folding/stripping an attacker could escape an
// otherwise valid trust list.
//
// Header-sourced IPs only. [ClientIPFromRemoteAddr] normalizes inline
// (Unmap, but zone preserved for legitimate link-local connections).
func parseHeaderAddr(s string) (netip.Addr, bool) {
ip, err := netip.ParseAddr(s)
if err != nil {
return netip.Addr{}, false
}
return ip.Unmap().WithZone(""), true
}

View File

@@ -0,0 +1,58 @@
package middleware
import (
"fmt"
"strings"
"testing"
)
// BenchmarkWalkXFF measures the cost of walking a merged X-Forwarded-For
// chain end-to-end (visitor never stops). Exercises the worst case for
// ClientIPFromXFF when every entry is in the trusted prefix list, and
// bounds the absolute cost of a pathologically large attacker-supplied
// XFF header (subject to http.Server.MaxHeaderBytes).
//
// n varies the number of comma-separated entries in a single XFF header.
// All entries are valid IPs of identical length, so total header size
// scales linearly with n.
func BenchmarkWalkXFF(b *testing.B) {
for _, n := range []int{1, 10, 100, 1000, 10000} {
entries := make([]string, n)
for i := range entries {
entries[i] = "1.2.3.4"
}
headers := []string{strings.Join(entries, ", ")}
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
walkXFF(headers, func(entry string) bool {
return false // walk to the leftmost entry
})
}
})
}
}
// BenchmarkWalkXFF_RightmostStop measures the common case for both
// ClientIPFromXFF (no trusted prefixes => stop at the first / rightmost
// entry) and ClientIPFromXFFTrustedProxies(1). Should be near-constant
// regardless of n, since the walker stops after the first visit.
func BenchmarkWalkXFF_RightmostStop(b *testing.B) {
for _, n := range []int{1, 10, 100, 1000, 10000} {
entries := make([]string, n)
for i := range entries {
entries[i] = "1.2.3.4"
}
headers := []string{strings.Join(entries, ", ")}
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
walkXFF(headers, func(entry string) bool {
return true // stop at the rightmost entry
})
}
})
}
}

View File

@@ -0,0 +1,108 @@
package middleware_test
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// Example_clientIP shows how to choose a ClientIPFrom* middleware. There is
// no safe default — pick exactly ONE based on your network setup:
//
// - [middleware.ClientIPFromRemoteAddr] — this server is directly on the
// public internet, with no reverse proxy in front.
//
// - [middleware.ClientIPFromHeader] — your reverse proxy sets a dedicated
// single-IP header (X-Real-IP, CF-Connecting-IP, X-Client-IP) and
// overwrites any client-supplied value for every request.
//
// - [middleware.ClientIPFromXFF] — you sit behind one or more reverse
// proxies whose IP ranges you can enumerate (your VPC CIDR, Cloudflare's
// published IP list, etc.).
//
// - [middleware.ClientIPFromXFFTrustedProxies] — you sit behind a known,
// fixed number of reverse proxies whose IPs are dynamic (autoscaling
// pools, ephemeral containers).
//
// Read the resulting IP with [middleware.GetClientIP] (string) or
// [middleware.GetClientIPAddr] ([net/netip.Addr]). These middlewares never
// mutate [net/http.Request.RemoteAddr].
//
// The legacy [middleware.RealIP] middleware is deprecated; it is vulnerable
// to IP spoofing (GHSA-3fxj-6jh8-hvhx, GHSA-rjr7-jggh-pgcp, GHSA-9g5q-2w5x-hmxf).
//
// Background:
// https://github.com/go-chi/chi/pull/967
// https://adam-p.ca/blog/2022/03/x-forwarded-for/
func Example_clientIP() {
r := chi.NewRouter()
// Pick ONE; the others are shown commented for reference.
// (1) Behind a reverse proxy that sets a single-IP header for every
// request and overwrites any client-supplied value (Cloudflare, Nginx
// with ngx_http_realip_module, Apache with mod_remoteip, ...).
r.Use(middleware.ClientIPFromHeader("CF-Connecting-IP"))
// (2) Behind reverse proxies whose IP ranges you can enumerate as CIDRs.
// The middleware walks X-Forwarded-For right-to-left, skipping trusted
// entries; fail-closed on garbage.
//
// r.Use(middleware.ClientIPFromXFF(
// "13.32.0.0/15", // CloudFront IPv4.
// "2600:9000::/28", // CloudFront IPv6.
// ))
// (3) Behind a known number of trusted reverse proxies whose IPs are
// dynamic.
//
// r.Use(middleware.ClientIPFromXFFTrustedProxies(2))
// (4) Directly on the public internet, no reverse proxy in front.
//
// r.Use(middleware.ClientIPFromRemoteAddr)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, middleware.GetClientIP(r.Context()))
})
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("CF-Connecting-IP", "198.51.100.42")
req.Header.Set("X-Forwarded-For", "1.2.3.4") // attacker-supplied; ignored.
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
fmt.Print(w.Body.String())
// Output: 198.51.100.42
}
// Example_clientIPFromXFFTrustedProxies shows that with two proxies the
// client sits at XFF[len-2] and any client-prepended entries to the left of
// that position are ignored.
func Example_clientIPFromXFFTrustedProxies() {
r := chi.NewRouter()
r.Use(middleware.ClientIPFromXFFTrustedProxies(2)) // P1 (inner) + P2 (outer).
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, middleware.GetClientIP(r.Context()))
})
// Each chain ends in "<client>, <P1>"; P2's address is in RemoteAddr,
// not in X-Forwarded-For. Leading entries are client-controlled.
for _, xff := range []string{
"109.81.118.93, 3.172.119.73",
"8.8.8.8, 109.81.118.93, 3.172.119.73",
"8.8.8.8, 8.8.8.8, 8.8.8.8, 109.81.118.93, 3.172.119.73",
} {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Forwarded-For", xff)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
fmt.Print(w.Body.String())
}
// Output:
// 109.81.118.93
// 109.81.118.93
// 109.81.118.93
}

View File

@@ -0,0 +1,737 @@
package middleware
import (
"net/http"
"net/http/httptest"
"net/netip"
"testing"
"github.com/go-chi/chi/v5"
)
func TestClientIPFromHeader(t *testing.T) {
tt := []struct {
name string
in string
out string
}{
{"empty", "", ""},
{"ipv4", "100.100.100.100", "100.100.100.100"},
{"ipv6_canonical", "2345:425:2ca1::567:5673:23b5", "2345:425:2ca1::567:5673:23b5"},
{"ipv6_uncompressed_normalized", "2345:0425:2CA1:0000:0000:0567:5673:23B5", "2345:425:2ca1::567:5673:23b5"},
// netip.ParseAddr rejects non-IPs and embedded ports.
{"invalid", "invalid", ""},
{"ipv4_with_port", "100.100.100.100:80", ""},
{"multiple_ips", "100.100.100.100,200.200.200.200", ""},
// Per the blog and the three GHSAs: we trust the user's choice of header.
// If their infra writes a private/loopback IP here, that IS the answer.
{"loopback_accepted", "127.0.0.1", "127.0.0.1"},
{"private_v4_accepted", "10.0.1.10", "10.0.1.10"},
{"private_v6_accepted", "fc00::1", "fc00::1"},
{"unspecified_accepted", "0.0.0.0", "0.0.0.0"},
// Normalization: v4-mapped IPv6 folds to plain v4, IPv6 zone is stripped.
// Same logical client → same canonical string regardless of upstream notation.
{"v4_mapped_ipv6_folded_to_v4", "::ffff:1.2.3.4", "1.2.3.4"},
{"ipv6_zone_stripped", "2001:db8::1%eth0", "2001:db8::1"},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
got := run(t, ClientIPFromHeader("X-Real-IP"), func(r *http.Request) {
if tc.in != "" {
r.Header.Set("X-Real-IP", tc.in)
}
})
if got != tc.out {
t.Errorf("want %q, got %q", tc.out, got)
}
})
}
}
func TestClientIPFromXFF_NoTrustedPrefixes(t *testing.T) {
tt := []struct {
name string
xff []string
out string
}{
{"missing", nil, ""},
{"empty", []string{""}, ""},
{"single", []string{"100.100.100.100"}, "100.100.100.100"},
{"comma_space", []string{"1.1.1.1, 2.2.2.2"}, "2.2.2.2"},
{"comma_no_space", []string{"1.1.1.1,2.2.2.2"}, "2.2.2.2"},
{"multi_header_merged", []string{"1.1.1.1", "2.2.2.2"}, "2.2.2.2"},
{"multi_header_with_commas", []string{"5.5.5.5, 6.6.6.6", "7.7.7.7, 4.4.4.4"}, "4.4.4.4"},
{"ipv6", []string{"2001:db8::1"}, "2001:db8::1"},
{"mixed_v4_v6_rightmost_wins", []string{"203.0.113.1, 2001:db8::1"}, "2001:db8::1"},
// Normalization at the entry point: v4-mapped IPv6 folds to v4, zone stripped.
{"v4_mapped_rightmost_folded", []string{"::ffff:1.2.3.4"}, "1.2.3.4"},
{"zone_stripped_rightmost", []string{"2001:db8::1%eth0"}, "2001:db8::1"},
// A header like "oh, hi,,127.0.0.1,,,," can be injected by the client.
// The walker reaches 127.0.0.1 from the right before encountering the
// unparseable tokens, so fail-closed never trips here.
// See https://adam-p.ca/blog/2022/03/x-forwarded-for/ for more details.
{"weird_with_empties_then_valid_rightmost", []string{"oh, hi,,127.0.0.1,,,,"}, "127.0.0.1"},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
got := run(t, ClientIPFromXFF(), func(r *http.Request) {
for _, v := range tc.xff {
r.Header.Add("X-Forwarded-For", v)
}
})
if got != tc.out {
t.Errorf("want %q, got %q", tc.out, got)
}
})
}
}
func TestClientIPFromXFF_TrustedPrefixes(t *testing.T) {
tt := []struct {
name string
prefixes []string
xff []string
out string
}{
{
name: "single_trusted_proxy_skipped",
prefixes: []string{"203.0.113.0/24"},
xff: []string{"100.100.100.100, 203.0.113.50"},
out: "100.100.100.100",
},
{
name: "multiple_trusted_proxies_skipped",
prefixes: []string{"203.0.113.0/24", "198.51.100.0/24"},
xff: []string{"1.1.1.1, 203.0.113.10, 198.51.100.5"},
out: "1.1.1.1",
},
{
name: "all_trusted_returns_empty",
prefixes: []string{"203.0.113.0/24"},
xff: []string{"203.0.113.10, 203.0.113.20"},
out: "",
},
{
name: "ipv6_trusted_range",
prefixes: []string{"2606:4700::/32"},
xff: []string{"2001:db8::1, 2606:4700::1"},
out: "2001:db8::1",
},
{
name: "mixed_v4_v6_trust_list",
prefixes: []string{"2606:4700::/32", "203.0.113.0/24"},
xff: []string{"8.8.8.8, 2606:4700::1, 203.0.113.5"},
out: "8.8.8.8",
},
// Adam-P's blog and rezmoss's advisory: we must NOT filter private/loopback
// values when they sit between trusted proxies. K8s nginx-ingress legitimately
// produces this shape.
{
name: "private_between_trusted_is_client",
prefixes: []string{"10.244.0.0/24"},
xff: []string{"10.244.1.50, 10.244.0.10"},
out: "10.244.1.50",
},
// /24 boundary tests (Saku0512's PR #1087).
{
name: "boundary_first_addr_in_prefix",
prefixes: []string{"203.0.113.0/24"},
xff: []string{"100.100.100.100, 203.0.113.0"},
out: "100.100.100.100",
},
{
name: "boundary_last_addr_in_prefix",
prefixes: []string{"203.0.113.0/24"},
xff: []string{"100.100.100.100, 203.0.113.255"},
out: "100.100.100.100",
},
{
name: "ip_just_outside_prefix_is_client",
prefixes: []string{"203.0.113.0/24"},
xff: []string{"203.0.114.1, 203.0.113.1"},
out: "203.0.114.1",
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
got := run(t, ClientIPFromXFF(tc.prefixes...), func(r *http.Request) {
for _, v := range tc.xff {
r.Header.Add("X-Forwarded-For", v)
}
})
if got != tc.out {
t.Errorf("want %q, got %q", tc.out, got)
}
})
}
}
func TestClientIPFromXFF_PanicsOnBadPrefix(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic on invalid CIDR")
}
}()
ClientIPFromXFF("not-a-cidr")
}
func TestClientIPFromXFFTrustedProxies(t *testing.T) {
tt := []struct {
name string
n int
xff []string
out string
}{
// N=1 ⇒ rightmost (equivalent to ClientIPFromXFF() with no prefixes).
{"n1_rightmost", 1, []string{"1.1.1.1, 2.2.2.2"}, "2.2.2.2"},
{"n2_second_from_right", 2, []string{"1.1.1.1, 2.2.2.2, 3.3.3.3"}, "2.2.2.2"},
{"n3_third_from_right", 3, []string{"1.1.1.1, 2.2.2.2, 3.3.3.3, 4.4.4.4"}, "2.2.2.2"},
{"n2_exactly_matches_len", 2, []string{"1.1.1.1, 2.2.2.2"}, "1.1.1.1"},
// XFF shorter than N: no IP set. This is intentionally fail-closed so a
// proxy-count mismatch doesn't silently fall through to attacker-controlled
// values. The index len(xff)-N is negative/out-of-range here; the walk
// must never panic or wrap around to an entry the client controls.
{"shorter_than_n", 3, []string{"1.1.1.1, 2.2.2.2"}, ""},
{"single_entry_n_two", 2, []string{"2.2.2.2"}, ""},
{"missing_header", 1, nil, ""},
// Spoofing: prepended attacker values are to the LEFT of the chosen
// position, so they're ignored.
{"spoof_prepend_ignored", 2, []string{"6.6.6.6, 1.1.1.1, 2.2.2.2, 3.3.3.3"}, "2.2.2.2"},
// Bad parse at the target position: no IP set (don't accept garbage as client IP).
{"bad_parse_at_position", 2, []string{"garbage, 2.2.2.2"}, ""},
// Merging across multiple header instances.
{"multi_header", 2, []string{"1.1.1.1", "2.2.2.2", "3.3.3.3"}, "2.2.2.2"},
// Empty entries dropped by the walker must not shift the chosen
// position. The position is computed from the right, where trusted
// proxies append, so an attacker prepending commas can't slide their
// chosen value into the trusted position.
{"empties_dont_shift_position", 2, []string{",,, 3.3.3.3, 1.1.1.1, 2.2.2.2"}, "1.1.1.1"},
// Normalization at the chosen position: v4-mapped IPv6 folds to v4, zone stripped.
{"v4_mapped_at_position_folded", 1, []string{"::ffff:1.2.3.4"}, "1.2.3.4"},
{"zone_stripped_at_position", 1, []string{"2001:db8::1%eth0"}, "2001:db8::1"},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
got := run(t, ClientIPFromXFFTrustedProxies(tc.n), func(r *http.Request) {
for _, v := range tc.xff {
r.Header.Add("X-Forwarded-For", v)
}
})
if got != tc.out {
t.Errorf("want %q, got %q", tc.out, got)
}
})
}
}
func TestClientIPFromXFFTrustedProxies_PanicsOnZero(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic on numTrustedProxies < 1")
}
}()
ClientIPFromXFFTrustedProxies(0)
}
func TestClientIPFromRemoteAddr(t *testing.T) {
tt := []struct {
name string
remoteAddr string
out string
}{
{"ipv4_with_port", "192.0.2.1:1234", "192.0.2.1"},
{"ipv6_with_port", "[2001:db8::1]:1234", "2001:db8::1"},
{"bare_ipv4", "192.0.2.1", "192.0.2.1"},
{"empty", "", ""},
{"garbage", "not-an-ip", ""},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
got := run(t, ClientIPFromRemoteAddr, func(r *http.Request) {
r.RemoteAddr = tc.remoteAddr
})
if got != tc.out {
t.Errorf("want %q, got %q", tc.out, got)
}
})
}
}
// TestClientIPLastWriteWins locks down the implementation property that each
// ClientIPFrom* middleware unconditionally overwrites the context value when
// it finds a valid IP, while leaving any previously stored value alone when
// it doesn't. Application code should pick exactly one ClientIPFrom*
// middleware; this test only guards against an accidental return to
// first-write-wins semantics.
func TestClientIPLastWriteWins(t *testing.T) {
t.Run("later_overrides_earlier", func(t *testing.T) {
got := runChain(t,
[]func(http.Handler) http.Handler{
ClientIPFromXFF(),
ClientIPFromHeader("CF-Connecting-IP"),
},
func(r *http.Request) {
r.Header.Set("CF-Connecting-IP", "1.1.1.1")
r.Header.Set("X-Forwarded-For", "2.2.2.2")
})
if got != "1.1.1.1" {
t.Errorf("want 1.1.1.1, got %q", got)
}
})
t.Run("earlier_persists_when_later_finds_nothing", func(t *testing.T) {
got := runChain(t,
[]func(http.Handler) http.Handler{
ClientIPFromXFF(),
ClientIPFromHeader("CF-Connecting-IP"), // missing → no-op.
},
func(r *http.Request) {
r.Header.Set("X-Forwarded-For", "8.8.8.8")
})
if got != "8.8.8.8" {
t.Errorf("want 8.8.8.8, got %q", got)
}
})
t.Run("earlier_persists_when_later_xff_all_trusted", func(t *testing.T) {
got := runChain(t,
[]func(http.Handler) http.Handler{
ClientIPFromRemoteAddr,
ClientIPFromXFF("10.0.0.0/8"), // all trusted → no-op.
},
func(r *http.Request) {
r.Header.Set("X-Forwarded-For", "10.0.0.1, 10.0.0.2")
r.RemoteAddr = "192.0.2.1:1234"
})
if got != "192.0.2.1" {
t.Errorf("want 192.0.2.1, got %q", got)
}
})
}
func TestGetClientIPAddr_Unset(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
if got := GetClientIP(req.Context()); got != "" {
t.Errorf("GetClientIP on empty ctx: want %q, got %q", "", got)
}
if got := GetClientIPAddr(req.Context()); got.IsValid() {
t.Errorf("GetClientIPAddr on empty ctx: want zero, got %v", got)
}
}
func TestGetClientIPAddr_RoundTrip(t *testing.T) {
var gotStr string
var gotAddr netip.Addr
r := chi.NewRouter()
r.Use(ClientIPFromHeader("X-Real-IP"))
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
gotStr = GetClientIP(r.Context())
gotAddr = GetClientIPAddr(r.Context())
})
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Real-IP", "203.0.113.42")
r.ServeHTTP(httptest.NewRecorder(), req)
if gotStr != "203.0.113.42" {
t.Errorf("GetClientIP: want 203.0.113.42, got %q", gotStr)
}
if !gotAddr.IsValid() || gotAddr.String() != "203.0.113.42" {
t.Errorf("GetClientIPAddr: want 203.0.113.42, got %v", gotAddr)
}
if gotAddr.Is6() {
t.Errorf("GetClientIPAddr: want IPv4, got IPv6")
}
}
// GHSA-3fxj-6jh8-hvhx (Saku0512): an admin endpoint gates on the client IP,
// and an attacker prepends a spoofed IP to X-Forwarded-For. middleware.RealIP
// would set r.RemoteAddr from the leftmost XFF value, bypassing the gate.
// The new ClientIPFrom* middlewares defeat this attack in both deployment
// shapes the application might choose.
func TestAdvisory_GHSA_3fxj_6jh8_hvhx(t *testing.T) {
// (a) Server directly on the internet: use ClientIPFromRemoteAddr.
// XFF is never consulted; the spoofed admin IP is ignored.
t.Run("direct_internet_uses_remoteaddr", func(t *testing.T) {
var clientIP string
r := chi.NewRouter()
r.Use(ClientIPFromRemoteAddr)
r.Get("/admin", func(w http.ResponseWriter, r *http.Request) {
clientIP = GetClientIP(r.Context())
})
req := httptest.NewRequest("GET", "/admin", nil)
req.RemoteAddr = "99.99.99.99:1234"
req.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8") // attacker's spoof.
r.ServeHTTP(httptest.NewRecorder(), req)
if clientIP == "1.2.3.4" {
t.Fatal("VULNERABLE: returned attacker-supplied admin IP")
}
if clientIP != "99.99.99.99" {
t.Errorf("want 99.99.99.99 (real TCP peer), got %q", clientIP)
}
})
// (b) Server behind a trusted proxy: use ClientIPFromXFF with the proxy's
// CIDR. In a real deployment the trusted proxy APPENDS the attacker's
// real IP, so by the time the request reaches us the rightmost-untrusted
// entry is the genuine attacker IP, never the prepended spoof.
t.Run("behind_proxy_uses_xff", func(t *testing.T) {
got := run(t, ClientIPFromXFF("10.0.0.0/8"), func(r *http.Request) {
r.Header.Set("X-Forwarded-For", "1.2.3.4, 99.99.99.99")
})
if got == "1.2.3.4" {
t.Fatal("VULNERABLE: returned attacker-supplied admin IP")
}
if got != "99.99.99.99" {
t.Errorf("want 99.99.99.99 (rightmost-untrusted), got %q", got)
}
})
}
// GHSA-9g5q-2w5x-hmxf (convto): RealIP set r.RemoteAddr from the leftmost XFF
// IP. With ClientIPFromXFF the rightmost-untrusted IP is selected, and we
// never mutate r.RemoteAddr.
func TestAdvisory_GHSA_9g5q_2w5x_hmxf(t *testing.T) {
originalRemoteAddr := "203.0.113.99:1234"
var capturedClientIP, capturedRemoteAddr string
r := chi.NewRouter()
r.Use(ClientIPFromXFF())
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
capturedClientIP = GetClientIP(r.Context())
capturedRemoteAddr = r.RemoteAddr
})
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Forwarded-For", "192.0.2.2, 192.0.2.1") // convto's PoC values.
req.RemoteAddr = originalRemoteAddr
r.ServeHTTP(httptest.NewRecorder(), req)
// The rightmost value is the one added by our (single) trusted hop.
if capturedClientIP != "192.0.2.1" {
t.Errorf("client IP: want 192.0.2.1 (rightmost), got %q", capturedClientIP)
}
if capturedClientIP == "192.0.2.2" {
t.Errorf("VULNERABLE: returned attacker-supplied leftmost IP")
}
// Critical: r.RemoteAddr is never mutated, unlike middleware.RealIP.
if capturedRemoteAddr != originalRemoteAddr {
t.Errorf("r.RemoteAddr was mutated: want %q, got %q", originalRemoteAddr, capturedRemoteAddr)
}
}
// GHSA-rjr7-jggh-pgcp (rezmoss): RealIP trusted X-Real-IP / True-Client-IP /
// X-Forwarded-For unconditionally and used the leftmost XFF value, allowing
// "X-Forwarded-For: 127.0.0.1" to impersonate loopback. The new middleware
// (a) requires the user to opt into a specific source and (b) uses
// rightmost-untrusted for XFF.
func TestAdvisory_GHSA_rjr7_jggh_pgcp(t *testing.T) {
// Attacker sends "X-Forwarded-For: 127.0.0.1" to spoof loopback. In a real
// deployment the trusted proxy appends the attacker's actual IP, so the
// rightmost-untrusted entry is never the loopback spoof.
t.Run("xff_loopback_spoof_rejected", func(t *testing.T) {
got := run(t, ClientIPFromXFF("10.0.0.0/8"), func(r *http.Request) {
r.Header.Set("X-Forwarded-For", "127.0.0.1, 99.99.99.99")
})
if got == "127.0.0.1" {
t.Fatal("VULNERABLE: returned attacker-supplied loopback")
}
if got != "99.99.99.99" {
t.Errorf("want 99.99.99.99 (rightmost-untrusted), got %q", got)
}
})
// Unlike RealIP, ClientIPFromHeader reads ONLY the header the user opted
// into. Attacker-supplied True-Client-IP / X-Forwarded-For are ignored.
t.Run("only_opted_in_header_is_read", func(t *testing.T) {
got := run(t, ClientIPFromHeader("X-Real-IP"), func(r *http.Request) {
r.Header.Set("True-Client-IP", "1.1.1.1") // attacker-supplied; ignored.
r.Header.Set("X-Forwarded-For", "2.2.2.2") // attacker-supplied; ignored.
r.Header.Set("X-Real-IP", "203.0.113.7") // set by the trusted proxy.
})
if got != "203.0.113.7" {
t.Errorf("want 203.0.113.7 (the only trusted header), got %q", got)
}
})
}
// "Multiple XFF headers" attack — Go's http.Header.Get returns only the first
// value, but the security-relevant rightmost IP is in the LAST instance after
// the proxy appends. We must merge all values; otherwise an attacker can pick
// which header the server sees by sending a duplicate.
//
// See https://adam-p.ca/blog/2022/03/x-forwarded-for/ "Multiple headers".
func TestXFF_MultipleHeadersMerged(t *testing.T) {
// Attacker sends two XFF headers; the trusted proxy appends to the second
// one (per RFC 2616). The genuine client is at the right of the merged
// chain. A naive http.Header.Get() implementation would only see the first
// header and return the attacker's prepended value.
got := run(t, ClientIPFromXFF("198.51.100.0/24"), func(r *http.Request) {
r.Header.Add("X-Forwarded-For", "127.0.0.1") // attacker-injected.
r.Header.Add("X-Forwarded-For", "8.8.8.8, 198.51.100.1") // appended by trusted proxy.
})
if got != "8.8.8.8" {
t.Errorf("want 8.8.8.8, got %q", got)
}
}
// TestClientIPFromHeader_MultiValueLastWins is a regression pin for the
// defense-in-depth gap fixed in a8e3dbf. The pre-fix implementation read
// the trusted single-IP header with r.Header.Get(), which returns only
// the first header value Go saw on the wire — so a misconfigured proxy
// that appended (instead of overwriting) the trusted header, or any
// chain where another proxy already set it, could surface an attacker-
// controlled values[0].
//
// Pinned post-fix contract: ClientIPFromHeader reads r.Header.Values()
// and uses the LAST entry (closest hop = most trusted, same
// rightmost-untrusted spirit as ClientIPFromXFF). Fail-closed on garbage
// at the last position; we do NOT fall back to earlier values — those
// came from hops further from us and are less trustworthy by definition.
func TestClientIPFromHeader_MultiValueLastWins(t *testing.T) {
tt := []struct {
name string
values []string // appended in order via Header.Add
out string
}{
// Baseline: single value (the well-configured case) still works.
{"single_value_unchanged", []string{"100.1.1.1"}, "100.1.1.1"},
// Attack: attacker sets X-Real-IP, then proxy appends (instead of
// overwriting) its own value. Last value (the proxy's) must win.
{"attacker_then_proxy", []string{"6.6.6.6", "100.2.2.2"}, "100.2.2.2"},
// Three hops; only the last is trusted.
{"three_values_last_wins", []string{"6.6.6.6", "5.5.5.5", "100.3.3.3"}, "100.3.3.3"},
// Fail-closed: last value is garbage. We do NOT fall back to the
// previous value — that value was set by someone further from us in
// the chain and is by definition less trustworthy than what our
// nearest hop produced (even if what they produced is garbage).
{"last_unparseable_no_fallback", []string{"100.4.4.4", "garbage"}, ""},
// Fail-closed: last value is empty. Same rationale.
{"last_empty_no_fallback", []string{"100.5.5.5", ""}, ""},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
got := run(t, ClientIPFromHeader("X-Real-IP"), func(r *http.Request) {
for _, v := range tc.values {
r.Header.Add("X-Real-IP", v)
}
})
if got != tc.out {
t.Errorf("VULNERABLE or wrong: want %q, got %q", tc.out, got)
}
})
}
}
// TestXFF_V4MappedIPv6BypassesTrustedV4Prefix demonstrates a gap in the
// rightmost-untrusted walker: netip.Prefix.Contains returns false when an
// IPv4-mapped IPv6 address (::ffff:a.b.c.d) is checked against a plain v4
// prefix. So an attacker who can inject the v4-mapped form of an internal
// address into XFF — and whose own connecting IP happens to be in the
// trusted range (e.g. a compromised host inside an internal VPC) — gets a
// trusted-looking value returned as "the client IP". Any downstream code
// that treats internal IPs as privileged (rate-limit exemptions, internal
// admin allowlists, audit pipelines) then mis-classifies the request.
//
// Pinned post-fix behavior: parseHeaderAddr calls Unmap() before the prefix
// check, so ::ffff:10.0.0.5 is recognized as 10.0.0.5 and skipped along with
// the rest of the trusted chain — leaving no untrusted IP and an empty
// GetClientIP.
func TestXFF_V4MappedIPv6BypassesTrustedV4Prefix(t *testing.T) {
tt := []struct {
name string
prefixes []string
xff string
out string
}{
{
name: "internal_address_via_v4_mapped",
prefixes: []string{"10.0.0.0/8"},
xff: "::ffff:10.0.0.5, 10.0.0.1",
out: "",
},
{
name: "loopback_via_v4_mapped",
prefixes: []string{"127.0.0.0/8", "10.0.0.0/8"},
xff: "::ffff:127.0.0.1, 10.0.0.1",
out: "",
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
got := run(t, ClientIPFromXFF(tc.prefixes...), func(r *http.Request) {
r.Header.Set("X-Forwarded-For", tc.xff)
})
if got != tc.out {
t.Errorf("VULNERABLE: v4-mapped IPv6 escaped v4 trusted prefix — want %q, got %q", tc.out, got)
}
})
}
}
// TestXFF_IPv6ZoneIDBypassesTrustedV6Prefix demonstrates the same shape of
// gap for IPv6 trust prefixes: netip.Prefix.Contains returns false for any
// zoned IPv6 address, so an attacker-injected zone suffix escapes the trust
// check and the value is returned as "the client IP". Zone IDs are
// link-local concepts; they have no meaning on the wire and should never
// appear in a real XFF chain.
//
// Pinned post-fix behavior: parseHeaderAddr strips the zone before the
// prefix check, so 2606:4700::1%attacker is recognized as in 2606:4700::/32
// and skipped.
func TestXFF_IPv6ZoneIDBypassesTrustedV6Prefix(t *testing.T) {
got := run(t, ClientIPFromXFF("2606:4700::/32"), func(r *http.Request) {
r.Header.Set("X-Forwarded-For", "2606:4700::1%attacker, 2606:4700::5")
})
if got != "" {
t.Errorf("VULNERABLE: zoned IPv6 escaped v6 trusted prefix — want %q, got %q", "", got)
}
}
// TestClientIPFromRemoteAddr_V4MappedIsUnmapped pins the desired
// normalization for v4 connections accepted on a dual-stack listener:
// Go's net/http surfaces those as ::ffff:a.b.c.d in r.RemoteAddr, and
// returning them in that v6 form splits one logical client across two
// rate-limit buckets, log keys, and prefix-check buckets depending on
// listener configuration. Pinned post-fix behavior: ClientIPFromRemoteAddr
// calls Unmap() before storing.
func TestClientIPFromRemoteAddr_V4MappedIsUnmapped(t *testing.T) {
tt := []struct {
name string
remoteAddr string
out string
}{
{"v4_mapped_with_port", "[::ffff:1.2.3.4]:1234", "1.2.3.4"},
{"v4_mapped_bare", "::ffff:1.2.3.4", "1.2.3.4"},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
got := run(t, ClientIPFromRemoteAddr, func(r *http.Request) {
r.RemoteAddr = tc.remoteAddr
})
if got != tc.out {
t.Errorf("want %q (unmapped v4 form), got %q", tc.out, got)
}
})
}
}
// TestXFF_FailClosedOnUnparseable pins the fail-closed contract for the
// rightmost-untrusted XFF walk (PR #967 review issue #6, adam-p): if the
// walker encounters an entry that does not parse as an IP, it MUST abandon
// the walk and leave no client IP set. Rightmost-untrusted relies on being
// able to read every hop to the right of the client; an unparseable hop is
// indistinguishable from a hostile or missing one, so we cannot safely keep
// walking past it.
//
// Empty / whitespace-only entries are NOT parse failures — they are dropped
// by trim before parsing, and do not trip fail-closed.
func TestXFF_FailClosedOnUnparseable(t *testing.T) {
tt := []struct {
name string
prefixes []string
xff []string
out string
}{
// Rightmost entry is garbage — walker fails closed before reaching the
// otherwise-valid 1.1.1.1 to the left. Without fail-closed, garbage
// to the right of a real client could be silently elided.
{
name: "garbage_rightmost_no_prefixes",
xff: []string{"1.1.1.1, garbage"},
out: "",
},
// Garbage between the client and a trusted proxy hop. Without fail-
// closed, the walker would skip the trusted hop, silently skip the
// garbage, and return 203.0.113.7 — a spoofable result.
{
name: "garbage_between_client_and_trusted_proxy",
prefixes: []string{"10.0.0.0/8"},
xff: []string{"203.0.113.7, garbage, 10.0.0.1"},
out: "",
},
// Garbage past a multi-hop trusted chain. Same shape with more
// trusted hops in front; behaviour must be identical.
{
name: "garbage_past_trusted_chain",
prefixes: []string{"10.0.0.0/8"},
xff: []string{"203.0.113.7, garbage, 10.0.0.1, 10.0.0.2"},
out: "",
},
// Negative case: garbage sits in a left-hand header that the walker
// never reaches because it returns at a valid untrusted entry first.
// Fail-closed must NOT over-trigger here.
{
name: "garbage_in_unreachable_left_header",
prefixes: []string{"10.0.0.0/8"},
xff: []string{"garbage", "203.0.113.7, 10.0.0.1"},
out: "203.0.113.7",
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
got := run(t, ClientIPFromXFF(tc.prefixes...), func(r *http.Request) {
for _, v := range tc.xff {
r.Header.Add("X-Forwarded-For", v)
}
})
if got != tc.out {
t.Errorf("want %q, got %q", tc.out, got)
}
})
}
}
// run invokes mw with the request constructed by buildReq and returns the
// client IP captured inside the handler (or "" if none was set).
func run(t *testing.T, mw func(http.Handler) http.Handler, buildReq func(*http.Request)) string {
t.Helper()
req := httptest.NewRequest("GET", "/", nil)
buildReq(req)
var got string
r := chi.NewRouter()
r.Use(mw)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
got = GetClientIP(r.Context())
})
r.ServeHTTP(httptest.NewRecorder(), req)
return got
}
// runChain is like run but applies multiple middlewares in order.
func runChain(t *testing.T, mws []func(http.Handler) http.Handler, buildReq func(*http.Request)) string {
t.Helper()
req := httptest.NewRequest("GET", "/", nil)
buildReq(req)
var got string
r := chi.NewRouter()
for _, mw := range mws {
r.Use(mw)
}
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
got = GetClientIP(r.Context())
})
r.ServeHTTP(httptest.NewRecorder(), req)
return got
}

412
middleware/compress.go Normal file
View File

@@ -0,0 +1,412 @@
package middleware
import (
"bufio"
"compress/flate"
"compress/gzip"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
)
var defaultCompressibleContentTypes = []string{
"text/html",
"text/css",
"text/plain",
"text/javascript",
"text/markdown",
"text/csv",
"text/vtt",
"application/javascript",
"application/x-javascript",
"application/json",
"application/atom+xml",
"application/rss+xml",
"application/xml",
"text/xml",
"image/svg+xml",
}
// Compress is a middleware that compresses response
// body of a given content types to a data format based
// on Accept-Encoding request header. It uses a given
// compression level.
//
// NOTE: make sure to set the Content-Type header on your response
// otherwise this middleware will not compress the response body. For ex, in
// your handler you should set w.Header().Set("Content-Type", http.DetectContentType(yourBody))
// or set it manually.
//
// Passing a compression level of 5 is sensible value
func Compress(level int, types ...string) func(next http.Handler) http.Handler {
compressor := NewCompressor(level, types...)
return compressor.Handler
}
// Compressor represents a set of encoding configurations.
type Compressor struct {
// The mapping of encoder names to encoder functions.
encoders map[string]EncoderFunc
// The mapping of pooled encoders to pools.
pooledEncoders map[string]*sync.Pool
// The set of content types allowed to be compressed.
allowedTypes map[string]struct{}
allowedWildcards map[string]struct{}
// The list of encoders in order of decreasing precedence.
encodingPrecedence []string
level int // The compression level.
}
// NewCompressor creates a new Compressor that will handle encoding responses.
//
// The level should be one of the ones defined in the flate package.
// The types are the content types that are allowed to be compressed.
//
// Catch-all wildcards ("*/*", "/*") are rejected: compressing every response
// wastes CPU on already-compressed types like zip, jpeg or png. Pass explicit
// types instead, e.g. "text/html" or "application/*".
func NewCompressor(level int, types ...string) *Compressor {
// If types are provided, set those as the allowed types. If none are
// provided, use the default list.
allowedTypes := make(map[string]struct{})
allowedWildcards := make(map[string]struct{})
if len(types) > 0 {
for _, t := range types {
if strings.Contains(strings.TrimSuffix(t, "/*"), "*") {
panic(fmt.Sprintf("middleware/compress: Unsupported content-type wildcard pattern '%s'. Only '<type>/*' supported", t))
}
if before, ok := strings.CutSuffix(t, "/*"); ok {
if before == "" {
panic(fmt.Sprintf("middleware/compress: Unsupported content-type wildcard pattern '%s'. Only '<type>/*' supported", t))
}
allowedWildcards[before] = struct{}{}
} else {
allowedTypes[t] = struct{}{}
}
}
} else {
for _, t := range defaultCompressibleContentTypes {
allowedTypes[t] = struct{}{}
}
}
c := &Compressor{
level: level,
encoders: make(map[string]EncoderFunc),
pooledEncoders: make(map[string]*sync.Pool),
allowedTypes: allowedTypes,
allowedWildcards: allowedWildcards,
}
// Set the default encoders. The precedence order uses the reverse
// ordering that the encoders were added. This means adding new encoders
// will move them to the front of the order.
//
// TODO:
// lzma: Opera.
// sdch: Chrome, Android. Gzip output + dictionary header.
// br: Brotli, see https://github.com/go-chi/chi/pull/326
// HTTP 1.1 "deflate" (RFC 2616) stands for DEFLATE data (RFC 1951)
// wrapped with zlib (RFC 1950). The zlib wrapper uses Adler-32
// checksum compared to CRC-32 used in "gzip" and thus is faster.
//
// But.. some old browsers (MSIE, Safari 5.1) incorrectly expect
// raw DEFLATE data only, without the mentioned zlib wrapper.
// Because of this major confusion, most modern browsers try it
// both ways, first looking for zlib headers.
// Quote by Mark Adler: http://stackoverflow.com/a/9186091/385548
//
// The list of browsers having problems is quite big, see:
// http://zoompf.com/blog/2012/02/lose-the-wait-http-compression
// https://web.archive.org/web/20120321182910/http://www.vervestudios.co/projects/compression-tests/results
//
// That's why we prefer gzip over deflate. It's just more reliable
// and not significantly slower than deflate.
c.SetEncoder("deflate", encoderDeflate)
// TODO: Exception for old MSIE browsers that can't handle non-HTML?
// https://zoompf.com/blog/2012/02/lose-the-wait-http-compression
c.SetEncoder("gzip", encoderGzip)
// NOTE: Not implemented, intentionally:
// case "compress": // LZW. Deprecated.
// case "bzip2": // Too slow on-the-fly.
// case "zopfli": // Too slow on-the-fly.
// case "xz": // Too slow on-the-fly.
return c
}
// SetEncoder can be used to set the implementation of a compression algorithm.
//
// The encoding should be a standardised identifier. See:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding
//
// For example, add the Brotli algorithm:
//
// import "github.com/google/brotli/go/cbrotli"
//
// compressor := middleware.NewCompressor(5, "text/html")
// compressor.SetEncoder("br", func(w io.Writer, level int) io.Writer {
// return cbrotli.NewWriter(w, cbrotli.WriterOptions{Quality: level})
// })
//
// Alternatively, this Cgo-free Brotli module can be used:
//
// import "github.com/andybalholm/brotli"
//
// compressor := middleware.NewCompressor(5, "text/html")
//
// compressor.SetEncoder("br", func(w io.Writer, level int) io.Writer {
// return brotli.NewWriterV2(w, level)
// })
func (c *Compressor) SetEncoder(encoding string, fn EncoderFunc) {
encoding = strings.ToLower(encoding)
if encoding == "" {
panic("the encoding can not be empty")
}
if fn == nil {
panic("attempted to set a nil encoder function")
}
// If we are adding a new encoder that is already registered, we have to
// clear that one out first.
delete(c.pooledEncoders, encoding)
delete(c.encoders, encoding)
// If the encoder supports Resetting (IoReseterWriter), then it can be pooled.
encoder := fn(io.Discard, c.level)
if _, ok := encoder.(ioResetterWriter); ok {
pool := &sync.Pool{
New: func() any {
return fn(io.Discard, c.level)
},
}
c.pooledEncoders[encoding] = pool
}
// If the encoder is not in the pooledEncoders, add it to the normal encoders.
if _, ok := c.pooledEncoders[encoding]; !ok {
c.encoders[encoding] = fn
}
for i, v := range c.encodingPrecedence {
if v == encoding {
c.encodingPrecedence = append(c.encodingPrecedence[:i], c.encodingPrecedence[i+1:]...)
}
}
c.encodingPrecedence = append([]string{encoding}, c.encodingPrecedence...)
}
// Handler returns a new middleware that will compress the response based on the
// current Compressor.
func (c *Compressor) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
encoder, encoding, cleanup := c.selectEncoder(r.Header, w)
cw := &compressResponseWriter{
ResponseWriter: w,
w: w,
contentTypes: c.allowedTypes,
contentWildcards: c.allowedWildcards,
encoding: encoding,
compressible: false, // determined in post-handler
}
if encoder != nil {
cw.w = encoder
}
// Re-add the encoder to the pool if applicable.
defer cleanup()
defer cw.Close()
next.ServeHTTP(cw, r)
})
}
// selectEncoder returns the encoder, the name of the encoder, and a closer function.
func (c *Compressor) selectEncoder(h http.Header, w io.Writer) (io.Writer, string, func()) {
header := h.Get("Accept-Encoding")
// Parse the names of all accepted algorithms from the header.
accepted := strings.Split(strings.ToLower(header), ",")
// Find supported encoder by accepted list by precedence
for _, name := range c.encodingPrecedence {
if matchAcceptEncoding(accepted, name) {
if pool, ok := c.pooledEncoders[name]; ok {
encoder := pool.Get().(ioResetterWriter)
cleanup := func() {
pool.Put(encoder)
}
encoder.Reset(w)
return encoder, name, cleanup
}
if fn, ok := c.encoders[name]; ok {
return fn(w, c.level), name, func() {}
}
}
}
// No encoder found to match the accepted encoding
return nil, "", func() {}
}
func matchAcceptEncoding(accepted []string, encoding string) bool {
for _, v := range accepted {
if strings.Contains(v, encoding) {
return true
}
}
return false
}
// An EncoderFunc is a function that wraps the provided io.Writer with a
// streaming compression algorithm and returns it.
//
// In case of failure, the function should return nil.
type EncoderFunc func(w io.Writer, level int) io.Writer
// Interface for types that allow resetting io.Writers.
type ioResetterWriter interface {
io.Writer
Reset(w io.Writer)
}
type compressResponseWriter struct {
http.ResponseWriter
// The streaming encoder writer to be used if there is one. Otherwise,
// this is just the normal writer.
w io.Writer
contentTypes map[string]struct{}
contentWildcards map[string]struct{}
encoding string
wroteHeader bool
compressible bool
}
func (cw *compressResponseWriter) isCompressible() bool {
// Parse the first part of the Content-Type response header.
contentType := cw.Header().Get("Content-Type")
contentType, _, _ = strings.Cut(contentType, ";")
// Is the content type compressible?
if _, ok := cw.contentTypes[contentType]; ok {
return true
}
if contentType, _, hadSlash := strings.Cut(contentType, "/"); hadSlash {
_, ok := cw.contentWildcards[contentType]
return ok
}
return false
}
func (cw *compressResponseWriter) WriteHeader(code int) {
if cw.wroteHeader {
cw.ResponseWriter.WriteHeader(code) // Allow multiple calls to propagate.
return
}
cw.wroteHeader = true
defer cw.ResponseWriter.WriteHeader(code)
// Already compressed data?
if cw.Header().Get("Content-Encoding") != "" {
return
}
if !cw.isCompressible() {
cw.compressible = false
return
}
if cw.encoding != "" {
cw.compressible = true
cw.Header().Set("Content-Encoding", cw.encoding)
cw.Header().Add("Vary", "Accept-Encoding")
// The content-length after compression is unknown
cw.Header().Del("Content-Length")
}
}
func (cw *compressResponseWriter) Write(p []byte) (int, error) {
if !cw.wroteHeader {
cw.WriteHeader(http.StatusOK)
}
return cw.writer().Write(p)
}
func (cw *compressResponseWriter) writer() io.Writer {
if cw.compressible {
return cw.w
}
return cw.ResponseWriter
}
type compressFlusher interface {
Flush() error
}
func (cw *compressResponseWriter) Flush() {
if f, ok := cw.writer().(http.Flusher); ok {
f.Flush()
}
// If the underlying writer has a compression flush signature,
// call this Flush() method instead
if f, ok := cw.writer().(compressFlusher); ok {
f.Flush()
// Also flush the underlying response writer
if f, ok := cw.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
}
func (cw *compressResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if hj, ok := cw.writer().(http.Hijacker); ok {
return hj.Hijack()
}
return nil, nil, errors.New("chi/middleware: http.Hijacker is unavailable on the writer")
}
func (cw *compressResponseWriter) Push(target string, opts *http.PushOptions) error {
if ps, ok := cw.writer().(http.Pusher); ok {
return ps.Push(target, opts)
}
return errors.New("chi/middleware: http.Pusher is unavailable on the writer")
}
func (cw *compressResponseWriter) Close() error {
if c, ok := cw.writer().(io.WriteCloser); ok {
return c.Close()
}
return errors.New("chi/middleware: io.WriteCloser is unavailable on the writer")
}
func (cw *compressResponseWriter) Unwrap() http.ResponseWriter {
return cw.ResponseWriter
}
func encoderGzip(w io.Writer, level int) io.Writer {
gw, err := gzip.NewWriterLevel(w, level)
if err != nil {
return nil
}
return gw
}
func encoderDeflate(w io.Writer, level int) io.Writer {
dw, err := flate.NewWriter(w, level)
if err != nil {
return nil
}
return dw
}

251
middleware/compress_test.go Normal file
View File

@@ -0,0 +1,251 @@
package middleware
import (
"compress/flate"
"compress/gzip"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
func TestCompressor(t *testing.T) {
r := chi.NewRouter()
compressor := NewCompressor(5, "text/html", "text/css")
if len(compressor.encoders) != 0 || len(compressor.pooledEncoders) != 2 {
t.Errorf("gzip and deflate should be pooled")
}
compressor.SetEncoder("nop", func(w io.Writer, _ int) io.Writer {
return w
})
if len(compressor.encoders) != 1 {
t.Errorf("nop encoder should be stored in the encoders map")
}
r.Use(compressor.Handler)
r.Get("/gethtml", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte("textstring"))
})
r.Get("/getcss", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte("textstring"))
})
r.Get("/getplain", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte("textstring"))
})
ts := httptest.NewServer(r)
defer ts.Close()
tests := []struct {
name string
path string
expectedEncoding string
acceptedEncodings []string
}{
{
name: "no expected encodings due to no accepted encodings",
path: "/gethtml",
acceptedEncodings: nil,
expectedEncoding: "",
},
{
name: "no expected encodings due to content type",
path: "/getplain",
acceptedEncodings: nil,
expectedEncoding: "",
},
{
name: "gzip is only encoding",
path: "/gethtml",
acceptedEncodings: []string{"gzip"},
expectedEncoding: "gzip",
},
{
name: "gzip is preferred over deflate",
path: "/getcss",
acceptedEncodings: []string{"gzip", "deflate"},
expectedEncoding: "gzip",
},
{
name: "deflate is used",
path: "/getcss",
acceptedEncodings: []string{"deflate"},
expectedEncoding: "deflate",
},
{
name: "nop is preferred",
path: "/getcss",
acceptedEncodings: []string{"nop, gzip, deflate"},
expectedEncoding: "nop",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
resp, respString := testRequestWithAcceptedEncodings(t, ts, "GET", tc.path, tc.acceptedEncodings...)
if respString != "textstring" {
t.Errorf("response text doesn't match; expected:%q, got:%q", "textstring", respString)
}
if got := resp.Header.Get("Content-Encoding"); got != tc.expectedEncoding {
t.Errorf("expected encoding %q but got %q", tc.expectedEncoding, got)
}
})
}
}
func TestCompressorDefaultTextTypes(t *testing.T) {
for _, contentType := range []string{"text/markdown", "text/csv", "text/vtt"} {
t.Run(contentType, func(t *testing.T) {
r := chi.NewRouter()
r.Use(Compress(5))
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", contentType)
w.Write([]byte("textstring"))
})
ts := httptest.NewServer(r)
defer ts.Close()
resp, body := testRequestWithAcceptedEncodings(t, ts, "GET", "/", "gzip")
if body != "textstring" {
t.Errorf("response text doesn't match; expected:%q, got:%q", "textstring", body)
}
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
t.Errorf("expected encoding %q but got %q", "gzip", got)
}
})
}
}
func TestCompressorWildcards(t *testing.T) {
tests := []struct {
name string
recover string
types []string
typesCount int
wcCount int
}{
{
name: "defaults",
typesCount: len(defaultCompressibleContentTypes),
},
{
name: "no wildcard",
types: []string{"text/plain", "text/html"},
typesCount: 2,
},
{
name: "invalid wildcard #1",
types: []string{"audio/*wav"},
recover: "middleware/compress: Unsupported content-type wildcard pattern 'audio/*wav'. Only '<type>/*' supported",
},
{
name: "invalid wildcard #2",
types: []string{"application*/*"},
recover: "middleware/compress: Unsupported content-type wildcard pattern 'application*/*'. Only '<type>/*' supported",
},
{
name: "catch-all wildcard #1",
types: []string{"*/*"},
recover: "middleware/compress: Unsupported content-type wildcard pattern '*/*'. Only '<type>/*' supported",
},
{
name: "catch-all wildcard #2",
types: []string{"/*"},
recover: "middleware/compress: Unsupported content-type wildcard pattern '/*'. Only '<type>/*' supported",
},
{
name: "valid wildcard",
types: []string{"text/*"},
wcCount: 1,
},
{
name: "mixed",
types: []string{"audio/wav", "text/*"},
typesCount: 1,
wcCount: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer func() {
if tt.recover == "" {
tt.recover = "<nil>"
}
if r := recover(); tt.recover != fmt.Sprintf("%v", r) {
t.Errorf("Unexpected value recovered: %v", r)
}
}()
compressor := NewCompressor(5, tt.types...)
if len(compressor.allowedTypes) != tt.typesCount {
t.Errorf("expected %d allowedTypes, got %d", tt.typesCount, len(compressor.allowedTypes))
}
if len(compressor.allowedWildcards) != tt.wcCount {
t.Errorf("expected %d allowedWildcards, got %d", tt.wcCount, len(compressor.allowedWildcards))
}
})
}
}
func testRequestWithAcceptedEncodings(t *testing.T, ts *httptest.Server, method, path string, encodings ...string) (*http.Response, string) {
req, err := http.NewRequest(method, ts.URL+path, nil)
if err != nil {
t.Fatal(err)
return nil, ""
}
if len(encodings) > 0 {
encodingsString := strings.Join(encodings, ",")
req.Header.Set("Accept-Encoding", encodingsString)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
return nil, ""
}
respBody := decodeResponseBody(t, resp)
defer resp.Body.Close()
return resp, respBody
}
func decodeResponseBody(t *testing.T, resp *http.Response) string {
var reader io.ReadCloser
switch resp.Header.Get("Content-Encoding") {
case "gzip":
var err error
reader, err = gzip.NewReader(resp.Body)
if err != nil {
t.Fatal(err)
}
case "deflate":
reader = flate.NewReader(resp.Body)
default:
reader = resp.Body
}
respBody, err := io.ReadAll(reader)
if err != nil {
t.Fatal(err)
return ""
}
reader.Close()
return string(respBody)
}

View File

@@ -0,0 +1,51 @@
package middleware
import (
"net/http"
"slices"
"strings"
)
// ContentCharset generates a handler that writes a 415 Unsupported Media Type response if none of the charsets match.
// An empty charset will allow requests with no Content-Type header or no specified charset.
// Requests without a body (ContentLength == 0) are always allowed.
func ContentCharset(charsets ...string) func(next http.Handler) http.Handler {
for i, c := range charsets {
charsets[i] = strings.ToLower(c)
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength == 0 {
next.ServeHTTP(w, r)
return
}
if !contentEncoding(r.Header.Get("Content-Type"), charsets...) {
w.WriteHeader(http.StatusUnsupportedMediaType)
return
}
next.ServeHTTP(w, r)
})
}
}
// Check the content encoding against a list of acceptable values.
func contentEncoding(ce string, charsets ...string) bool {
_, ce = split(strings.ToLower(ce), ";")
_, ce = split(ce, "charset=")
ce, _ = split(ce, ";")
return slices.Contains(charsets, ce)
}
// Split a string in two parts, cleaning any whitespace.
func split(str, sep string) (string, string) {
a, b, found := strings.Cut(str, sep)
a = strings.TrimSpace(a)
if found {
b = strings.TrimSpace(b)
}
return a, b
}

View File

@@ -0,0 +1,159 @@
package middleware
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
func TestContentCharset(t *testing.T) {
t.Parallel()
var tests = []struct {
name string
inputValue string
inputContentCharset []string
body string
want int
}{
{
"should accept requests with a matching charset",
"application/json; charset=UTF-8",
[]string{"UTF-8"},
"foobar",
http.StatusOK,
},
{
"should be case-insensitive",
"application/json; charset=utf-8",
[]string{"UTF-8"},
"foobar",
http.StatusOK,
},
{
"should accept requests with a matching charset with extra values",
"application/json; foo=bar; charset=UTF-8; spam=eggs",
[]string{"UTF-8"},
"foobar",
http.StatusOK,
},
{
"should accept requests with a matching charset when multiple charsets are supported",
"text/xml; charset=UTF-8",
[]string{"UTF-8", "Latin-1"},
"foobar",
http.StatusOK,
},
{
"should accept requests with no charset if empty charset headers are allowed",
"text/xml",
[]string{"UTF-8", ""},
"foobar",
http.StatusOK,
},
{
"should not accept requests with no charset if empty charset headers are not allowed",
"text/xml",
[]string{"UTF-8"},
"foobar",
http.StatusUnsupportedMediaType,
},
{
"should not accept requests with a mismatching charset",
"text/plain; charset=Latin-1",
[]string{"UTF-8"},
"foobar",
http.StatusUnsupportedMediaType,
},
{
"should not accept requests with a mismatching charset even if empty charsets are allowed",
"text/plain; charset=Latin-1",
[]string{"UTF-8", ""},
"foobar",
http.StatusUnsupportedMediaType,
},
{
"should skip validation for requests without a body",
"text/plain; charset=Latin-1",
[]string{"UTF-8"},
"",
http.StatusOK,
},
{
"should skip validation for requests without a body and no Content-Type header",
"",
[]string{"UTF-8"},
"",
http.StatusOK,
},
}
for _, tt := range tests {
var tt = tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var recorder = httptest.NewRecorder()
var r = chi.NewRouter()
r.Use(ContentCharset(tt.inputContentCharset...))
r.Post("/", func(w http.ResponseWriter, r *http.Request) {})
var body io.Reader
if tt.body != "" {
body = strings.NewReader(tt.body)
}
var req, _ = http.NewRequest("POST", "/", body)
if tt.inputValue != "" {
req.Header.Set("Content-Type", tt.inputValue)
}
r.ServeHTTP(recorder, req)
var res = recorder.Result()
if res.StatusCode != tt.want {
t.Errorf("response is incorrect, got %d, want %d", recorder.Code, tt.want)
}
})
}
}
func TestSplit(t *testing.T) {
t.Parallel()
var s1, s2 = split(" type1;type2 ", ";")
if s1 != "type1" || s2 != "type2" {
t.Errorf("Want type1, type2 got %s, %s", s1, s2)
}
s1, s2 = split("type1 ", ";")
if s1 != "type1" {
t.Errorf("Want \"type1\" got \"%s\"", s1)
}
if s2 != "" {
t.Errorf("Want empty string got \"%s\"", s2)
}
}
func TestContentEncoding(t *testing.T) {
t.Parallel()
if !contentEncoding("application/json; foo=bar; charset=utf-8; spam=eggs", []string{"utf-8"}...) {
t.Error("Want true, got false")
}
if contentEncoding("text/plain; charset=latin-1", []string{"utf-8"}...) {
t.Error("Want false, got true")
}
if !contentEncoding("text/xml; charset=UTF-8", []string{"latin-1", "utf-8"}...) {
t.Error("Want true, got false")
}
}

View File

@@ -0,0 +1,34 @@
package middleware
import (
"net/http"
"strings"
)
// AllowContentEncoding enforces a whitelist of request Content-Encoding otherwise responds
// with a 415 Unsupported Media Type status.
func AllowContentEncoding(contentEncoding ...string) func(next http.Handler) http.Handler {
allowedEncodings := make(map[string]struct{}, len(contentEncoding))
for _, encoding := range contentEncoding {
allowedEncodings[strings.TrimSpace(strings.ToLower(encoding))] = struct{}{}
}
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
requestEncodings := r.Header["Content-Encoding"]
// skip check for empty content body or no Content-Encoding
if r.ContentLength == 0 {
next.ServeHTTP(w, r)
return
}
// All encodings in the request must be allowed
for _, encoding := range requestEncodings {
if _, ok := allowedEncodings[strings.TrimSpace(strings.ToLower(encoding))]; !ok {
w.WriteHeader(http.StatusUnsupportedMediaType)
return
}
}
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}

View File

@@ -0,0 +1,82 @@
package middleware
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
func TestContentEncodingMiddleware(t *testing.T) {
t.Parallel()
// support for:
// Content-Encoding: gzip
// Content-Encoding: deflate
// Content-Encoding: gzip, deflate
// Content-Encoding: deflate, gzip
middleware := AllowContentEncoding("deflate", "gzip")
tests := []struct {
name string
encodings []string
expectedStatus int
}{
{
name: "Support no encoding",
encodings: []string{},
expectedStatus: 200,
},
{
name: "Support gzip encoding",
encodings: []string{"gzip"},
expectedStatus: 200,
},
{
name: "No support for br encoding",
encodings: []string{"br"},
expectedStatus: 415,
},
{
name: "Support for gzip and deflate encoding",
encodings: []string{"gzip", "deflate"},
expectedStatus: 200,
},
{
name: "Support for deflate and gzip encoding",
encodings: []string{"deflate", "gzip"},
expectedStatus: 200,
},
{
name: "No support for deflate and br encoding",
encodings: []string{"deflate", "br"},
expectedStatus: 415,
},
}
for _, tt := range tests {
var tt = tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := []byte("This is my content. There are many like this but this one is mine")
r := httptest.NewRequest("POST", "/", bytes.NewReader(body))
for _, encoding := range tt.encodings {
r.Header.Set("Content-Encoding", encoding)
}
w := httptest.NewRecorder()
router := chi.NewRouter()
router.Use(middleware)
router.Post("/", func(w http.ResponseWriter, r *http.Request) {})
router.ServeHTTP(w, r)
res := w.Result()
if res.StatusCode != tt.expectedStatus {
t.Errorf("response is incorrect, got %d, want %d", w.Code, tt.expectedStatus)
}
})
}
}

View File

@@ -0,0 +1,45 @@
package middleware
import (
"net/http"
"strings"
)
// SetHeader is a convenience handler to set a response header key/value
func SetHeader(key, value string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(key, value)
next.ServeHTTP(w, r)
})
}
}
// AllowContentType enforces a whitelist of request Content-Types otherwise responds
// with a 415 Unsupported Media Type status.
func AllowContentType(contentTypes ...string) func(http.Handler) http.Handler {
allowedContentTypes := make(map[string]struct{}, len(contentTypes))
for _, ctype := range contentTypes {
allowedContentTypes[strings.TrimSpace(strings.ToLower(ctype))] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength == 0 {
// Skip check for empty content body
next.ServeHTTP(w, r)
return
}
s, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";")
s = strings.ToLower(strings.TrimSpace(s))
if _, ok := allowedContentTypes[s]; ok {
next.ServeHTTP(w, r)
return
}
w.WriteHeader(http.StatusUnsupportedMediaType)
})
}
}

View File

@@ -0,0 +1,82 @@
package middleware
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
func TestContentType(t *testing.T) {
t.Parallel()
var tests = []struct {
name string
inputValue string
allowedContentTypes []string
want int
}{
{
"should accept requests with a matching content type",
"application/json; charset=UTF-8",
[]string{"application/json"},
http.StatusOK,
},
{
"should accept requests with a matching content type no charset",
"application/json",
[]string{"application/json"},
http.StatusOK,
},
{
"should accept requests with a matching content-type with extra values",
"application/json; foo=bar; charset=UTF-8; spam=eggs",
[]string{"application/json"},
http.StatusOK,
},
{
"should accept requests with a matching content type when multiple content types are supported",
"text/xml; charset=UTF-8",
[]string{"application/json", "text/xml"},
http.StatusOK,
},
{
"should not accept requests with a mismatching content type",
"text/plain; charset=latin-1",
[]string{"application/json"},
http.StatusUnsupportedMediaType,
},
{
"should not accept requests with a mismatching content type even if multiple content types are allowed",
"text/plain; charset=Latin-1",
[]string{"application/json", "text/xml"},
http.StatusUnsupportedMediaType,
},
}
for _, tt := range tests {
var tt = tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
recorder := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(AllowContentType(tt.allowedContentTypes...))
r.Post("/", func(w http.ResponseWriter, r *http.Request) {})
body := []byte("This is my content. There are many like this but this one is mine")
req := httptest.NewRequest("POST", "/", bytes.NewReader(body))
req.Header.Set("Content-Type", tt.inputValue)
r.ServeHTTP(recorder, req)
res := recorder.Result()
if res.StatusCode != tt.want {
t.Errorf("response is incorrect, got %d, want %d", recorder.Code, tt.want)
}
})
}
}

39
middleware/get_head.go Normal file
View File

@@ -0,0 +1,39 @@
package middleware
import (
"net/http"
"github.com/go-chi/chi/v5"
)
// GetHead automatically route undefined HEAD requests to GET handlers.
func GetHead(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "HEAD" {
rctx := chi.RouteContext(r.Context())
routePath := rctx.RoutePath
if routePath == "" {
if r.URL.RawPath != "" {
routePath = r.URL.RawPath
} else {
routePath = r.URL.Path
}
}
// Temporary routing context to look-ahead before routing the request
tctx := chi.NewRouteContext()
// Attempt to find a HEAD handler for the routing path, if not found, traverse
// the router as through its a GET route, but proceed with the request
// with the HEAD method.
if !rctx.Routes.Match(tctx, "HEAD", routePath) {
rctx.RouteMethod = "GET"
rctx.RoutePath = routePath
next.ServeHTTP(w, r)
return
}
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,66 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
func TestGetHead(t *testing.T) {
r := chi.NewRouter()
r.Use(GetHead)
r.Get("/hi", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Test", "yes")
w.Write([]byte("bye"))
})
r.Route("/articles", func(r chi.Router) {
r.Get("/{id}", func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
w.Header().Set("X-Article", id)
w.Write([]byte("article:" + id))
})
})
r.Route("/users", func(r chi.Router) {
r.Head("/{id}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-User", "-")
w.Write([]byte("user"))
})
r.Get("/{id}", func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
w.Header().Set("X-User", id)
w.Write([]byte("user:" + id))
})
})
ts := httptest.NewServer(r)
defer ts.Close()
if _, body := testRequest(t, ts, "GET", "/hi", nil); body != "bye" {
t.Fatal(body)
}
if req, body := testRequest(t, ts, "HEAD", "/hi", nil); body != "" || req.Header.Get("X-Test") != "yes" {
t.Fatal(body)
}
if _, body := testRequest(t, ts, "GET", "/", nil); body != "404 page not found\n" {
t.Fatal(body)
}
if req, body := testRequest(t, ts, "HEAD", "/", nil); body != "" || req.StatusCode != 404 {
t.Fatal(body)
}
if _, body := testRequest(t, ts, "GET", "/articles/5", nil); body != "article:5" {
t.Fatal(body)
}
if req, body := testRequest(t, ts, "HEAD", "/articles/5", nil); body != "" || req.Header.Get("X-Article") != "5" {
t.Fatalf("expecting X-Article header '5' but got '%s'", req.Header.Get("X-Article"))
}
if _, body := testRequest(t, ts, "GET", "/users/1", nil); body != "user:1" {
t.Fatal(body)
}
if req, body := testRequest(t, ts, "HEAD", "/users/1", nil); body != "" || req.Header.Get("X-User") != "-" {
t.Fatalf("expecting X-User header '-' but got '%s'", req.Header.Get("X-User"))
}
}

26
middleware/heartbeat.go Normal file
View File

@@ -0,0 +1,26 @@
package middleware
import (
"net/http"
"strings"
)
// Heartbeat endpoint middleware useful to setting up a path like
// `/ping` that load balancers or uptime testing external services
// can make a request before hitting any routes. It's also convenient
// to place this above ACL middlewares as well.
func Heartbeat(endpoint string) func(http.Handler) http.Handler {
f := func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if (r.Method == "GET" || r.Method == "HEAD") && strings.EqualFold(r.URL.Path, endpoint) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("."))
return
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
return f
}

View File

@@ -0,0 +1,31 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
func TestHeartbeat(t *testing.T) {
endpoint := "/ping"
r := chi.NewRouter()
r.Use(Heartbeat(endpoint))
r.Handle(endpoint, http.NotFoundHandler())
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, endpoint, http.NoBody)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("heartbeat: unexpected response code: %v", w.Code)
}
if w.Body.String() != "." {
t.Errorf("heartbeat: unexpected response body: %q", w.Body.String())
}
if got := w.Header().Get("Content-Type"); got != "text/plain" {
t.Errorf("heartbeat: unexpected Content-Type: %q", got)
}
}

178
middleware/logger.go Normal file
View File

@@ -0,0 +1,178 @@
package middleware
import (
"bytes"
"context"
"log"
"net/http"
"os"
"runtime"
"time"
)
var (
// LogEntryCtxKey is the context.Context key to store the request log entry.
LogEntryCtxKey = &contextKey{"LogEntry"}
// DefaultLogger is called by the Logger middleware handler to log each request.
// Its made a package-level variable so that it can be reconfigured for custom
// logging configurations.
DefaultLogger func(next http.Handler) http.Handler
)
// Logger is a middleware that logs the start and end of each request, along
// with some useful data about what was requested, what the response status was,
// and how long it took to return. When standard output is a TTY, Logger will
// print in color, otherwise it will print in black and white. Logger prints a
// request ID if one is provided.
//
// Alternatively, look at https://github.com/goware/httplog for a more in-depth
// http logger with structured logging support.
//
// IMPORTANT NOTE: Logger should go before any other middleware that may change
// the response, such as middleware.Recoverer. Example:
//
// r := chi.NewRouter()
// r.Use(middleware.Logger) // <--<< Logger should come before Recoverer
// r.Use(middleware.Recoverer)
// r.Get("/", handler)
func Logger(next http.Handler) http.Handler {
return DefaultLogger(next)
}
// RequestLogger returns a logger handler using a custom LogFormatter.
func RequestLogger(f LogFormatter) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
entry := f.NewLogEntry(r)
ww := NewWrapResponseWriter(w, r.ProtoMajor)
t1 := time.Now()
defer func() {
entry.Write(ww.Status(), ww.BytesWritten(), ww.Header(), time.Since(t1), nil)
}()
next.ServeHTTP(ww, WithLogEntry(r, entry))
}
return http.HandlerFunc(fn)
}
}
// LogFormatter initiates the beginning of a new LogEntry per request.
// See DefaultLogFormatter for an example implementation.
type LogFormatter interface {
NewLogEntry(r *http.Request) LogEntry
}
// LogEntry records the final log when a request completes.
// See defaultLogEntry for an example implementation.
type LogEntry interface {
Write(status, bytes int, header http.Header, elapsed time.Duration, extra any)
Panic(v any, stack []byte)
}
// GetLogEntry returns the in-context LogEntry for a request.
func GetLogEntry(r *http.Request) LogEntry {
entry, _ := r.Context().Value(LogEntryCtxKey).(LogEntry)
return entry
}
// WithLogEntry sets the in-context LogEntry for a request.
func WithLogEntry(r *http.Request, entry LogEntry) *http.Request {
r = r.WithContext(context.WithValue(r.Context(), LogEntryCtxKey, entry))
return r
}
// LoggerInterface accepts printing to stdlib logger or compatible logger.
type LoggerInterface interface {
Print(v ...any)
}
// DefaultLogFormatter is a simple logger that implements a LogFormatter.
type DefaultLogFormatter struct {
Logger LoggerInterface
NoColor bool
}
// NewLogEntry creates a new LogEntry for the request.
func (l *DefaultLogFormatter) NewLogEntry(r *http.Request) LogEntry {
ctx := r.Context()
useColor := !l.NoColor
entry := &defaultLogEntry{
DefaultLogFormatter: l,
request: r,
buf: &bytes.Buffer{},
useColor: useColor,
}
reqID := GetReqID(ctx)
if reqID != "" {
cW(entry.buf, useColor, nYellow, "[%s] ", reqID)
}
cW(entry.buf, useColor, nCyan, "\"")
cW(entry.buf, useColor, bMagenta, "%s ", r.Method)
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
cW(entry.buf, useColor, nCyan, "%s://%s%s %s\" ", scheme, r.Host, r.RequestURI, r.Proto)
entry.buf.WriteString("from ")
clientIP := GetClientIP(ctx)
if clientIP == "" {
clientIP = r.RemoteAddr
}
entry.buf.WriteString(clientIP)
entry.buf.WriteString(" - ")
return entry
}
type defaultLogEntry struct {
*DefaultLogFormatter
request *http.Request
buf *bytes.Buffer
useColor bool
}
func (l *defaultLogEntry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra any) {
switch {
case status < 200:
cW(l.buf, l.useColor, bBlue, "%03d", status)
case status < 300:
cW(l.buf, l.useColor, bGreen, "%03d", status)
case status < 400:
cW(l.buf, l.useColor, bCyan, "%03d", status)
case status < 500:
cW(l.buf, l.useColor, bYellow, "%03d", status)
default:
cW(l.buf, l.useColor, bRed, "%03d", status)
}
cW(l.buf, l.useColor, bBlue, " %dB", bytes)
l.buf.WriteString(" in ")
if elapsed < 500*time.Millisecond {
cW(l.buf, l.useColor, nGreen, "%s", elapsed)
} else if elapsed < 5*time.Second {
cW(l.buf, l.useColor, nYellow, "%s", elapsed)
} else {
cW(l.buf, l.useColor, nRed, "%s", elapsed)
}
l.Logger.Print(l.buf.String())
}
func (l *defaultLogEntry) Panic(v any, stack []byte) {
printPrettyStack(v, l.useColor)
}
func init() {
color := true
if runtime.GOOS == "windows" {
color = false
}
DefaultLogger = RequestLogger(&DefaultLogFormatter{Logger: log.New(os.Stdout, "", log.LstdFlags), NoColor: !color})
}

51
middleware/logger_test.go Normal file
View File

@@ -0,0 +1,51 @@
package middleware
import (
"bufio"
"bytes"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
)
type testLoggerWriter struct {
*httptest.ResponseRecorder
}
func (cw testLoggerWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return nil, nil, nil
}
func TestRequestLogger(t *testing.T) {
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, ok := w.(http.Hijacker)
if !ok {
t.Errorf("http.Hijacker is unavailable on the writer. add the interface methods.")
}
})
r := httptest.NewRequest("GET", "/", nil)
w := testLoggerWriter{
ResponseRecorder: httptest.NewRecorder(),
}
handler := DefaultLogger(testHandler)
handler.ServeHTTP(w, r)
}
func TestRequestLoggerReadFrom(t *testing.T) {
data := []byte("file data")
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, "file", time.Time{}, bytes.NewReader(data))
})
r := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
handler := DefaultLogger(testHandler)
handler.ServeHTTP(w, r)
assertEqual(t, data, w.Body.Bytes())
}

18
middleware/maybe.go Normal file
View File

@@ -0,0 +1,18 @@
package middleware
import "net/http"
// Maybe middleware will allow you to change the flow of the middleware stack execution depending on return
// value of maybeFn(request). This is useful for example if you'd like to skip a middleware handler if
// a request does not satisfy the maybeFn logic.
func Maybe(mw func(http.Handler) http.Handler, maybeFn func(r *http.Request) bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if maybeFn(r) {
mw(next).ServeHTTP(w, r)
} else {
next.ServeHTTP(w, r)
}
})
}
}

23
middleware/middleware.go Normal file
View File

@@ -0,0 +1,23 @@
package middleware
import "net/http"
// New will create a new middleware handler from a http.Handler.
func New(h http.Handler) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.ServeHTTP(w, r)
})
}
}
// contextKey is a value for use with context.WithValue. It's used as
// a pointer so it fits in an interface{} without allocation. This technique
// for defining context keys was copied from Go 1.7's new use of context in net/http.
type contextKey struct {
name string
}
func (k *contextKey) String() string {
return "chi/middleware context value " + k.name
}

View File

@@ -0,0 +1,213 @@
package middleware
import (
"crypto/tls"
"io"
"net/http"
"net/http/httptest"
"path"
"reflect"
"runtime"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
var testdataDir string
func init() {
_, filename, _, _ := runtime.Caller(0)
testdataDir = path.Join(path.Dir(filename), "/../testdata")
}
type customMiddleware struct{}
func (c *customMiddleware) ServeHTTP(w http.ResponseWriter, req *http.Request) {
for name, headers := range req.Header {
if strings.ToLower(name) == "x-custom-header" {
if len(headers) == 1 {
w.WriteHeader(http.StatusOK)
return
}
}
}
w.WriteHeader(http.StatusUnauthorized)
}
func TestNew(t *testing.T) {
endpoint := "/"
r := chi.NewRouter()
r.Use(New(&customMiddleware{}))
r.Get(endpoint, func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
t.Run("allows request with custom header", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, endpoint, http.NoBody)
req.Header.Set("X-Custom-Header", "abc123")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("new: unexpected response code: got %d, want %d", w.Code, http.StatusOK)
}
})
t.Run("rejects request without custom header", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, endpoint, http.NoBody)
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("new: unexpected response code: got %d, want %d", w.Code, http.StatusUnauthorized)
}
})
}
func Test_contextKey_String(t *testing.T) {
k := &contextKey{
name: "test",
}
if got := k.String(); got != "chi/middleware context value test" {
t.Errorf("got: %q, expected: %q", got, "chi/middleware context value test")
}
}
func TestWrapWriterHTTP2(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Proto != "HTTP/2.0" {
t.Fatalf("request proto should be HTTP/2.0 but was %s", r.Proto)
}
_, fl := w.(http.Flusher)
if !fl {
t.Fatal("request should have been a http.Flusher")
}
_, hj := w.(http.Hijacker)
if hj {
t.Fatal("request should not have been a http.Hijacker")
}
_, rf := w.(io.ReaderFrom)
if rf {
t.Fatal("request should not have been an io.ReaderFrom")
}
_, ps := w.(http.Pusher)
if !ps {
t.Fatal("request should have been a http.Pusher")
}
w.Write([]byte("OK"))
})
wmw := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(NewWrapResponseWriter(w, r.ProtoMajor), r)
})
}
server := http.Server{
Addr: ":7072",
Handler: wmw(handler),
}
// By serving over TLS, we get HTTP2 requests
go server.ListenAndServeTLS(testdataDir+"/cert.pem", testdataDir+"/key.pem")
defer server.Close()
// We need the server to start before making the request
time.Sleep(100 * time.Millisecond)
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
// The certificates we are using are self signed
InsecureSkipVerify: true,
},
ForceAttemptHTTP2: true,
},
}
resp, err := client.Get("https://localhost:7072")
if err != nil {
t.Fatalf("could not get server: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("non 200 response: %v", resp.StatusCode)
}
}
func testRequest(t *testing.T, ts *httptest.Server, method, path string, body io.Reader) (*http.Response, string) {
req, err := http.NewRequest(method, ts.URL+path, body)
if err != nil {
t.Fatal(err)
return nil, ""
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
return nil, ""
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
return nil, ""
}
defer resp.Body.Close()
return resp, string(respBody)
}
func testRequestNoRedirect(t *testing.T, ts *httptest.Server, method, path string, body io.Reader) (*http.Response, string) {
req, err := http.NewRequest(method, ts.URL+path, body)
if err != nil {
t.Fatal(err)
return nil, ""
}
// http client that doesn't redirect
httpClient := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := httpClient.Do(req)
if err != nil {
t.Fatal(err)
return nil, ""
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
return nil, ""
}
defer resp.Body.Close()
return resp, string(respBody)
}
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("expecting no error")
}
}
func assertError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatalf("expecting error")
}
}
func assertEqual(t *testing.T, a, b any) {
t.Helper()
if !reflect.DeepEqual(a, b) {
t.Fatalf("expecting values to be equal but got: '%v' and '%v'", a, b)
}
}

59
middleware/nocache.go Normal file
View File

@@ -0,0 +1,59 @@
package middleware
// Ported from Goji's middleware, source:
// https://github.com/zenazn/goji/tree/master/web/middleware
import (
"net/http"
"time"
)
// Unix epoch time
var epoch = time.Unix(0, 0).UTC().Format(http.TimeFormat)
// Taken from https://github.com/mytrile/nocache
var noCacheHeaders = map[string]string{
"Expires": epoch,
"Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0",
"Pragma": "no-cache",
"X-Accel-Expires": "0",
}
var etagHeaders = []string{
"ETag",
"If-Modified-Since",
"If-Match",
"If-None-Match",
"If-Range",
"If-Unmodified-Since",
}
// NoCache is a simple piece of middleware that sets a number of HTTP headers to prevent
// a router (or subrouter) from being cached by an upstream proxy and/or client.
//
// As per http://wiki.nginx.org/HttpProxyModule - NoCache sets:
//
// Expires: Thu, 01 Jan 1970 00:00:00 UTC
// Cache-Control: no-cache, private, max-age=0
// X-Accel-Expires: 0
// Pragma: no-cache (for HTTP/1.0 proxies/clients)
func NoCache(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
// Delete any ETag headers that may have been set
for _, v := range etagHeaders {
if r.Header.Get(v) != "" {
r.Header.Del(v)
}
}
// Set our NoCache headers
for k, v := range noCacheHeaders {
w.Header().Set(k, v)
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}

20
middleware/page_route.go Normal file
View File

@@ -0,0 +1,20 @@
package middleware
import (
"net/http"
"strings"
)
// PageRoute is a simple middleware which allows you to route a static GET request
// at the middleware stack level.
func PageRoute(path string, handler http.Handler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && strings.EqualFold(r.URL.Path, path) {
handler.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,16 @@
package middleware
import (
"net/http"
"strings"
)
// PathRewrite is a simple middleware which allows you to rewrite the request URL path.
func PathRewrite(old, new string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = strings.Replace(r.URL.Path, old, new, 1)
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,26 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
func TestPathRewrite(t *testing.T) {
r := chi.NewRouter()
r.Use(PathRewrite("/endpoint", "/v1/endpoint"))
r.Get("/v1/endpoint", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/endpoint", nil)
r.ServeHTTP(w, req)
if w.Result().StatusCode != http.StatusOK {
t.Errorf("path rewrite: unexpected response code: %v", w.Result().StatusCode)
}
}

48
middleware/profiler.go Normal file
View File

@@ -0,0 +1,48 @@
//go:build !tinygo
package middleware
import (
"expvar"
"net/http"
"net/http/pprof"
"github.com/go-chi/chi/v5"
)
// Profiler is a convenient subrouter used for mounting net/http/pprof. ie.
//
// func MyService() http.Handler {
// r := chi.NewRouter()
// // ..middlewares
// r.Mount("/debug", middleware.Profiler())
// // ..routes
// return r
// }
func Profiler() http.Handler {
r := chi.NewRouter()
r.Use(NoCache)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, r.RequestURI+"/pprof/", http.StatusMovedPermanently)
})
r.HandleFunc("/pprof", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, r.RequestURI+"/", http.StatusMovedPermanently)
})
r.HandleFunc("/pprof/*", pprof.Index)
r.HandleFunc("/pprof/cmdline", pprof.Cmdline)
r.HandleFunc("/pprof/profile", pprof.Profile)
r.HandleFunc("/pprof/symbol", pprof.Symbol)
r.HandleFunc("/pprof/trace", pprof.Trace)
r.Handle("/vars", expvar.Handler())
r.Handle("/pprof/goroutine", pprof.Handler("goroutine"))
r.Handle("/pprof/threadcreate", pprof.Handler("threadcreate"))
r.Handle("/pprof/mutex", pprof.Handler("mutex"))
r.Handle("/pprof/heap", pprof.Handler("heap"))
r.Handle("/pprof/block", pprof.Handler("block"))
r.Handle("/pprof/allocs", pprof.Handler("allocs"))
return r
}

53
middleware/realip.go Normal file
View File

@@ -0,0 +1,53 @@
package middleware
// Ported from Goji's middleware, source:
// https://github.com/zenazn/goji/tree/master/web/middleware
import (
"net"
"net/http"
"strings"
)
var trueClientIP = http.CanonicalHeaderKey("True-Client-IP")
var xForwardedFor = http.CanonicalHeaderKey("X-Forwarded-For")
var xRealIP = http.CanonicalHeaderKey("X-Real-IP")
// RealIP is a middleware that sets a http.Request's RemoteAddr to the results
// of parsing either the True-Client-IP, X-Real-IP or the X-Forwarded-For headers
// (in that order).
//
// Deprecated: RealIP is vulnerable to IP spoofing — it mutates r.RemoteAddr
// to the leftmost X-Forwarded-For value, or to True-Client-IP / X-Real-IP
// whether or not your infrastructure actually sets them. See
// GHSA-3fxj-6jh8-hvhx, GHSA-rjr7-jggh-pgcp, GHSA-9g5q-2w5x-hmxf.
//
// Use [ClientIPFromHeader], [ClientIPFromXFF], [ClientIPFromXFFTrustedProxies]
// or [ClientIPFromRemoteAddr] and read the IP with [GetClientIP] instead.
// These never mutate r.RemoteAddr.
func RealIP(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
if rip := realIP(r); rip != "" {
r.RemoteAddr = rip
}
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
func realIP(r *http.Request) string {
var ip string
if tcip := r.Header.Get(trueClientIP); tcip != "" {
ip = tcip
} else if xrip := r.Header.Get(xRealIP); xrip != "" {
ip = xrip
} else if xff := r.Header.Get(xForwardedFor); xff != "" {
ip, _, _ = strings.Cut(xff, ",")
}
if ip == "" || net.ParseIP(ip) == nil {
return ""
}
return ip
}

115
middleware/realip_test.go Normal file
View File

@@ -0,0 +1,115 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
func TestXRealIP(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Add("X-Real-IP", "100.100.100.100")
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(RealIP)
realIP := ""
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
realIP = r.RemoteAddr
w.Write([]byte("Hello World"))
})
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatal("Response Code should be 200")
}
if realIP != "100.100.100.100" {
t.Fatal("Test get real IP error.")
}
}
func TestXForwardForIP(t *testing.T) {
xForwardedForIPs := []string{
"100.100.100.100",
"100.100.100.100, 200.200.200.200",
"100.100.100.100,200.200.200.200",
}
r := chi.NewRouter()
r.Use(RealIP)
for _, v := range xForwardedForIPs {
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Add("X-Forwarded-For", v)
w := httptest.NewRecorder()
realIP := ""
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
realIP = r.RemoteAddr
w.Write([]byte("Hello World"))
})
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatal("Response Code should be 200")
}
if realIP != "100.100.100.100" {
t.Fatal("Test get real IP error.")
}
}
}
func TestXForwardForXRealIPPrecedence(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Add("X-Forwarded-For", "0.0.0.0")
req.Header.Add("X-Real-IP", "100.100.100.100")
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(RealIP)
realIP := ""
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
realIP = r.RemoteAddr
w.Write([]byte("Hello World"))
})
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatal("Response Code should be 200")
}
if realIP != "100.100.100.100" {
t.Fatal("Test get real IP precedence error.")
}
}
func TestInvalidIP(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Add("X-Real-IP", "100.100.100.1000")
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(RealIP)
realIP := ""
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
realIP = r.RemoteAddr
w.Write([]byte("Hello World"))
})
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatal("Response Code should be 200")
}
if realIP != "" {
t.Fatal("Invalid IP used.")
}
}

209
middleware/recoverer.go Normal file
View File

@@ -0,0 +1,209 @@
package middleware
// The original work was derived from Goji's middleware, source:
// https://github.com/zenazn/goji/tree/master/web/middleware
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"os"
"runtime/debug"
"strings"
)
// Recoverer is a middleware that recovers from panics, logs the panic (and a
// backtrace), and returns a HTTP 500 (Internal Server Error) status if
// possible. Recoverer prints a request ID if one is provided.
//
// Alternatively, look at https://github.com/go-chi/httplog middleware pkgs.
func Recoverer(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rvr := recover(); rvr != nil {
if rvr == http.ErrAbortHandler {
// we don't recover http.ErrAbortHandler so the response
// to the client is aborted, this should not be logged
panic(rvr)
}
logEntry := GetLogEntry(r)
if logEntry != nil {
logEntry.Panic(rvr, debug.Stack())
} else {
PrintPrettyStack(rvr)
}
if r.Header.Get("Connection") != "Upgrade" {
w.WriteHeader(http.StatusInternalServerError)
}
}
}()
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
// for ability to test the PrintPrettyStack function
var recovererErrorWriter io.Writer = os.Stderr
func PrintPrettyStack(rvr any) {
printPrettyStack(rvr, true)
}
// printPrettyStack prints a formatted stack trace to stderr. When useColor is
// false, ANSI colour codes are suppressed, which is useful for terminals that
// do not support them (e.g. on Windows) or when output is being captured.
func printPrettyStack(rvr any, useColor bool) {
debugStack := debug.Stack()
s := prettyStack{}
out, err := s.parse(debugStack, rvr, useColor)
if err == nil {
recovererErrorWriter.Write(out)
} else {
// print stdlib output as a fallback
os.Stderr.Write(debugStack)
}
}
type prettyStack struct {
}
func (s prettyStack) parse(debugStack []byte, rvr any, useColor bool) ([]byte, error) {
var err error
buf := &bytes.Buffer{}
cW(buf, false, bRed, "\n")
cW(buf, useColor, bCyan, " panic: ")
cW(buf, useColor, bBlue, "%v", rvr)
cW(buf, false, bWhite, "\n \n")
// process debug stack info
stack := strings.Split(string(debugStack), "\n")
lines := []string{}
// locate panic line, as we may have nested panics
for i := len(stack) - 1; i > 0; i-- {
lines = append(lines, stack[i])
if strings.HasPrefix(stack[i], "panic(") {
lines = lines[0 : len(lines)-2] // remove boilerplate
break
}
}
// reverse
for i := len(lines)/2 - 1; i >= 0; i-- {
opp := len(lines) - 1 - i
lines[i], lines[opp] = lines[opp], lines[i]
}
// decorate
for i, line := range lines {
lines[i], err = s.decorateLine(line, useColor, i)
if err != nil {
return nil, err
}
}
for _, l := range lines {
fmt.Fprintf(buf, "%s", l)
}
return buf.Bytes(), nil
}
func (s prettyStack) decorateLine(line string, useColor bool, num int) (string, error) {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "\t") || strings.Contains(line, ".go:") {
return s.decorateSourceLine(line, useColor, num)
}
if strings.HasSuffix(line, ")") {
return s.decorateFuncCallLine(line, useColor, num)
}
if strings.HasPrefix(line, "\t") {
return strings.Replace(line, "\t", " ", 1), nil
}
return fmt.Sprintf(" %s\n", line), nil
}
func (s prettyStack) decorateFuncCallLine(line string, useColor bool, num int) (string, error) {
idx := strings.LastIndex(line, "(")
if idx < 0 {
return "", errors.New("not a func call line")
}
buf := &bytes.Buffer{}
pkg := line[0:idx]
// addr := line[idx:]
method := ""
if idx := strings.LastIndex(pkg, string(os.PathSeparator)); idx < 0 {
if idx := strings.Index(pkg, "."); idx > 0 {
method = pkg[idx:]
pkg = pkg[0:idx]
}
} else {
method = pkg[idx+1:]
pkg = pkg[0 : idx+1]
if idx := strings.Index(method, "."); idx > 0 {
pkg += method[0:idx]
method = method[idx:]
}
}
pkgColor := nYellow
methodColor := bGreen
if num == 0 {
cW(buf, useColor, bRed, " -> ")
pkgColor = bMagenta
methodColor = bRed
} else {
cW(buf, useColor, bWhite, " ")
}
cW(buf, useColor, pkgColor, "%s", pkg)
cW(buf, useColor, methodColor, "%s\n", method)
// cW(buf, useColor, nBlack, "%s", addr)
return buf.String(), nil
}
func (s prettyStack) decorateSourceLine(line string, useColor bool, num int) (string, error) {
idx := strings.LastIndex(line, ".go:")
if idx < 0 {
return "", errors.New("not a source line")
}
buf := &bytes.Buffer{}
path := line[0 : idx+3]
lineno := line[idx+3:]
idx = strings.LastIndex(path, string(os.PathSeparator))
dir := path[0 : idx+1]
file := path[idx+1:]
idx = strings.Index(lineno, " ")
if idx > 0 {
lineno = lineno[0:idx]
}
fileColor := bCyan
lineColor := bGreen
if num == 1 {
cW(buf, useColor, bRed, " -> ")
fileColor = bRed
lineColor = bMagenta
} else {
cW(buf, false, bWhite, " ")
}
cW(buf, useColor, bWhite, "%s", dir)
cW(buf, useColor, fileColor, "%s", file)
cW(buf, useColor, lineColor, "%s", lineno)
if num == 1 {
cW(buf, false, bWhite, "\n")
}
cW(buf, false, bWhite, "\n")
return buf.String(), nil
}

View File

@@ -0,0 +1,101 @@
package middleware
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
func panickingHandler(http.ResponseWriter, *http.Request) { panic("foo") }
type testPrintLogger struct{}
func (testPrintLogger) Print(v ...any) {}
func TestRecoverer(t *testing.T) {
r := chi.NewRouter()
oldRecovererErrorWriter := recovererErrorWriter
defer func() { recovererErrorWriter = oldRecovererErrorWriter }()
buf := &bytes.Buffer{}
recovererErrorWriter = buf
r.Use(Recoverer)
r.Get("/", panickingHandler)
ts := httptest.NewServer(r)
defer ts.Close()
res, _ := testRequest(t, ts, "GET", "/", nil)
assertEqual(t, res.StatusCode, http.StatusInternalServerError)
lines := strings.Split(buf.String(), "\n")
for _, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "->") {
if !strings.Contains(line, "panickingHandler") {
t.Fatalf("First func call line should refer to panickingHandler, but actual line:\n%v\n", line)
}
return
}
}
t.Fatal("First func call line should start with ->.")
}
func TestRecovererNoColor(t *testing.T) {
// Force IsTTY so cW would emit color codes if useColor were true; this is
// what makes the assertion below a real regression guard rather than a
// no-op on non-TTY test runners (e.g. CI).
oldIsTTY := IsTTY
IsTTY = true
defer func() { IsTTY = oldIsTTY }()
oldRecovererErrorWriter := recovererErrorWriter
defer func() { recovererErrorWriter = oldRecovererErrorWriter }()
buf := &bytes.Buffer{}
recovererErrorWriter = buf
r := chi.NewRouter()
r.Use(RequestLogger(&DefaultLogFormatter{Logger: testPrintLogger{}, NoColor: true}))
r.Use(Recoverer)
r.Get("/", panickingHandler)
ts := httptest.NewServer(r)
defer ts.Close()
res, _ := testRequest(t, ts, "GET", "/", nil)
assertEqual(t, res.StatusCode, http.StatusInternalServerError)
// ANSI escape sequences start with \x1b[
if strings.Contains(buf.String(), "\x1b[") {
t.Fatal("Output should not contain ANSI color codes when NoColor is true")
}
}
func TestRecovererAbortHandler(t *testing.T) {
defer func() {
rcv := recover()
if rcv != http.ErrAbortHandler {
t.Fatalf("http.ErrAbortHandler should not be recovered")
}
}()
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(Recoverer)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
panic(http.ErrAbortHandler)
})
req, err := http.NewRequest("GET", "/", nil)
if err != nil {
t.Fatal(err)
}
r.ServeHTTP(w, req)
}

96
middleware/request_id.go Normal file
View File

@@ -0,0 +1,96 @@
package middleware
// Ported from Goji's middleware, source:
// https://github.com/zenazn/goji/tree/master/web/middleware
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"os"
"strings"
"sync/atomic"
)
// Key to use when setting the request ID.
type ctxKeyRequestID int
// RequestIDKey is the key that holds the unique request ID in a request context.
const RequestIDKey ctxKeyRequestID = 0
// RequestIDHeader is the name of the HTTP Header which contains the request id.
// Exported so that it can be changed by developers
var RequestIDHeader = "X-Request-Id"
var prefix string
var reqid atomic.Uint64
// A quick note on the statistics here: we're trying to calculate the chance that
// two randomly generated base62 prefixes will collide. We use the formula from
// http://en.wikipedia.org/wiki/Birthday_problem
//
// P[m, n] \approx 1 - e^{-m^2/2n}
//
// We ballpark an upper bound for $m$ by imagining (for whatever reason) a server
// that restarts every second over 10 years, for $m = 86400 * 365 * 10 = 315360000$
//
// For a $k$ character base-62 identifier, we have $n(k) = 62^k$
//
// Plugging this in, we find $P[m, n(10)] \approx 5.75%$, which is good enough for
// our purposes, and is surely more than anyone would ever need in practice -- a
// process that is rebooted a handful of times a day for a hundred years has less
// than a millionth of a percent chance of generating two colliding IDs.
func init() {
hostname, err := os.Hostname()
if hostname == "" || err != nil {
hostname = "localhost"
}
var buf [12]byte
var b64 string
for len(b64) < 10 {
rand.Read(buf[:])
b64 = base64.StdEncoding.EncodeToString(buf[:])
b64 = strings.NewReplacer("+", "", "/", "").Replace(b64)
}
prefix = fmt.Sprintf("%s/%s", hostname, b64[0:10])
}
// RequestID is a middleware that injects a request ID into the context of each
// request. A request ID is a string of the form "host.example.com/random-0001",
// where "random" is a base62 random string that uniquely identifies this go
// process, and where the last number is an atomically incremented request
// counter.
func RequestID(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
requestID := r.Header.Get(RequestIDHeader)
if requestID == "" {
myid := reqid.Add(1)
requestID = fmt.Sprintf("%s-%06d", prefix, myid)
}
ctx = context.WithValue(ctx, RequestIDKey, requestID)
next.ServeHTTP(w, r.WithContext(ctx))
}
return http.HandlerFunc(fn)
}
// GetReqID returns a request ID from the given context if one is present.
// Returns the empty string if a request ID cannot be found.
func GetReqID(ctx context.Context) string {
if ctx == nil {
return ""
}
if reqID, ok := ctx.Value(RequestIDKey).(string); ok {
return reqID
}
return ""
}
// NextRequestID generates the next request ID in the sequence.
func NextRequestID() uint64 {
return reqid.Add(1)
}

View File

@@ -0,0 +1,71 @@
package middleware
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
func maintainDefaultRequestID() func() {
original := RequestIDHeader
return func() {
RequestIDHeader = original
}
}
func TestRequestID(t *testing.T) {
tests := map[string]struct {
requestIDHeader string
request func() *http.Request
expectedResponse string
}{
"Retrieves Request Id from default header": {
"X-Request-Id",
func() *http.Request {
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Add("X-Request-Id", "req-123456")
return req
},
"RequestID: req-123456",
},
"Retrieves Request Id from custom header": {
"X-Trace-Id",
func() *http.Request {
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Add("X-Trace-Id", "trace:abc123")
return req
},
"RequestID: trace:abc123",
},
}
defer maintainDefaultRequestID()()
for _, test := range tests {
w := httptest.NewRecorder()
r := chi.NewRouter()
RequestIDHeader = test.requestIDHeader
r.Use(RequestID)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
requestID := GetReqID(r.Context())
response := fmt.Sprintf("RequestID: %s", requestID)
w.Write([]byte(response))
})
r.ServeHTTP(w, test.request())
if w.Body.String() != test.expectedResponse {
t.Fatalf("RequestID was not the expected value")
}
}
}

View File

@@ -0,0 +1,18 @@
package middleware
import (
"net/http"
)
// RequestSize is a middleware that will limit request sizes to a specified
// number of bytes. It uses MaxBytesReader to do so.
func RequestSize(bytes int64) func(http.Handler) http.Handler {
f := func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, bytes)
h.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
return f
}

146
middleware/route_headers.go Normal file
View File

@@ -0,0 +1,146 @@
package middleware
import (
"net/http"
"strings"
)
// RouteHeaders is a neat little header-based router that allows you to direct
// the flow of a request through a middleware stack based on a request header.
//
// For example, lets say you'd like to setup multiple routers depending on the
// request Host header, you could then do something as so:
//
// r := chi.NewRouter()
// rSubdomain := chi.NewRouter()
// r.Use(middleware.RouteHeaders().
// Route("Host", "example.com", middleware.New(r)).
// Route("Host", "*.example.com", middleware.New(rSubdomain)).
// Handler)
// r.Get("/", h)
// rSubdomain.Get("/", h2)
//
// Another example, imagine you want to setup multiple CORS handlers, where for
// your origin servers you allow authorized requests, but for third-party public
// requests, authorization is disabled.
//
// r := chi.NewRouter()
// r.Use(middleware.RouteHeaders().
// Route("Origin", "https://app.skyweaver.net", cors.Handler(cors.Options{
// AllowedOrigins: []string{"https://api.skyweaver.net"},
// AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
// AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
// AllowCredentials: true, // <----------<<< allow credentials
// })).
// Route("Origin", "*", cors.Handler(cors.Options{
// AllowedOrigins: []string{"*"},
// AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
// AllowedHeaders: []string{"Accept", "Content-Type"},
// AllowCredentials: false, // <----------<<< do not allow credentials
// })).
// Handler)
func RouteHeaders() HeaderRouter {
return HeaderRouter{}
}
type HeaderRouter map[string][]HeaderRoute
func (hr HeaderRouter) Route(header, match string, middlewareHandler func(next http.Handler) http.Handler) HeaderRouter {
header = strings.ToLower(header)
k := hr[header]
if k == nil {
hr[header] = []HeaderRoute{}
}
hr[header] = append(hr[header], HeaderRoute{MatchOne: NewPattern(match), Middleware: middlewareHandler})
return hr
}
func (hr HeaderRouter) RouteAny(header string, match []string, middlewareHandler func(next http.Handler) http.Handler) HeaderRouter {
header = strings.ToLower(header)
k := hr[header]
if k == nil {
hr[header] = []HeaderRoute{}
}
patterns := []Pattern{}
for _, m := range match {
patterns = append(patterns, NewPattern(m))
}
hr[header] = append(hr[header], HeaderRoute{MatchAny: patterns, Middleware: middlewareHandler})
return hr
}
func (hr HeaderRouter) RouteDefault(handler func(next http.Handler) http.Handler) HeaderRouter {
hr["*"] = []HeaderRoute{{Middleware: handler}}
return hr
}
func (hr HeaderRouter) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(hr) == 0 {
// skip if no routes set
next.ServeHTTP(w, r)
return
}
// find first matching header route, and continue
for header, matchers := range hr {
headerValue := r.Header.Get(header)
if headerValue == "" {
continue
}
headerValue = strings.ToLower(headerValue)
for _, matcher := range matchers {
if matcher.IsMatch(headerValue) {
matcher.Middleware(next).ServeHTTP(w, r)
return
}
}
}
// if no match, check for "*" default route
matcher, ok := hr["*"]
if !ok || matcher[0].Middleware == nil {
next.ServeHTTP(w, r)
return
}
matcher[0].Middleware(next).ServeHTTP(w, r)
})
}
type HeaderRoute struct {
Middleware func(next http.Handler) http.Handler
MatchOne Pattern
MatchAny []Pattern
}
func (r HeaderRoute) IsMatch(value string) bool {
if len(r.MatchAny) > 0 {
for _, m := range r.MatchAny {
if m.Match(value) {
return true
}
}
} else if r.MatchOne.Match(value) {
return true
}
return false
}
type Pattern struct {
prefix string
suffix string
wildcard bool
}
func NewPattern(value string) Pattern {
p := Pattern{}
p.prefix, p.suffix, p.wildcard = strings.Cut(value, "*")
return p
}
func (p Pattern) Match(v string) bool {
if !p.wildcard {
return p.prefix == v
}
return len(v) >= len(p.prefix+p.suffix) && strings.HasPrefix(v, p.prefix) && strings.HasSuffix(v, p.suffix)
}

View File

@@ -0,0 +1,212 @@
package middleware
import (
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
func TestRouteHeaders(t *testing.T) {
t.Run("empty router should call next handler exactly once", func(t *testing.T) {
var callCount atomic.Int32
hr := RouteHeaders()
handler := hr.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount.Add(1)
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if callCount.Load() != 1 {
t.Errorf("expected next handler to be called exactly once, but was called %d times", callCount.Load())
}
})
t.Run("matching header should route to correct middleware", func(t *testing.T) {
var matchedRoute string
hr := RouteHeaders().
Route("Host", "example.com", func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
matchedRoute = "example.com"
next.ServeHTTP(w, r)
})
}).
Route("Host", "other.com", func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
matchedRoute = "other.com"
next.ServeHTTP(w, r)
})
})
handler := hr.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/", nil)
req.Host = "example.com"
req.Header.Set("Host", "example.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if matchedRoute != "example.com" {
t.Errorf("expected matched route to be 'example.com', got '%s'", matchedRoute)
}
})
t.Run("wildcard pattern should match", func(t *testing.T) {
var matched bool
hr := RouteHeaders().
Route("Host", "*.example.com", func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
matched = true
next.ServeHTTP(w, r)
})
})
handler := hr.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Host", "api.example.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if !matched {
t.Error("expected wildcard pattern to match")
}
})
t.Run("default route should be used when no match", func(t *testing.T) {
var usedDefault bool
hr := RouteHeaders().
Route("Host", "example.com", func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
})
}).
RouteDefault(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
usedDefault = true
next.ServeHTTP(w, r)
})
})
handler := hr.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Host", "other.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if !usedDefault {
t.Error("expected default route to be used when no match")
}
})
t.Run("RouteAny should match any of the provided patterns", func(t *testing.T) {
var matched bool
hr := RouteHeaders().
RouteAny("Content-Type", []string{"application/json", "application/xml"}, func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
matched = true
next.ServeHTTP(w, r)
})
})
handler := hr.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Test with application/json
req := httptest.NewRequest("POST", "/", nil)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if !matched {
t.Error("expected RouteAny to match 'application/json'")
}
// Reset and test with application/xml
matched = false
req = httptest.NewRequest("POST", "/", nil)
req.Header.Set("Content-Type", "application/xml")
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if !matched {
t.Error("expected RouteAny to match 'application/xml'")
}
})
t.Run("no match and no default should call next handler", func(t *testing.T) {
var nextCalled bool
hr := RouteHeaders().
Route("Host", "example.com", func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
})
})
handler := hr.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Host", "other.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if !nextCalled {
t.Error("expected next handler to be called when no match and no default")
}
})
}
func TestPattern(t *testing.T) {
tests := []struct {
pattern string
value string
expected bool
}{
{"example.com", "example.com", true},
{"example.com", "other.com", false},
{"*.example.com", "api.example.com", true},
{"*.example.com", "example.com", false},
{"api.*", "api.example.com", true},
{"*", "anything", true},
{"prefix*suffix", "prefixmiddlesuffix", true},
{"prefix*suffix", "prefixsuffix", true},
{"prefix*suffix", "wrongmiddlesuffix", false},
}
for _, tt := range tests {
t.Run(tt.pattern+"_"+tt.value, func(t *testing.T) {
p := NewPattern(tt.pattern)
if got := p.Match(tt.value); got != tt.expected {
t.Errorf("Pattern(%q).Match(%q) = %v, want %v", tt.pattern, tt.value, got, tt.expected)
}
})
}
}

77
middleware/strip.go Normal file
View File

@@ -0,0 +1,77 @@
package middleware
import (
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
)
// StripSlashes is a middleware that will match request paths with a trailing
// slash, strip it from the path and continue routing through the mux, if a route
// matches, then it will serve the handler.
func StripSlashes(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
var path string
rctx := chi.RouteContext(r.Context())
if rctx != nil && rctx.RoutePath != "" {
path = rctx.RoutePath
} else {
path = r.URL.Path
}
if len(path) > 1 && path[len(path)-1] == '/' {
newPath := path[:len(path)-1]
if rctx == nil {
r.URL.Path = newPath
} else {
rctx.RoutePath = newPath
}
}
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
// RedirectSlashes is a middleware that will match request paths with a trailing
// slash and redirect to the same path, less the trailing slash.
//
// NOTE: RedirectSlashes middleware is *incompatible* with http.FileServer,
// see https://github.com/go-chi/chi/issues/343
func RedirectSlashes(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
var path string
rctx := chi.RouteContext(r.Context())
if rctx != nil && rctx.RoutePath != "" {
path = rctx.RoutePath
} else {
path = r.URL.Path
}
if len(path) > 1 && path[len(path)-1] == '/' {
// Normalize backslashes to forward slashes to prevent "/\evil.com" style redirects
// that some clients may interpret as protocol-relative.
path = strings.ReplaceAll(path, `\`, `/`)
// Collapse leading/trailing slashes and force a single leading slash.
path := "/" + strings.Trim(path, "/")
if r.URL.RawQuery != "" {
path = fmt.Sprintf("%s?%s", path, r.URL.RawQuery)
}
http.Redirect(w, r, path, 301)
return
}
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
// StripPrefix is a middleware that will strip the provided prefix from the
// request path before handing the request over to the next handler.
func StripPrefix(prefix string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.StripPrefix(prefix, next)
}
}

329
middleware/strip_test.go Normal file
View File

@@ -0,0 +1,329 @@
package middleware
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
func TestStripSlashes(t *testing.T) {
r := chi.NewRouter()
// This middleware must be mounted at the top level of the router, not at the end-handler
// because then it'll be too late and will end up in a 404
r.Use(StripSlashes)
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte("nothing here"))
})
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("root"))
})
r.Route("/accounts/{accountID}", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountID")
w.Write([]byte(accountID))
})
})
ts := httptest.NewServer(r)
defer ts.Close()
if _, resp := testRequest(t, ts, "GET", "/", nil); resp != "root" {
t.Fatal(resp)
}
if _, resp := testRequest(t, ts, "GET", "//", nil); resp != "root" {
t.Fatal(resp)
}
if _, resp := testRequest(t, ts, "GET", "/accounts/admin", nil); resp != "admin" {
t.Fatal(resp)
}
if _, resp := testRequest(t, ts, "GET", "/accounts/admin/", nil); resp != "admin" {
t.Fatal(resp)
}
if _, resp := testRequest(t, ts, "GET", "/nothing-here", nil); resp != "nothing here" {
t.Fatal(resp)
}
}
func TestStripSlashesInRoute(t *testing.T) {
r := chi.NewRouter()
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte("nothing here"))
})
r.Get("/hi", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hi"))
})
r.Route("/accounts/{accountID}", func(r chi.Router) {
r.Use(StripSlashes)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("accounts index"))
})
r.Get("/query", func(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountID")
w.Write([]byte(accountID))
})
})
ts := httptest.NewServer(r)
defer ts.Close()
if _, resp := testRequest(t, ts, "GET", "/hi", nil); resp != "hi" {
t.Fatal(resp)
}
if _, resp := testRequest(t, ts, "GET", "/hi/", nil); resp != "nothing here" {
t.Fatal(resp)
}
if _, resp := testRequest(t, ts, "GET", "/accounts/admin", nil); resp != "accounts index" {
t.Fatal(resp)
}
if _, resp := testRequest(t, ts, "GET", "/accounts/admin/", nil); resp != "accounts index" {
t.Fatal(resp)
}
if _, resp := testRequest(t, ts, "GET", "/accounts/admin/query", nil); resp != "admin" {
t.Fatal(resp)
}
if _, resp := testRequest(t, ts, "GET", "/accounts/admin/query/", nil); resp != "admin" {
t.Fatal(resp)
}
}
func TestRedirectSlashes(t *testing.T) {
r := chi.NewRouter()
// This middleware must be mounted at the top level of the router, not at the end-handler
// because then it'll be too late and will end up in a 404
r.Use(RedirectSlashes)
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte("nothing here"))
})
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("root"))
})
r.Route("/accounts/{accountID}", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountID")
w.Write([]byte(accountID))
})
})
ts := httptest.NewServer(r)
defer ts.Close()
if resp, body := testRequest(t, ts, "GET", "/", nil); body != "root" || resp.StatusCode != 200 {
t.Fatal(body, resp.StatusCode)
}
// NOTE: the testRequest client will follow the redirection..
if resp, body := testRequest(t, ts, "GET", "//", nil); body != "root" || resp.StatusCode != 200 {
t.Fatal(body, resp.StatusCode)
}
if resp, body := testRequest(t, ts, "GET", "/accounts/admin", nil); body != "admin" || resp.StatusCode != 200 {
t.Fatal(body, resp.StatusCode)
}
// NOTE: the testRequest client will follow the redirection..
if resp, body := testRequest(t, ts, "GET", "/accounts/admin/", nil); body != "admin" || resp.StatusCode != 200 {
t.Fatal(body, resp.StatusCode)
}
if resp, body := testRequest(t, ts, "GET", "/nothing-here", nil); body != "nothing here" || resp.StatusCode != 404 {
t.Fatal(body, resp.StatusCode)
}
// Ensure redirect Location url is correct
{
resp, body := testRequestNoRedirect(t, ts, "GET", "/accounts/someuser/", nil)
if resp.StatusCode != 301 {
t.Fatal(body, resp.StatusCode)
}
location := resp.Header.Get("Location")
if location != "/accounts/someuser" {
t.Fatalf("invalid redirection, should be /accounts/someuser")
}
}
// Ensure query params are kept in tact upon redirecting a slash
{
resp, body := testRequestNoRedirect(t, ts, "GET", "/accounts/someuser/?a=1&b=2", nil)
if resp.StatusCode != 301 {
t.Fatal(body, resp.StatusCode)
}
location := resp.Header.Get("Location")
if location != "/accounts/someuser?a=1&b=2" {
t.Fatalf("invalid redirection, should be /accounts/someuser?a=1&b=2")
}
}
// Ensure that we don't redirect to 'evil.com', but rather to 'server.url/evil.com/'
{
paths := []string{"//evil.com/", "///evil.com/"}
for _, p := range paths {
resp, body := testRequest(t, ts, "GET", p, nil)
if u, err := url.Parse(ts.URL); err != nil && resp.Request.URL.Host != u.Host {
t.Fatalf("host should remain the same. got: %q, want: %q", resp.Request.URL.Host, ts.URL)
}
if body != "nothing here" || resp.StatusCode != 404 {
t.Fatal(body, resp.StatusCode)
}
}
}
// Ensure that we don't redirect to 'evil.com', but rather to 'server.url/evil.com/'
{
resp, body := testRequest(t, ts, "GET", "//evil.com/", nil)
if u, err := url.Parse(ts.URL); err != nil && resp.Request.URL.Host != u.Host {
t.Fatalf("host should remain the same. got: %q, want: %q", resp.Request.URL.Host, ts.URL)
}
if body != "nothing here" || resp.StatusCode != 404 {
t.Fatal(body, resp.StatusCode)
}
}
}
// This tests a http.Handler that is not chi.Router
// In these cases, the routeContext is nil
func TestStripSlashesWithNilContext(t *testing.T) {
r := http.NewServeMux()
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("root"))
})
r.HandleFunc("/accounts", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("accounts"))
})
r.HandleFunc("/accounts/admin", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("admin"))
})
ts := httptest.NewServer(StripSlashes(r))
defer ts.Close()
if _, resp := testRequest(t, ts, "GET", "/", nil); resp != "root" {
t.Fatal(resp)
}
if _, resp := testRequest(t, ts, "GET", "//", nil); resp != "root" {
t.Fatal(resp)
}
if _, resp := testRequest(t, ts, "GET", "/accounts", nil); resp != "accounts" {
t.Fatal(resp)
}
if _, resp := testRequest(t, ts, "GET", "/accounts/", nil); resp != "accounts" {
t.Fatal(resp)
}
if _, resp := testRequest(t, ts, "GET", "/accounts/admin", nil); resp != "admin" {
t.Fatal(resp)
}
if _, resp := testRequest(t, ts, "GET", "/accounts/admin/", nil); resp != "admin" {
t.Fatal(resp)
}
}
func TestStripPrefix(t *testing.T) {
r := chi.NewRouter()
r.Use(StripPrefix("/api"))
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("api root"))
})
r.Get("/accounts", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("api accounts"))
})
r.Get("/accounts/{accountID}", func(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountID")
w.Write([]byte(accountID))
})
ts := httptest.NewServer(r)
defer ts.Close()
if _, resp := testRequest(t, ts, "GET", "/api/", nil); resp != "api root" {
t.Fatalf("got: %q, want: %q", resp, "api root")
}
if _, resp := testRequest(t, ts, "GET", "/api/accounts", nil); resp != "api accounts" {
t.Fatalf("got: %q, want: %q", resp, "api accounts")
}
if _, resp := testRequest(t, ts, "GET", "/api/accounts/admin", nil); resp != "admin" {
t.Fatalf("got: %q, want: %q", resp, "admin")
}
if _, resp := testRequest(t, ts, "GET", "/api-nope/", nil); resp != "404 page not found\n" {
t.Fatalf("got: %q, want: %q", resp, "404 page not found\n")
}
}
func TestRedirectSlashes_PreventBackslashRelativeOpenRedirect(t *testing.T) {
h := RedirectSlashes(http.NotFoundHandler())
tests := []struct {
name string
target string
}{
{
name: `raw backslash: /\evil.com/`,
target: `/\evil.com/`,
},
{
name: `encoded backslash: /%5Cevil.com/`,
target: "/%5Cevil.com/",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://example.test"+tc.target, nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
res := rr.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusMovedPermanently {
t.Fatalf("expected %d, got %d", http.StatusMovedPermanently, res.StatusCode)
}
loc := res.Header.Get("Location")
if loc == "" {
t.Fatalf("expected Location header to be set")
}
// The core security assertions:
if strings.Contains(loc, `\`) {
t.Fatalf("Location must not contain backslashes: %q", loc)
}
if strings.HasPrefix(loc, "//") {
t.Fatalf("Location must not be protocol-relative: %q", loc)
}
if !strings.HasPrefix(loc, "/") {
t.Fatalf("Location must be an absolute-path reference starting with '/': %q", loc)
}
// Optional stronger assertion if your middleware normalizes to /evil.com exactly:
// (Keep or remove depending on your chosen behavior.)
if loc != "/evil.com" {
t.Fatalf("expected Location %q, got %q", "/evil.com", loc)
}
})
}
}

25
middleware/sunset.go Normal file
View File

@@ -0,0 +1,25 @@
package middleware
import (
"net/http"
"time"
)
// Sunset set Deprecation/Sunset header to response
// This can be used to enable Sunset in a route or a route group
// For more: https://www.rfc-editor.org/rfc/rfc8594.html
func Sunset(sunsetAt time.Time, links ...string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !sunsetAt.IsZero() {
w.Header().Set("Sunset", sunsetAt.Format(http.TimeFormat))
w.Header().Set("Deprecation", sunsetAt.Format(http.TimeFormat))
for _, link := range links {
w.Header().Add("Link", link)
}
}
next.ServeHTTP(w, r)
})
}
}

104
middleware/sunset_test.go Normal file
View File

@@ -0,0 +1,104 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
func TestSunset(t *testing.T) {
t.Run("Sunset without link", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
r := chi.NewRouter()
sunsetAt := time.Date(2025, 12, 24, 10, 20, 0, 0, time.UTC)
r.Use(Sunset(sunsetAt))
var sunset, deprecation string
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
clonedHeader := w.Header().Clone()
sunset = clonedHeader.Get("Sunset")
deprecation = clonedHeader.Get("Deprecation")
w.Write([]byte("I'll be unavailable soon"))
})
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatal("Response Code should be 200")
}
if sunset != "Wed, 24 Dec 2025 10:20:00 GMT" {
t.Fatal("Test get sunset error.", sunset)
}
if deprecation != "Wed, 24 Dec 2025 10:20:00 GMT" {
t.Fatal("Test get deprecation error.")
}
})
t.Run("Sunset with link", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
r := chi.NewRouter()
sunsetAt := time.Date(2025, 12, 24, 10, 20, 0, 0, time.UTC)
deprecationLink := "https://example.com/v1/deprecation-details"
r.Use(Sunset(sunsetAt, deprecationLink))
var sunset, deprecation, link string
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
clonedHeader := w.Header().Clone()
sunset = clonedHeader.Get("Sunset")
deprecation = clonedHeader.Get("Deprecation")
link = clonedHeader.Get("Link")
w.Write([]byte("I'll be unavailable soon"))
})
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatal("Response Code should be 200")
}
if sunset != "Wed, 24 Dec 2025 10:20:00 GMT" {
t.Fatal("Test get sunset error.", sunset)
}
if deprecation != "Wed, 24 Dec 2025 10:20:00 GMT" {
t.Fatal("Test get deprecation error.")
}
if link != deprecationLink {
t.Fatal("Test get deprecation link error.")
}
})
}
/**
EXAMPLE USAGES
func main() {
r := chi.NewRouter()
sunsetAt := time.Date(2025, 12, 24, 10, 20, 0, 0, time.UTC)
r.Use(middleware.Sunset(sunsetAt))
// can provide additional link for updated resource
// r.Use(middleware.Sunset(sunsetAt, "https://example.com/v1/deprecation-details"))
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("This endpoint will be removed soon"))
})
log.Println("Listening on port: 3000")
http.ListenAndServe(":3000", r)
}
**/

View File

@@ -0,0 +1,27 @@
package middleware
import (
"net/http"
"github.com/go-chi/chi/v5"
)
// SupressNotFound will quickly respond with a 404 if the route is not found
// and will not continue to the next middleware handler.
//
// This is handy to put at the top of your middleware stack to avoid unnecessary
// processing of requests that are not going to match any routes anyway. For
// example its super annoying to see a bunch of 404's in your logs from bots.
func SupressNotFound(router *chi.Mux) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rctx := chi.RouteContext(r.Context())
match := rctx.Routes.Match(rctx, r.Method, r.URL.Path)
if !match {
router.NotFoundHandler().ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
}
}

63
middleware/terminal.go Normal file
View File

@@ -0,0 +1,63 @@
package middleware
// Ported from Goji's middleware, source:
// https://github.com/zenazn/goji/tree/master/web/middleware
import (
"fmt"
"io"
"os"
)
var (
// Normal colors
nBlack = []byte{'\033', '[', '3', '0', 'm'}
nRed = []byte{'\033', '[', '3', '1', 'm'}
nGreen = []byte{'\033', '[', '3', '2', 'm'}
nYellow = []byte{'\033', '[', '3', '3', 'm'}
nBlue = []byte{'\033', '[', '3', '4', 'm'}
nMagenta = []byte{'\033', '[', '3', '5', 'm'}
nCyan = []byte{'\033', '[', '3', '6', 'm'}
nWhite = []byte{'\033', '[', '3', '7', 'm'}
// Bright colors
bBlack = []byte{'\033', '[', '3', '0', ';', '1', 'm'}
bRed = []byte{'\033', '[', '3', '1', ';', '1', 'm'}
bGreen = []byte{'\033', '[', '3', '2', ';', '1', 'm'}
bYellow = []byte{'\033', '[', '3', '3', ';', '1', 'm'}
bBlue = []byte{'\033', '[', '3', '4', ';', '1', 'm'}
bMagenta = []byte{'\033', '[', '3', '5', ';', '1', 'm'}
bCyan = []byte{'\033', '[', '3', '6', ';', '1', 'm'}
bWhite = []byte{'\033', '[', '3', '7', ';', '1', 'm'}
reset = []byte{'\033', '[', '0', 'm'}
)
var IsTTY bool
func init() {
// This is sort of cheating: if stdout is a character device, we assume
// that means it's a TTY. Unfortunately, there are many non-TTY
// character devices, but fortunately stdout is rarely set to any of
// them.
//
// We could solve this properly by pulling in a dependency on
// code.google.com/p/go.crypto/ssh/terminal, for instance, but as a
// heuristic for whether to print in color or in black-and-white, I'd
// really rather not.
fi, err := os.Stdout.Stat()
if err == nil {
m := os.ModeDevice | os.ModeCharDevice
IsTTY = fi.Mode()&m == m
}
}
// colorWrite
func cW(w io.Writer, useColor bool, color []byte, s string, args ...any) {
if IsTTY && useColor {
w.Write(color)
}
fmt.Fprintf(w, s, args...)
if IsTTY && useColor {
w.Write(reset)
}
}

View File

@@ -0,0 +1,36 @@
package middleware
import (
"bytes"
"testing"
)
func Test_cW(t *testing.T) {
cases := []struct {
name string
useColor bool
color []byte
s string
args []any
expected string
}{
{
name: "No color",
useColor: false,
color: nGreen,
s: "no color test",
expected: "no color test",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
actual := &bytes.Buffer{}
cW(actual, c.useColor, c.color, c.s, c.args...)
if got := actual.String(); got != c.expected {
t.Errorf("output: %q, expected: %q", got, c.expected)
}
})
}
}

151
middleware/throttle.go Normal file
View File

@@ -0,0 +1,151 @@
package middleware
import (
"net/http"
"strconv"
"time"
)
const (
errCapacityExceeded = "Server capacity exceeded."
errTimedOut = "Timed out while waiting for a pending request to complete."
errContextCanceled = "Context was canceled."
)
var (
defaultBacklogTimeout = time.Second * 60
)
// ThrottleOpts represents a set of throttling options.
type ThrottleOpts struct {
RetryAfterFn func(ctxDone bool) time.Duration
Limit int
BacklogLimit int
BacklogTimeout time.Duration
StatusCode int
}
// Throttle is a middleware that limits number of currently processed requests
// at a time across all users. Note: Throttle is not a rate-limiter per user,
// instead it just puts a ceiling on the number of current in-flight requests
// being processed from the point from where the Throttle middleware is mounted.
func Throttle(limit int) func(http.Handler) http.Handler {
return ThrottleWithOpts(ThrottleOpts{Limit: limit, BacklogTimeout: defaultBacklogTimeout})
}
// ThrottleBacklog is a middleware that limits number of currently processed
// requests at a time and provides a backlog for holding a finite number of
// pending requests.
func ThrottleBacklog(limit, backlogLimit int, backlogTimeout time.Duration) func(http.Handler) http.Handler {
return ThrottleWithOpts(ThrottleOpts{Limit: limit, BacklogLimit: backlogLimit, BacklogTimeout: backlogTimeout})
}
// ThrottleWithOpts is a middleware that limits number of currently processed requests using passed ThrottleOpts.
func ThrottleWithOpts(opts ThrottleOpts) func(http.Handler) http.Handler {
if opts.Limit < 1 {
panic("chi/middleware: Throttle expects limit > 0")
}
if opts.BacklogLimit < 0 {
panic("chi/middleware: Throttle expects backlogLimit to be positive")
}
statusCode := opts.StatusCode
if statusCode == 0 {
statusCode = http.StatusTooManyRequests
}
t := throttler{
tokens: make(chan token, opts.Limit),
backlogTokens: make(chan token, opts.Limit+opts.BacklogLimit),
backlogTimeout: opts.BacklogTimeout,
statusCode: statusCode,
retryAfterFn: opts.RetryAfterFn,
}
// Filling tokens.
for i := 0; i < opts.Limit+opts.BacklogLimit; i++ {
if i < opts.Limit {
t.tokens <- token{}
}
t.backlogTokens <- token{}
}
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
select {
case <-ctx.Done():
t.setRetryAfterHeaderIfNeeded(w, true)
http.Error(w, errContextCanceled, t.statusCode)
return
case btok := <-t.backlogTokens:
defer func() {
t.backlogTokens <- btok
}()
// Try to get a processing token immediately first
select {
case tok := <-t.tokens:
defer func() {
t.tokens <- tok
}()
next.ServeHTTP(w, r)
return
default:
// No immediate token available, need to wait with timer
}
timer := time.NewTimer(t.backlogTimeout)
select {
case <-timer.C:
t.setRetryAfterHeaderIfNeeded(w, false)
http.Error(w, errTimedOut, t.statusCode)
return
case <-ctx.Done():
timer.Stop()
t.setRetryAfterHeaderIfNeeded(w, true)
http.Error(w, errContextCanceled, t.statusCode)
return
case tok := <-t.tokens:
defer func() {
timer.Stop()
t.tokens <- tok
}()
next.ServeHTTP(w, r)
}
return
default:
t.setRetryAfterHeaderIfNeeded(w, false)
http.Error(w, errCapacityExceeded, t.statusCode)
return
}
}
return http.HandlerFunc(fn)
}
}
// token represents a request that is being processed.
type token struct{}
// throttler limits number of currently processed requests at a time.
type throttler struct {
tokens chan token
backlogTokens chan token
retryAfterFn func(ctxDone bool) time.Duration
backlogTimeout time.Duration
statusCode int
}
// setRetryAfterHeaderIfNeeded sets Retry-After HTTP header if corresponding retryAfterFn option of throttler is initialized.
func (t throttler) setRetryAfterHeaderIfNeeded(w http.ResponseWriter, ctxDone bool) {
if t.retryAfterFn == nil {
return
}
w.Header().Set("Retry-After", strconv.Itoa(int(t.retryAfterFn(ctxDone).Seconds())))
}

329
middleware/throttle_test.go Normal file
View File

@@ -0,0 +1,329 @@
package middleware
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
var testContent = []byte("Hello world!")
func TestThrottleBacklog(t *testing.T) {
r := chi.NewRouter()
r.Use(ThrottleBacklog(10, 50, time.Second*10))
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
time.Sleep(time.Second * 1) // Expensive operation.
w.Write(testContent)
})
server := httptest.NewServer(r)
defer server.Close()
client := http.Client{
Timeout: time.Second * 5, // Maximum waiting time.
}
var wg sync.WaitGroup
// The throttler processes 10 consecutive requests, each one of those
// requests lasts 1s. The maximum number of requests this can possible serve
// before the clients time out (5s) is 40.
for i := range 40 {
wg.Add(1)
go func(i int) {
defer wg.Done()
res, err := client.Get(server.URL)
assertNoError(t, err)
assertEqual(t, http.StatusOK, res.StatusCode)
buf, err := io.ReadAll(res.Body)
assertNoError(t, err)
assertEqual(t, testContent, buf)
}(i)
}
wg.Wait()
}
func TestThrottleClientTimeout(t *testing.T) {
r := chi.NewRouter()
r.Use(ThrottleBacklog(10, 50, time.Second*10))
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
time.Sleep(time.Second * 5) // Expensive operation.
w.Write(testContent)
})
server := httptest.NewServer(r)
defer server.Close()
client := http.Client{
Timeout: time.Second * 3, // Maximum waiting time.
}
var wg sync.WaitGroup
for i := range 10 {
wg.Add(1)
go func(i int) {
defer wg.Done()
_, err := client.Get(server.URL)
assertError(t, err)
}(i)
}
wg.Wait()
}
func TestThrottleTriggerGatewayTimeout(t *testing.T) {
r := chi.NewRouter()
r.Use(ThrottleBacklog(50, 100, time.Second*5))
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
time.Sleep(time.Second * 10) // Expensive operation.
w.Write(testContent)
})
server := httptest.NewServer(r)
defer server.Close()
client := http.Client{
Timeout: time.Second * 60, // Maximum waiting time.
}
var wg sync.WaitGroup
// These requests will be processed normally until they finish.
for i := range 50 {
wg.Add(1)
go func(i int) {
defer wg.Done()
res, err := client.Get(server.URL)
assertNoError(t, err)
assertEqual(t, http.StatusOK, res.StatusCode)
}(i)
}
time.Sleep(time.Second * 1)
// These requests will wait for the first batch to complete but it will take
// too much time, so they will eventually receive a timeout error.
for i := range 50 {
wg.Add(1)
go func(i int) {
defer wg.Done()
res, err := client.Get(server.URL)
assertNoError(t, err)
buf, err := io.ReadAll(res.Body)
assertNoError(t, err)
assertEqual(t, http.StatusTooManyRequests, res.StatusCode)
assertEqual(t, errTimedOut, strings.TrimSpace(string(buf)))
}(i)
}
wg.Wait()
}
func TestThrottleMaximum(t *testing.T) {
r := chi.NewRouter()
r.Use(ThrottleBacklog(10, 10, time.Second*5))
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
time.Sleep(time.Second * 3) // Expensive operation.
w.Write(testContent)
})
server := httptest.NewServer(r)
defer server.Close()
client := http.Client{
Timeout: time.Second * 60, // Maximum waiting time.
}
var wg sync.WaitGroup
for i := range 20 {
wg.Add(1)
go func(i int) {
defer wg.Done()
res, err := client.Get(server.URL)
assertNoError(t, err)
assertEqual(t, http.StatusOK, res.StatusCode)
buf, err := io.ReadAll(res.Body)
assertNoError(t, err)
assertEqual(t, testContent, buf)
}(i)
}
// Wait less time than what the server takes to reply.
time.Sleep(time.Second * 2)
// At this point the server is still processing, all the following request
// will be beyond the server capacity.
for i := range 20 {
wg.Add(1)
go func(i int) {
defer wg.Done()
res, err := client.Get(server.URL)
assertNoError(t, err)
buf, err := io.ReadAll(res.Body)
assertNoError(t, err)
assertEqual(t, http.StatusTooManyRequests, res.StatusCode)
assertEqual(t, errCapacityExceeded, strings.TrimSpace(string(buf)))
}(i)
}
wg.Wait()
}
func TestThrottleRetryAfter(t *testing.T) {
r := chi.NewRouter()
retryAfterFn := func(ctxDone bool) time.Duration { return time.Hour }
r.Use(ThrottleWithOpts(ThrottleOpts{
Limit: 5,
BacklogLimit: 0,
RetryAfterFn: retryAfterFn,
}))
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(time.Second * 1) // Expensive operation.
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
server := httptest.NewServer(r)
defer server.Close()
client := http.Client{}
type result struct {
status int
header http.Header
}
var wg sync.WaitGroup
totalRequests := 10
resultsCh := make(chan result, totalRequests)
for i := 0; i < totalRequests; i++ {
wg.Add(1)
go func() {
defer wg.Done()
res, _ := client.Get(server.URL)
resultsCh <- result{status: res.StatusCode, header: res.Header}
}()
}
wg.Wait()
close(resultsCh)
count200 := 0
count429 := 0
for res := range resultsCh {
switch res.status {
case http.StatusOK:
count200++
continue
case http.StatusTooManyRequests:
count429++
assertEqual(t, "3600", res.header.Get("Retry-After"))
continue
default:
t.Fatalf("Unexpected status code: %d", res.status)
continue
}
}
assertEqual(t, 5, count200)
assertEqual(t, 5, count429)
}
func TestThrottleCustomStatusCode(t *testing.T) {
const timeout = time.Second * 3
wait := make(chan struct{})
r := chi.NewRouter()
r.Use(ThrottleWithOpts(ThrottleOpts{Limit: 1, StatusCode: http.StatusServiceUnavailable}))
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
select {
case <-wait:
case <-time.After(timeout):
}
w.WriteHeader(http.StatusOK)
})
server := httptest.NewServer(r)
defer server.Close()
const totalRequestCount = 5
codes := make(chan int, totalRequestCount)
errs := make(chan error, totalRequestCount)
client := &http.Client{Timeout: timeout}
for range totalRequestCount {
go func() {
resp, err := client.Get(server.URL)
if err != nil {
errs <- err
return
}
codes <- resp.StatusCode
}()
}
waitResponse := func(wantCode int) {
select {
case err := <-errs:
t.Fatal(err)
case code := <-codes:
assertEqual(t, wantCode, code)
case <-time.After(timeout):
t.Fatalf("waiting %d code, timeout exceeded", wantCode)
}
}
for range totalRequestCount - 1 {
waitResponse(http.StatusServiceUnavailable)
}
close(wait) // Allow the last request to proceed.
waitResponse(http.StatusOK)
}
func BenchmarkThrottle(b *testing.B) {
throttleMiddleware := ThrottleBacklog(1000, 50, time.Second)
handler := throttleMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest("GET", "/", nil)
b.ReportAllocs()
for b.Loop() {
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
}
}

48
middleware/timeout.go Normal file
View File

@@ -0,0 +1,48 @@
package middleware
import (
"context"
"net/http"
"time"
)
// Timeout is a middleware that cancels ctx after a given timeout and return
// a 504 Gateway Timeout error to the client.
//
// It's required that you select the ctx.Done() channel to check for the signal
// if the context has reached its deadline and return, otherwise the timeout
// signal will be just ignored.
//
// ie. a route/handler may look like:
//
// r.Get("/long", func(w http.ResponseWriter, r *http.Request) {
// ctx := r.Context()
// processTime := time.Duration(rand.Intn(4)+1) * time.Second
//
// select {
// case <-ctx.Done():
// return
//
// case <-time.After(processTime):
// // The above channel simulates some hard work.
// }
//
// w.Write([]byte("done"))
// })
func Timeout(timeout time.Duration) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer func() {
cancel()
if ctx.Err() == context.DeadlineExceeded {
w.WriteHeader(http.StatusGatewayTimeout)
}
}()
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}

77
middleware/url_format.go Normal file
View File

@@ -0,0 +1,77 @@
package middleware
import (
"context"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
)
var (
// URLFormatCtxKey is the context.Context key to store the URL format data
// for a request.
URLFormatCtxKey = &contextKey{"URLFormat"}
)
// URLFormat is a middleware that parses the url extension from a request path and stores it
// on the context as a string under the key `middleware.URLFormatCtxKey`. The middleware will
// trim the suffix from the routing path and continue routing.
//
// Routers should not include a url parameter for the suffix when using this middleware.
//
// Sample usage for url paths `/articles/1`, `/articles/1.json` and `/articles/1.xml`:
//
// func routes() http.Handler {
// r := chi.NewRouter()
// r.Use(middleware.URLFormat)
//
// r.Get("/articles/{id}", ListArticles)
//
// return r
// }
//
// func ListArticles(w http.ResponseWriter, r *http.Request) {
// urlFormat, _ := r.Context().Value(middleware.URLFormatCtxKey).(string)
//
// switch urlFormat {
// case "json":
// render.JSON(w, r, articles)
// case "xml:"
// render.XML(w, r, articles)
// default:
// render.JSON(w, r, articles)
// }
// }
func URLFormat(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var format string
path := r.URL.Path
rctx := chi.RouteContext(r.Context())
if rctx != nil && rctx.RoutePath != "" {
path = rctx.RoutePath
}
if strings.Index(path, ".") > 0 {
base := strings.LastIndex(path, "/")
idx := strings.LastIndex(path[base:], ".")
if idx > 0 {
idx += base
format = path[idx+1:]
if rctx != nil {
rctx.RoutePath = path[:idx]
}
}
}
r = r.WithContext(context.WithValue(ctx, URLFormatCtxKey, format))
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}

View File

@@ -0,0 +1,69 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
func TestURLFormat(t *testing.T) {
r := chi.NewRouter()
r.Use(URLFormat)
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte("nothing here"))
})
r.Route("/samples/articles/samples.{articleID}", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
articleID := chi.URLParam(r, "articleID")
w.Write([]byte(articleID))
})
})
r.Route("/articles/{articleID}", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
articleID := chi.URLParam(r, "articleID")
w.Write([]byte(articleID))
})
})
ts := httptest.NewServer(r)
defer ts.Close()
if _, resp := testRequest(t, ts, "GET", "/articles/1.json", nil); resp != "1" {
t.Fatal(resp)
}
if _, resp := testRequest(t, ts, "GET", "/articles/1.xml", nil); resp != "1" {
t.Fatal(resp)
}
if _, resp := testRequest(t, ts, "GET", "/samples/articles/samples.1.json", nil); resp != "1" {
t.Fatal(resp)
}
if _, resp := testRequest(t, ts, "GET", "/samples/articles/samples.1.xml", nil); resp != "1" {
t.Fatal(resp)
}
}
func TestURLFormatInSubRouter(t *testing.T) {
r := chi.NewRouter()
r.Route("/articles/{articleID}", func(r chi.Router) {
r.Use(URLFormat)
r.Get("/subroute", func(w http.ResponseWriter, r *http.Request) {
articleID := chi.URLParam(r, "articleID")
w.Write([]byte(articleID))
})
})
ts := httptest.NewServer(r)
defer ts.Close()
if _, resp := testRequest(t, ts, "GET", "/articles/1/subroute.json", nil); resp != "1" {
t.Fatal(resp)
}
}

17
middleware/value.go Normal file
View File

@@ -0,0 +1,17 @@
package middleware
import (
"context"
"net/http"
)
// WithValue is a middleware that sets a given key/value in a context chain.
func WithValue(key, val any) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(context.WithValue(r.Context(), key, val))
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}

33
middleware/value_test.go Normal file
View File

@@ -0,0 +1,33 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
)
func TestWithValue(t *testing.T) {
ctxKey := "customKey"
ctxValue := "customValue"
request := func() *http.Request {
req, _ := http.NewRequest("GET", "/", nil)
return req
}
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
val := r.Context().Value(ctxKey).(string)
w.Write([]byte(val))
})
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(WithValue(ctxKey, ctxValue))
r.Get("/", testHandler)
r.ServeHTTP(w, request())
if w.Body.String() != ctxValue {
t.Errorf("value: context did not contain expected value")
}
}

245
middleware/wrap_writer.go Normal file
View File

@@ -0,0 +1,245 @@
package middleware
// The original work was derived from Goji's middleware, source:
// https://github.com/zenazn/goji/tree/master/web/middleware
import (
"bufio"
"io"
"net"
"net/http"
)
// NewWrapResponseWriter wraps an http.ResponseWriter, returning a proxy that allows you to
// hook into various parts of the response process.
func NewWrapResponseWriter(w http.ResponseWriter, protoMajor int) WrapResponseWriter {
_, fl := w.(http.Flusher)
bw := basicWriter{ResponseWriter: w}
if protoMajor == 2 {
_, ps := w.(http.Pusher)
if fl && ps {
return &http2FancyWriter{bw}
}
} else {
_, hj := w.(http.Hijacker)
_, rf := w.(io.ReaderFrom)
if fl && hj && rf {
return &httpFancyWriter{bw}
}
if fl && hj {
return &flushHijackWriter{bw}
}
if hj {
return &hijackWriter{bw}
}
}
if fl {
return &flushWriter{bw}
}
return &bw
}
// WrapResponseWriter is a proxy around an http.ResponseWriter that allows you to hook
// into various parts of the response process.
type WrapResponseWriter interface {
http.ResponseWriter
// Status returns the HTTP status of the request, or 0 if one has not
// yet been sent.
Status() int
// BytesWritten returns the total number of bytes sent to the client.
BytesWritten() int
// Tee causes the response body to be written to the given io.Writer in
// addition to proxying the writes through. Only one io.Writer can be
// tee'd to at once: setting a second one will overwrite the first.
// Writes will be sent to the proxy before being written to this
// io.Writer. It is illegal for the tee'd writer to be modified
// concurrently with writes.
Tee(io.Writer)
// Unwrap returns the original proxied target.
Unwrap() http.ResponseWriter
// Discard causes all writes to the original ResponseWriter be discarded,
// instead writing only to the tee'd writer if it's set.
// The caller is responsible for calling WriteHeader and Write on the
// original ResponseWriter once the processing is done.
Discard()
}
// basicWriter wraps a http.ResponseWriter that implements the minimal
// http.ResponseWriter interface.
type basicWriter struct {
http.ResponseWriter
tee io.Writer
code int
bytes int
wroteHeader bool
discard bool
}
func (b *basicWriter) WriteHeader(code int) {
if code >= 100 && code <= 199 && code != http.StatusSwitchingProtocols {
if !b.discard {
b.ResponseWriter.WriteHeader(code)
}
} else if !b.wroteHeader {
b.code = code
b.wroteHeader = true
if !b.discard {
b.ResponseWriter.WriteHeader(code)
}
}
}
func (b *basicWriter) Write(buf []byte) (n int, err error) {
b.maybeWriteHeader()
if !b.discard {
n, err = b.ResponseWriter.Write(buf)
if b.tee != nil {
_, err2 := b.tee.Write(buf[:n])
// Prefer errors generated by the proxied writer.
if err == nil {
err = err2
}
}
} else if b.tee != nil {
n, err = b.tee.Write(buf)
} else {
n, err = io.Discard.Write(buf)
}
b.bytes += n
return n, err
}
func (b *basicWriter) maybeWriteHeader() {
if !b.wroteHeader {
b.WriteHeader(http.StatusOK)
}
}
func (b *basicWriter) Status() int {
return b.code
}
func (b *basicWriter) BytesWritten() int {
return b.bytes
}
func (b *basicWriter) Tee(w io.Writer) {
b.tee = w
}
func (b *basicWriter) Unwrap() http.ResponseWriter {
return b.ResponseWriter
}
func (b *basicWriter) Discard() {
b.discard = true
}
// flushWriter ...
type flushWriter struct {
basicWriter
}
func (f *flushWriter) Flush() {
f.basicWriter.maybeWriteHeader()
fl := f.basicWriter.ResponseWriter.(http.Flusher)
fl.Flush()
}
var _ http.Flusher = &flushWriter{}
// hijackWriter ...
type hijackWriter struct {
basicWriter
}
func (f *hijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj := f.basicWriter.ResponseWriter.(http.Hijacker)
return hj.Hijack()
}
var _ http.Hijacker = &hijackWriter{}
// flushHijackWriter ...
type flushHijackWriter struct {
basicWriter
}
func (f *flushHijackWriter) Flush() {
f.basicWriter.maybeWriteHeader()
fl := f.basicWriter.ResponseWriter.(http.Flusher)
fl.Flush()
}
func (f *flushHijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj := f.basicWriter.ResponseWriter.(http.Hijacker)
return hj.Hijack()
}
var _ http.Flusher = &flushHijackWriter{}
var _ http.Hijacker = &flushHijackWriter{}
// httpFancyWriter is a HTTP writer that additionally satisfies
// http.Flusher, http.Hijacker, and io.ReaderFrom. It exists for the common case
// of wrapping the http.ResponseWriter that package http gives you, in order to
// make the proxied object support the full method set of the proxied object.
type httpFancyWriter struct {
basicWriter
}
func (f *httpFancyWriter) Flush() {
f.basicWriter.maybeWriteHeader()
fl := f.basicWriter.ResponseWriter.(http.Flusher)
fl.Flush()
}
func (f *httpFancyWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj := f.basicWriter.ResponseWriter.(http.Hijacker)
return hj.Hijack()
}
func (f *http2FancyWriter) Push(target string, opts *http.PushOptions) error {
return f.basicWriter.ResponseWriter.(http.Pusher).Push(target, opts)
}
func (f *httpFancyWriter) ReadFrom(r io.Reader) (int64, error) {
if f.basicWriter.tee != nil || f.basicWriter.discard {
// Route through basicWriter.Write so that the tee and discard semantics
// are honored (the fast ReaderFrom path below would bypass both, writing
// straight to the original ResponseWriter). basicWriter.Write already
// increments basicWriter.bytes, so we must NOT add n again here (that
// would double-count).
n, err := io.Copy(&f.basicWriter, r)
return n, err
}
rf := f.basicWriter.ResponseWriter.(io.ReaderFrom)
f.basicWriter.maybeWriteHeader()
n, err := rf.ReadFrom(r)
f.basicWriter.bytes += int(n)
return n, err
}
var _ http.Flusher = &httpFancyWriter{}
var _ http.Hijacker = &httpFancyWriter{}
var _ http.Pusher = &http2FancyWriter{}
var _ io.ReaderFrom = &httpFancyWriter{}
// http2FancyWriter is a HTTP2 writer that additionally satisfies
// http.Flusher, and io.ReaderFrom. It exists for the common case
// of wrapping the http.ResponseWriter that package http gives you, in order to
// make the proxied object support the full method set of the proxied object.
type http2FancyWriter struct {
basicWriter
}
func (f *http2FancyWriter) Flush() {
f.basicWriter.maybeWriteHeader()
fl := f.basicWriter.ResponseWriter.(http.Flusher)
fl.Flush()
}
var _ http.Flusher = &http2FancyWriter{}

View File

@@ -0,0 +1,184 @@
package middleware
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// readerFromRecorder is a ResponseRecorder that also satisfies io.ReaderFrom,
// mirroring the real net/http response writer (which implements ReadFrom and
// is what httpFancyWriter.ReadFrom fast-paths to). Bytes copied via ReadFrom
// are recorded to the body so a test can observe whether they reached the
// original writer.
type readerFromRecorder struct {
*httptest.ResponseRecorder
}
func (r *readerFromRecorder) ReadFrom(src io.Reader) (int64, error) {
return io.Copy(r.ResponseRecorder.Body, src)
}
func TestWrapResponseWriterStatusWhenFlushed(t *testing.T) {
for _, writer := range []struct {
name string
wrap func(http.ResponseWriter) WrapResponseWriter
}{
{"flushWriter", func(w http.ResponseWriter) WrapResponseWriter {
return &flushWriter{basicWriter{ResponseWriter: w}}
}},
{"flushHijackWriter", func(w http.ResponseWriter) WrapResponseWriter {
return &flushHijackWriter{basicWriter{ResponseWriter: w}}
}},
{"httpFancyWriter", func(w http.ResponseWriter) WrapResponseWriter {
return &httpFancyWriter{basicWriter{ResponseWriter: w}}
}},
{"http2FancyWriter", func(w http.ResponseWriter) WrapResponseWriter {
return &http2FancyWriter{basicWriter{ResponseWriter: w}}
}},
} {
t.Run(writer.name, func(t *testing.T) {
for _, status := range []struct {
name string
code int
want int
}{
{"implicit", 0, http.StatusOK},
{"explicit", http.StatusCreated, http.StatusCreated},
} {
t.Run(status.name, func(t *testing.T) {
original := httptest.NewRecorder()
wrap := writer.wrap(original)
if status.code != 0 {
wrap.WriteHeader(status.code)
}
wrap.(http.Flusher).Flush()
assertEqual(t, status.want, wrap.Status())
assertEqual(t, status.want, original.Code)
assertEqual(t, true, original.Flushed)
assertEqual(t, 0, wrap.BytesWritten())
// Flushing commits the status, even before any body is written.
wrap.WriteHeader(http.StatusInternalServerError)
_, err := wrap.Write([]byte("body"))
assertNoError(t, err)
wrap.(http.Flusher).Flush()
assertEqual(t, status.want, wrap.Status())
assertEqual(t, status.want, original.Code)
assertEqual(t, "body", original.Body.String())
assertEqual(t, 4, wrap.BytesWritten())
})
}
})
}
}
func TestBasicWritesTeesWritesWithoutDiscard(t *testing.T) {
// explicitly create the struct instead of NewRecorder to control the value of Code
original := &httptest.ResponseRecorder{
HeaderMap: make(http.Header),
Body: new(bytes.Buffer),
}
wrap := &basicWriter{ResponseWriter: original}
var buf bytes.Buffer
wrap.Tee(&buf)
_, err := wrap.Write([]byte("hello world"))
assertNoError(t, err)
assertEqual(t, 200, original.Code)
assertEqual(t, []byte("hello world"), original.Body.Bytes())
assertEqual(t, []byte("hello world"), buf.Bytes())
assertEqual(t, 11, wrap.BytesWritten())
}
func TestBasicWriterDiscardsWritesToOriginalResponseWriter(t *testing.T) {
t.Run("With Tee", func(t *testing.T) {
// explicitly create the struct instead of NewRecorder to control the value of Code
original := &httptest.ResponseRecorder{
HeaderMap: make(http.Header),
Body: new(bytes.Buffer),
}
wrap := &basicWriter{ResponseWriter: original}
var buf bytes.Buffer
wrap.Tee(&buf)
wrap.Discard()
_, err := wrap.Write([]byte("hello world"))
assertNoError(t, err)
assertEqual(t, 0, original.Code) // wrapper shouldn't call WriteHeader implicitly
assertEqual(t, 0, original.Body.Len())
assertEqual(t, []byte("hello world"), buf.Bytes())
assertEqual(t, 11, wrap.BytesWritten())
})
t.Run("Without Tee", func(t *testing.T) {
// explicitly create the struct instead of NewRecorder to control the value of Code
original := &httptest.ResponseRecorder{
HeaderMap: make(http.Header),
Body: new(bytes.Buffer),
}
wrap := &basicWriter{ResponseWriter: original}
wrap.Discard()
_, err := wrap.Write([]byte("hello world"))
assertNoError(t, err)
assertEqual(t, 0, original.Code) // wrapper shouldn't call WriteHeader implicitly
assertEqual(t, 0, original.Body.Len())
assertEqual(t, 11, wrap.BytesWritten())
})
}
// TestHttpFancyWriterReadFromByteCountWithTee is a regression test for
// https://github.com/go-chi/chi/issues/1067.
// httpFancyWriter.ReadFrom was adding n to basicWriter.bytes even when the
// write went through basicWriter.Write (which already increments the counter),
// resulting in double-counting the bytes when a Tee writer was set.
func TestHttpFancyWriterReadFromByteCountWithTee(t *testing.T) {
original := &httptest.ResponseRecorder{
HeaderMap: make(http.Header),
Body: new(bytes.Buffer),
}
f := &httpFancyWriter{basicWriter: basicWriter{ResponseWriter: original}}
var teeBuf bytes.Buffer
f.Tee(&teeBuf)
const input = "hello world"
n, err := f.ReadFrom(strings.NewReader(input))
assertNoError(t, err)
assertEqual(t, int64(len(input)), n)
// Before the fix, BytesWritten() returned 22 (double-counted).
assertEqual(t, len(input), f.BytesWritten())
assertEqual(t, []byte(input), teeBuf.Bytes())
}
// TestHttpFancyWriterReadFromHonorsDiscard verifies that Discard() suppresses
// the body even when it is written via ReadFrom/io.Copy. Before the fix, the
// non-tee ReadFrom path streamed straight to the original ResponseWriter's
// ReaderFrom, bypassing the discard flag entirely.
func TestHttpFancyWriterReadFromHonorsDiscard(t *testing.T) {
original := &readerFromRecorder{&httptest.ResponseRecorder{
HeaderMap: make(http.Header),
Body: new(bytes.Buffer),
}}
f := &httpFancyWriter{basicWriter: basicWriter{ResponseWriter: original}}
f.Discard()
const input = "hello world"
n, err := f.ReadFrom(strings.NewReader(input))
assertNoError(t, err)
assertEqual(t, int64(len(input)), n)
// The data must NOT reach the original writer once Discard() is set.
assertEqual(t, 0, original.Body.Len())
// BytesWritten still reflects the bytes consumed.
assertEqual(t, len(input), f.BytesWritten())
}