Repository navigation
Expand file tree
/
Copy pathhttprate.go
More file actions
168 lines (152 loc) · 5.18 KB
/
Copy pathhttprate.go
File metadata and controls
168 lines (152 loc) · 5.18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
package httprate
import (
"net"
"net/http"
"strings"
"time"
)
// LimitBy is the canonical entry point for rate-limiting by an explicit key.
//
// It is shorthand for Limit with keyFn installed as the rate-limit key. The
// key is a required positional argument, so every call site has to state, on
// purpose, what it rate-limits by.
//
// To rate-limit by a trusted client IP behind a proxy, resolve the IP with one
// of chi's middleware.ClientIPFrom* middlewares (chi v5.3.0+) and read it back
// in the KeyFunc; CanonicalizeIP buckets IPv6 clients by their /64:
//
// r.Use(middleware.ClientIPFromXFF("10.0.0.0/8"))
// r.Use(httprate.LimitBy(100, time.Minute, func(r *http.Request) (string, error) {
// return httprate.CanonicalizeIP(middleware.GetClientIP(r.Context())), nil
// }))
//
// Use JoinKeys to rate-limit by more than one dimension at once:
//
// r.Use(httprate.LimitBy(100, time.Minute,
// httprate.JoinKeys(clientIPKey, httprate.KeyByEndpoint)))
func LimitBy(requestLimit int, windowLength time.Duration, keyFn KeyFunc, options ...Option) func(next http.Handler) http.Handler {
return NewRateLimiter(requestLimit, windowLength, append([]Option{WithKeyFuncs(keyFn)}, options...)...).Handler
}
type KeyFunc func(r *http.Request) (string, error)
type Option func(rl *RateLimiter)
// Set custom response headers. If empty, the header is omitted.
type ResponseHeaders struct {
Limit string // Default: X-RateLimit-Limit
Remaining string // Default: X-RateLimit-Remaining
Increment string // Default: X-RateLimit-Increment
Reset string // Default: X-RateLimit-Reset
RetryAfter string // Default: Retry-After
}
func Key(key string) func(r *http.Request) (string, error) {
return func(r *http.Request) (string, error) {
return key, nil
}
}
// CanonicalizeIP normalizes a client IP string for use as a rate-limit key:
//
// - IPv4 addresses are returned unchanged. IPv4 mapped IPv6 addresses use
// their dotted IPv4 form.
// - IPv6 addresses are reduced to their /64 prefix. An IPv6 client typically
// controls a whole /64 (2^64 addresses via SLAAC), so keying on the full
// address would let it rotate within its own /64 to win a fresh bucket per
// request and bypass a per-IP limit. Widen/narrow the prefix yourself if your
// clients are delegated a larger block (e.g. a /56 or /48).
// - Any other string, including "", is returned unchanged.
//
// httprate stays router-agnostic, so it does not resolve the client IP for you —
// pair CanonicalizeIP with whatever does. With chi's middleware.ClientIPFrom*
// (chi v5.3.0+) and middleware.GetClientIP:
//
// r.Use(middleware.ClientIPFromXFF("10.0.0.0/8"))
// r.Use(httprate.LimitBy(100, time.Minute, func(r *http.Request) (string, error) {
// return httprate.CanonicalizeIP(middleware.GetClientIP(r.Context())), nil
// }))
//
// WARNING: if the resolver returns "" (e.g. no ClientIPFrom* middleware is
// installed upstream), CanonicalizeIP returns "" and every request shares a
// single global rate-limit bucket. Strictly more restrictive, but a footgun —
// make sure the client IP is actually resolved upstream.
func CanonicalizeIP(ip string) string {
isIPv6 := false
// This is how net.ParseIP decides if an address is IPv6
// https://cs.opensource.google/go/go/+/refs/tags/go1.17.7:src/net/ip.go;l=704
for i := 0; !isIPv6 && i < len(ip); i++ {
switch ip[i] {
case '.':
// IPv4
return ip
case ':':
// IPv6
isIPv6 = true
}
}
if !isIPv6 {
// Not an IP address at all
return ip
}
ipv6 := net.ParseIP(ip)
if ipv6 == nil {
return ip
}
if ipv4 := ipv6.To4(); ipv4 != nil {
return ipv4.String()
}
return ipv6.Mask(net.CIDRMask(64, 128)).String()
}
func KeyByEndpoint(r *http.Request) (string, error) {
return r.URL.Path, nil
}
func WithKeyFuncs(keyFuncs ...KeyFunc) Option {
return func(rl *RateLimiter) {
if len(keyFuncs) > 0 {
rl.keyFn = JoinKeys(keyFuncs...)
}
}
}
func WithLimitHandler(h http.HandlerFunc) Option {
return func(rl *RateLimiter) {
rl.onRateLimited = h
}
}
func WithErrorHandler(h func(http.ResponseWriter, *http.Request, error)) Option {
return func(rl *RateLimiter) {
rl.onError = h
}
}
func WithLimitCounter(c LimitCounter) Option {
return func(rl *RateLimiter) {
rl.limitCounter = c
}
}
func WithResponseHeaders(headers ResponseHeaders) Option {
return func(rl *RateLimiter) {
rl.headers = headers
}
}
func WithNoop() Option {
return func(rl *RateLimiter) {}
}
// JoinKeys joins the results of several KeyFuncs into a single key with ":"
// separators, so they can be passed to LimitBy's positional key slot for
// multi-dimensional rate-limiting:
//
// r.Use(httprate.LimitBy(100, time.Minute,
// httprate.JoinKeys(clientIPKey, httprate.KeyByEndpoint)))
//
// where clientIPKey is your own client-IP KeyFunc (see LimitBy and
// CanonicalizeIP). It is the positional-argument equivalent of WithKeyFuncs. If any component
// KeyFunc returns an error, the joined key returns that error.
func JoinKeys(fns ...KeyFunc) KeyFunc {
return func(r *http.Request) (string, error) {
var key strings.Builder
for i := 0; i < len(fns); i++ {
k, err := fns[i](r)
if err != nil {
return "", err
}
key.WriteString(k)
key.WriteRune(':')
}
return key.String(), nil
}
}