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
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:
33
middleware/basic_auth.go
Normal file
33
middleware/basic_auth.go
Normal 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)
|
||||
}
|
||||
59
middleware/basic_auth_test.go
Normal file
59
middleware/basic_auth_test.go
Normal 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
28
middleware/clean_path.go
Normal 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)
|
||||
})
|
||||
}
|
||||
23
middleware/clean_path_test.go
Normal file
23
middleware/clean_path_test.go
Normal 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
274
middleware/client_ip.go
Normal 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
|
||||
}
|
||||
58
middleware/client_ip_bench_test.go
Normal file
58
middleware/client_ip_bench_test.go
Normal 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
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
108
middleware/client_ip_example_test.go
Normal file
108
middleware/client_ip_example_test.go
Normal 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
|
||||
}
|
||||
737
middleware/client_ip_test.go
Normal file
737
middleware/client_ip_test.go
Normal 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
412
middleware/compress.go
Normal 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
251
middleware/compress_test.go
Normal 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)
|
||||
}
|
||||
51
middleware/content_charset.go
Normal file
51
middleware/content_charset.go
Normal 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
|
||||
}
|
||||
159
middleware/content_charset_test.go
Normal file
159
middleware/content_charset_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
34
middleware/content_encoding.go
Normal file
34
middleware/content_encoding.go
Normal 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)
|
||||
}
|
||||
}
|
||||
82
middleware/content_encoding_test.go
Normal file
82
middleware/content_encoding_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
45
middleware/content_type.go
Normal file
45
middleware/content_type.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
82
middleware/content_type_test.go
Normal file
82
middleware/content_type_test.go
Normal 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
39
middleware/get_head.go
Normal 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)
|
||||
})
|
||||
}
|
||||
66
middleware/get_head_test.go
Normal file
66
middleware/get_head_test.go
Normal 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
26
middleware/heartbeat.go
Normal 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
|
||||
}
|
||||
31
middleware/heartbeat_test.go
Normal file
31
middleware/heartbeat_test.go
Normal 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
178
middleware/logger.go
Normal 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
51
middleware/logger_test.go
Normal 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
18
middleware/maybe.go
Normal 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
23
middleware/middleware.go
Normal 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
|
||||
}
|
||||
213
middleware/middleware_test.go
Normal file
213
middleware/middleware_test.go
Normal 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
59
middleware/nocache.go
Normal 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
20
middleware/page_route.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
16
middleware/path_rewrite.go
Normal file
16
middleware/path_rewrite.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
26
middleware/path_rewrite_test.go
Normal file
26
middleware/path_rewrite_test.go
Normal 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
48
middleware/profiler.go
Normal 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
53
middleware/realip.go
Normal 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
115
middleware/realip_test.go
Normal 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
209
middleware/recoverer.go
Normal 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
|
||||
}
|
||||
101
middleware/recoverer_test.go
Normal file
101
middleware/recoverer_test.go
Normal 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
96
middleware/request_id.go
Normal 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)
|
||||
}
|
||||
71
middleware/request_id_test.go
Normal file
71
middleware/request_id_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
18
middleware/request_size.go
Normal file
18
middleware/request_size.go
Normal 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
146
middleware/route_headers.go
Normal 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)
|
||||
}
|
||||
212
middleware/route_headers_test.go
Normal file
212
middleware/route_headers_test.go
Normal 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
77
middleware/strip.go
Normal 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
329
middleware/strip_test.go
Normal 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
25
middleware/sunset.go
Normal 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
104
middleware/sunset_test.go
Normal 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)
|
||||
}
|
||||
**/
|
||||
27
middleware/supress_notfound.go
Normal file
27
middleware/supress_notfound.go
Normal 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
63
middleware/terminal.go
Normal 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)
|
||||
}
|
||||
}
|
||||
36
middleware/terminal_test.go
Normal file
36
middleware/terminal_test.go
Normal 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
151
middleware/throttle.go
Normal 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
329
middleware/throttle_test.go
Normal 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
48
middleware/timeout.go
Normal 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
77
middleware/url_format.go
Normal 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)
|
||||
}
|
||||
69
middleware/url_format_test.go
Normal file
69
middleware/url_format_test.go
Normal 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
17
middleware/value.go
Normal 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
33
middleware/value_test.go
Normal 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
245
middleware/wrap_writer.go
Normal 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{}
|
||||
184
middleware/wrap_writer_test.go
Normal file
184
middleware/wrap_writer_test.go
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user