Skip to content

Commit 627ad01

Browse files
committed
proxy: rate limiter tests
1 parent dd83f83 commit 627ad01

File tree

2 files changed

+505
-0
lines changed

2 files changed

+505
-0
lines changed

proxy/ratelimiter_integration_test.go

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
package proxy_test
2+
3+
import (
4+
"crypto/tls"
5+
"encoding/base64"
6+
"fmt"
7+
"io"
8+
"net"
9+
"net/http"
10+
"path"
11+
"testing"
12+
"time"
13+
14+
"github.com/lightninglabs/aperture/auth"
15+
"github.com/lightninglabs/aperture/proxy"
16+
proxytest "github.com/lightninglabs/aperture/proxy/testdata"
17+
"github.com/stretchr/testify/require"
18+
"google.golang.org/grpc"
19+
"google.golang.org/grpc/credentials"
20+
"google.golang.org/grpc/metadata"
21+
"google.golang.org/grpc/status"
22+
"gopkg.in/macaroon.v2"
23+
)
24+
25+
// buildAuthHeader constructs an Authorization: L402 header with a valid
26+
// macaroon and a given preimage hex string. The macaroon content isn't
27+
// validated by the proxy for key derivation, only parsed.
28+
func buildAuthHeader(t *testing.T, preimageHex string) string {
29+
t.Helper()
30+
31+
dummyMac, err := macaroon.New(
32+
[]byte("key"), []byte("id"), "loc", macaroon.LatestVersion,
33+
)
34+
require.NoError(t, err)
35+
36+
macBytes, err := dummyMac.MarshalBinary()
37+
require.NoError(t, err)
38+
39+
macStr := base64.StdEncoding.EncodeToString(macBytes)
40+
41+
return fmt.Sprintf("L402 %s:%s", macStr, preimageHex)
42+
}
43+
44+
func TestHTTPRateLimit_RetryAfterAndCORS(t *testing.T) {
45+
// Configure a service with a rate limit for path /http/limited.
46+
services := []*proxy.Service{{
47+
Address: testTargetServiceAddress,
48+
HostRegexp: testHostRegexp,
49+
PathRegexp: testPathRegexpHTTP,
50+
Protocol: "http",
51+
Auth: "off",
52+
RateLimits: []proxy.RateLimit{{
53+
PathRegexp: "^/http/limited.*$",
54+
Requests: 1,
55+
Per: 500 * time.Millisecond,
56+
Burst: 1,
57+
}},
58+
}}
59+
60+
mockAuth := auth.NewMockAuthenticator()
61+
p, err := proxy.New(mockAuth, services, []string{})
62+
require.NoError(t, err)
63+
64+
// Start proxy and backend servers.
65+
srv := &http.Server{
66+
Addr: testProxyAddr,
67+
Handler: http.HandlerFunc(p.ServeHTTP),
68+
}
69+
go func() { _ = srv.ListenAndServe() }()
70+
t.Cleanup(func() { _ = srv.Close() })
71+
72+
backend := &http.Server{Addr: testTargetServiceAddress}
73+
go func() { _ = startBackendHTTP(backend) }()
74+
t.Cleanup(func() { _ = backend.Close() })
75+
76+
time.Sleep(100 * time.Millisecond)
77+
78+
client := &http.Client{}
79+
url := fmt.Sprintf("http://%s/http/limited", testProxyAddr)
80+
81+
// First request allowed.
82+
resp, err := client.Get(url)
83+
require.NoError(t, err)
84+
require.Equal(t, http.StatusOK, resp.StatusCode)
85+
_ = resp.Body.Close()
86+
87+
// The second immediate request should be rate limited.
88+
resp, err = client.Get(url)
89+
require.NoError(t, err)
90+
91+
defer resp.Body.Close()
92+
require.Equal(t, http.StatusTooManyRequests, resp.StatusCode)
93+
94+
// Retry-After should be set and sub-second rounded up to at least 1.
95+
ra := resp.Header.Get("Retry-After")
96+
require.Equal(t, "1", ra)
97+
98+
// Ensure the CORS headers are present.
99+
require.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin"))
100+
require.NotEmpty(t, resp.Header.Get("Access-Control-Allow-Methods"))
101+
102+
// Check the html body message.
103+
b, _ := io.ReadAll(resp.Body)
104+
require.Equal(t, "rate limit exceeded\n", string(b))
105+
106+
// After waiting 500ms, the request should succeed again.
107+
time.Sleep(500 * time.Millisecond)
108+
resp, err = client.Get(url)
109+
require.NoError(t, err)
110+
require.Equal(t, http.StatusOK, resp.StatusCode)
111+
_ = resp.Body.Close()
112+
113+
// Test whole-second accuracy: set per=2s and check Retry-After=2.
114+
services[0].RateLimits = []proxy.RateLimit{{
115+
PathRegexp: "^/http/limited.*$",
116+
Requests: 1,
117+
Per: 2 * time.Second,
118+
Burst: 1,
119+
}}
120+
require.NoError(t, p.UpdateServices(services))
121+
122+
resp, err = client.Get(url)
123+
require.NoError(t, err)
124+
require.Equal(t, http.StatusOK, resp.StatusCode)
125+
_ = resp.Body.Close()
126+
127+
resp, err = client.Get(url)
128+
require.NoError(t, err)
129+
defer resp.Body.Close()
130+
require.Equal(t, http.StatusTooManyRequests, resp.StatusCode)
131+
132+
// Due to integer truncation on fractional seconds, Retry-After may be
133+
// "1" if the computed delay is slightly under 2s.
134+
val := resp.Header.Get("Retry-After")
135+
require.Contains(t, []string{"1", "2"}, val)
136+
}
137+
138+
func TestHTTPRateLimit_MultipleRules_Strictest(t *testing.T) {
139+
services := []*proxy.Service{{
140+
Address: testTargetServiceAddress,
141+
HostRegexp: testHostRegexp,
142+
PathRegexp: testPathRegexpHTTP,
143+
Protocol: "http",
144+
Auth: "off",
145+
RateLimits: []proxy.RateLimit{{
146+
PathRegexp: "^/http/limited.*$",
147+
Requests: 2,
148+
Per: time.Second,
149+
Burst: 2,
150+
}, {
151+
PathRegexp: "^/http/limited.*$",
152+
Requests: 1,
153+
Per: time.Second,
154+
Burst: 1,
155+
}},
156+
}}
157+
mockAuth := auth.NewMockAuthenticator()
158+
p, err := proxy.New(mockAuth, services, []string{})
159+
require.NoError(t, err)
160+
161+
srv := &http.Server{
162+
Addr: testProxyAddr,
163+
Handler: http.HandlerFunc(p.ServeHTTP),
164+
}
165+
go func() { _ = srv.ListenAndServe() }()
166+
t.Cleanup(func() { _ = srv.Close() })
167+
168+
backend := &http.Server{
169+
Addr: testTargetServiceAddress,
170+
}
171+
go func() { _ = startBackendHTTP(backend) }()
172+
t.Cleanup(func() { _ = backend.Close() })
173+
174+
time.Sleep(100 * time.Millisecond)
175+
176+
client := &http.Client{}
177+
url := fmt.Sprintf("http://%s/http/limited", testProxyAddr)
178+
179+
// The first request should be allowed by both rules.
180+
resp, _ := client.Get(url)
181+
require.Equal(t, http.StatusOK, resp.StatusCode)
182+
_ = resp.Body.Close()
183+
184+
// The second request should be rate limited by the strictest rule.
185+
resp, err = client.Get(url)
186+
require.NoError(t, err)
187+
defer resp.Body.Close()
188+
require.Equal(t, http.StatusTooManyRequests, resp.StatusCode)
189+
}
190+
191+
func TestHTTPRateLimit_PerIdentityIsolationAndGlobal(t *testing.T) {
192+
services := []*proxy.Service{{
193+
Address: testTargetServiceAddress,
194+
HostRegexp: testHostRegexp,
195+
PathRegexp: testPathRegexpHTTP,
196+
Protocol: "http",
197+
Auth: "off",
198+
RateLimits: []proxy.RateLimit{{
199+
PathRegexp: "^/http/limited.*$",
200+
Requests: 1,
201+
Per: time.Second,
202+
Burst: 1,
203+
}},
204+
}}
205+
mockAuth := auth.NewMockAuthenticator()
206+
p, err := proxy.New(mockAuth, services, []string{})
207+
require.NoError(t, err)
208+
209+
srv := &http.Server{
210+
Addr: testProxyAddr,
211+
Handler: http.HandlerFunc(p.ServeHTTP),
212+
}
213+
go func() { _ = srv.ListenAndServe() }()
214+
t.Cleanup(func() { _ = srv.Close() })
215+
216+
backend := &http.Server{
217+
Addr: testTargetServiceAddress,
218+
}
219+
go func() { _ = startBackendHTTP(backend) }()
220+
t.Cleanup(func() { _ = backend.Close() })
221+
222+
time.Sleep(100 * time.Millisecond)
223+
224+
client := &http.Client{}
225+
url := fmt.Sprintf("http://%s/http/limited", testProxyAddr)
226+
preA := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
227+
preB := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
228+
authA := buildAuthHeader(t, preA)
229+
authB := buildAuthHeader(t, preB)
230+
231+
// A: first allowed, second denied.
232+
req, _ := http.NewRequest("GET", url, nil)
233+
req.Header.Set("Authorization", authA)
234+
resp, _ := client.Do(req)
235+
require.Equal(t, http.StatusOK, resp.StatusCode)
236+
_ = resp.Body.Close()
237+
238+
// Immediate second request should be denied.
239+
req, _ = http.NewRequest("GET", url, nil)
240+
req.Header.Set("Authorization", authA)
241+
resp, err = client.Do(req)
242+
require.NoError(t, err)
243+
defer resp.Body.Close()
244+
require.Equal(t, http.StatusTooManyRequests, resp.StatusCode)
245+
246+
// B: should be allowed independently.
247+
req, _ = http.NewRequest("GET", url, nil)
248+
req.Header.Set("Authorization", authB)
249+
resp, _ = client.Do(req)
250+
require.Equal(t, http.StatusOK, resp.StatusCode)
251+
_ = resp.Body.Close()
252+
253+
// No identity (global bucket): first is allowed, then denied; and
254+
// subsequent anonymous request shares same bucket.
255+
resp, _ = client.Get(url)
256+
require.Equal(t, http.StatusOK, resp.StatusCode)
257+
err = resp.Body.Close()
258+
require.NoError(t, err)
259+
260+
resp, err = client.Get(url)
261+
require.NoError(t, err)
262+
defer resp.Body.Close()
263+
require.Equal(t, http.StatusTooManyRequests, resp.StatusCode)
264+
}
265+
266+
func TestGRPCRateLimit_ResponsesAndCORS(t *testing.T) {
267+
// Start TLS infra like runGRPCTest.
268+
certFile := path.Join(t.TempDir(), "proxy.cert")
269+
keyFile := path.Join(t.TempDir(), "proxy.key")
270+
cp, creds, certData, err := genCertPair(certFile, keyFile)
271+
require.NoError(t, err)
272+
273+
// gRPC server
274+
httpListener, err := net.Listen("tcp", testProxyAddr)
275+
require.NoError(t, err)
276+
tlsListener := tls.NewListener(
277+
httpListener, configFromCert(&certData, cp),
278+
)
279+
t.Cleanup(func() { _ = tlsListener.Close() })
280+
281+
services := []*proxy.Service{{
282+
Address: testTargetServiceAddress,
283+
HostRegexp: testHostRegexp,
284+
PathRegexp: testPathRegexpGRPC,
285+
Protocol: "https",
286+
TLSCertPath: certFile,
287+
Auth: "off",
288+
RateLimits: []proxy.RateLimit{{
289+
PathRegexp: "^/proxy_test\\.Greeter/SayHello.*$",
290+
Requests: 1,
291+
Per: 2 * time.Second,
292+
Burst: 1,
293+
}},
294+
}}
295+
296+
mockAuth := auth.NewMockAuthenticator()
297+
p, err := proxy.New(mockAuth, services, []string{})
298+
require.NoError(t, err)
299+
300+
srv := &http.Server{
301+
Addr: testProxyAddr,
302+
Handler: http.HandlerFunc(p.ServeHTTP),
303+
TLSConfig: configFromCert(&certData, cp),
304+
}
305+
go func() { _ = srv.Serve(tlsListener) }()
306+
t.Cleanup(func() { _ = srv.Close() })
307+
308+
// Start backend gRPC server.
309+
serverOpts := []grpc.ServerOption{
310+
grpc.Creds(credentials.NewTLS(configFromCert(&certData, cp))),
311+
}
312+
backend := grpc.NewServer(serverOpts...)
313+
go func() { _ = startBackendGRPC(backend) }()
314+
t.Cleanup(func() { backend.Stop() })
315+
316+
// Dial client.
317+
conn, err := grpc.Dial(
318+
testProxyAddr, grpc.WithTransportCredentials(creds),
319+
)
320+
require.NoError(t, err)
321+
client := proxytest.NewGreeterClient(conn)
322+
323+
// First call allowed.
324+
_, err = client.SayHello(
325+
t.Context(), &proxytest.HelloRequest{Name: "x"},
326+
)
327+
require.NoError(t, err)
328+
329+
// The second immediate call should be rate-limited.
330+
var hdrMD, trMD metadata.MD
331+
_, err = client.SayHello(
332+
t.Context(), &proxytest.HelloRequest{Name: "x"},
333+
grpc.Header(&hdrMD), grpc.Trailer(&trMD),
334+
)
335+
require.Error(t, err)
336+
337+
st, _ := status.FromError(err)
338+
require.Equal(t, "rate limit exceeded", st.Message())
339+
340+
// CORS headers should be present in either headers or trailers.
341+
vals := hdrMD.Get("Access-Control-Allow-Origin")
342+
if len(vals) == 0 {
343+
vals = trMD.Get("Access-Control-Allow-Origin")
344+
}
345+
require.NotEmpty(t, vals)
346+
require.Equal(t, "*", vals[0])
347+
}

0 commit comments

Comments
 (0)