package proxy_test import ( "net/http" "sync/atomic" "testing" "sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/requestlog" ) // minute is the window of SWWAF_RATE_LIMIT_PER_MINUTE, as a log line's // limit_hit names it. const minute = "minute" func TestRateLimitRefusesBeforeTheApp(t *testing.T) { t.Parallel() var calls atomic.Int32 app := startApp(t, func(http.ResponseWriter, *http.Request) { calls.Add(1) }) addr, out := startProxy(t, app.URL, map[string]string{ trustedProxies: trustLocalhost, rateLimitPerMinute: "1", }) const otherClient = "203.0.113.10" // With a limit of one request a minute, a client's second request is // refused, with 403 by default. A client is one IPv4 address, or one // IPv6 /64; an IPv4 address in IPv6 form is that IPv4 address. requests := []struct { client string // as X-Forwarded-For names it logged string // as the log line's client_ip names it want int }{ {client, client, http.StatusOK}, {client, client, http.StatusForbidden}, {otherClient, otherClient, http.StatusOK}, {"::ffff:" + otherClient, otherClient, http.StatusForbidden}, {"2001:db8::1", "2001:db8::1", http.StatusOK}, {"2001:db8::8000:0:0:1", "2001:db8::8000:0:0:1", http.StatusForbidden}, {"2001:db8:0:1::1", "2001:db8:0:1::1", http.StatusOK}, } for i, sent := range requests { req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) req.Header.Set(forwardedFor, sent.client) wantStatus(t, do(t, req), sent.want) line := out.requestLines(t, i+1)[i] if line.ClientIP != sent.logged { t.Errorf("log line has client_ip %q, want %q", line.ClientIP, sent.logged) } if sent.want == http.StatusOK { wantLine(t, line, http.StatusOK, requestlog.ActionForward) } else { wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited) if line.LimitHit != minute { t.Errorf("log line has limit_hit %q, want minute", line.LimitHit) } } } if calls.Load() != 4 { t.Errorf("the app was called %d times, want 4", calls.Load()) } } func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) { t.Parallel() const denied = "192.0.2.50" // in SWWAF_DENY_NETS s, _, server := startWithClock(t, "", map[string]string{ rateLimitPerMinute: "1", rateLimitExemptPaths: "/assets/,/favicon.ico", denyNets: denied, deniedCountries: "kp", }) // The answers are kept before the requests, so that none waits for // GeoJS. server.GeoJS.Load([]lookup.Answer{ keptAnswer(client, "DE"), keptAnswer(fromKP, "KP"), }) // With a limit of one request a minute, the requests for paths under a // prefix are not counted, so client's first request for / is within // the limit; and once client has reached it, they are not refused. s.request(client, "/assets/app.js", http.StatusOK, requestlog.ActionForward) s.request(client, "/favicon.ico?v=2", http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward) line := s.request(client, "/assets/app.js", http.StatusOK, requestlog.ActionForward) if line.LimitHit != "" { t.Errorf("log line has limit_hit %q, want none", line.LimitHit) } // A path outside every prefix is counted: /assets is not under // /assets/, and breaks the limit. s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited) // A ban, SWWAF_DENY_NETS and the country lists still refuse a path // under a prefix. s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned) s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied) s.request(fromKP, "/assets/app.js", http.StatusForbidden, requestlog.ActionCountryDenied) } func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) { t.Parallel() for _, sent := range []string{ // A prefix matches only at the start of the path. "/static/assets/app.js", // .. once percent-decoded: an app may act on these as /login, the // last as a path under /sneak/app/ or as /assets/x. "/assets/../login", "/assets/%2e%2e/login", "/assets/..%2Flogin", "/assets/..;/login", "/sneak/app/src/branch/main/..%2F..%2F..%2F..%2F..%2F..%2Fassets/x", // An encoded slash or a backslash: Go's router takes /assets%2Fx // for one path segment, not a path under /assets/. "/assets%2Fx", "/assets%2fx", `/assets/x\y`, } { t.Run(sent, func(t *testing.T) { t.Parallel() s, _, _ := startWithClock(t, "", map[string]string{ rateLimitPerMinute: "1", rateLimitExemptPaths: "/assets/", }) // Counted, the second request breaks the limit of one request // a minute. s.request(client, sent, http.StatusOK, requestlog.ActionForward) s.request(client, sent, http.StatusForbidden, requestlog.ActionRateLimited) }) } }