package server import ( "io" "net/http" "net/http/httptest" "net/netip" "net/url" "path/filepath" "regexp" "strconv" "strings" "testing" "go.uber.org/fx/fxtest" "sneak.berlin/go/pixa/internal/config" "sneak.berlin/go/pixa/internal/database" "sneak.berlin/go/pixa/internal/globals" "sneak.berlin/go/pixa/internal/handlers" "sneak.berlin/go/pixa/internal/logger" "sneak.berlin/go/pixa/internal/middleware" ) // testSigningKey is a throwaway signing key; submitting it logs in. const testSigningKey = "test-signing-key-0123456789abcdef" // wrongKey is submitted for a failed login. const wrongKey = "not-the-signing-key" // Addresses for the login rate limit tests. The test server trusts // 10.0.0.0/8 as its proxies, so the X-Forwarded-For sent by proxyPeer is // believed and the one sent by firstClient or secondClient is ignored. const ( firstClient = "198.51.100.1:40000" secondClient = "198.51.100.2:40000" proxyPeer = "10.0.0.1:40000" firstForwarded = "203.0.113.1" secondForwarded = "203.0.113.2" ) // csrfFieldPattern extracts the CSRF token rendered into the login form. var csrfFieldPattern = regexp.MustCompile( `name="gorilla\.csrf\.Token" value="([^"]+)"`) // newTestServer builds the server's real routes from the constructors // cmd/pixad uses, with a throwaway state directory. Debug marks requests // as plain HTTP, so the CSRF check runs without an https Referer. func newTestServer(t *testing.T) *Server { t.Helper() stateDir := t.TempDir() cfg := &config.Config{ Debug: true, SigningKey: testSigningKey, StateDir: stateDir, DBURL: "file:" + filepath.Join(stateDir, "state.sqlite3"), TrustedProxies: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, DownstreamTimeout: config.DefaultDownstreamTimeout, } lc := fxtest.NewLifecycle(t) log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}}) if err != nil { t.Fatalf("logger.New() error = %v", err) } db, err := database.New(lc, database.Params{Logger: log, Config: cfg}) if err != nil { t.Fatalf("database.New() error = %v", err) } h, err := handlers.New(lc, handlers.Params{ Logger: log, Database: db, Config: cfg, }) if err != nil { t.Fatalf("handlers.New() error = %v", err) } mw, err := middleware.New(lc, middleware.Params{Logger: log, Config: cfg}) if err != nil { t.Fatalf("middleware.New() error = %v", err) } lc.RequireStart() t.Cleanup(lc.RequireStop) s := &Server{config: cfg, mw: mw, h: h} s.SetupRoutes() return s } // clientRequest builds a request for / arriving from remoteAddr, carrying // forwardedFor as its X-Forwarded-For header when that is not empty. func clientRequest( t *testing.T, method string, body io.Reader, remoteAddr, forwardedFor string, ) *http.Request { t.Helper() req := httptest.NewRequestWithContext(t.Context(), method, "/", body) req.RemoteAddr = remoteAddr if forwardedFor != "" { req.Header.Set("X-Forwarded-For", forwardedFor) } return req } // postLogin loads the login form with GET / and submits key in it with // POST /, as a browser does, both from the same client. GET / is not rate // limited, so the form must load even for a client over the limit. func postLogin( t *testing.T, s *Server, remoteAddr, forwardedFor, key string, ) *httptest.ResponseRecorder { t.Helper() page := httptest.NewRecorder() s.ServeHTTP(page, clientRequest(t, http.MethodGet, nil, remoteAddr, forwardedFor)) if page.Code != http.StatusOK { t.Fatalf("GET / status = %d, want %d", page.Code, http.StatusOK) } match := csrfFieldPattern.FindStringSubmatch(page.Body.String()) if match == nil { t.Fatalf("no CSRF token field found in the login form") } form := url.Values{"key": {key}, "gorilla.csrf.Token": {match[1]}} req := clientRequest(t, http.MethodPost, strings.NewReader(form.Encode()), remoteAddr, forwardedFor) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") for _, c := range page.Result().Cookies() { req.AddCookie(c) } rec := httptest.NewRecorder() s.ServeHTTP(rec, req) return rec } // tripLoginRateLimit makes LoginAttemptsPerMinute failed logins from one // client, each answered with the login form again, then one more, which // must be refused with 429. It returns the response to that last attempt. func tripLoginRateLimit( t *testing.T, s *Server, remoteAddr, forwardedFor string, ) *httptest.ResponseRecorder { t.Helper() for attempt := range LoginAttemptsPerMinute { rec := postLogin(t, s, remoteAddr, forwardedFor, wrongKey) if rec.Code != http.StatusOK { t.Fatalf("failed login %d status = %d, want %d", attempt+1, rec.Code, http.StatusOK) } } rec := postLogin(t, s, remoteAddr, forwardedFor, wrongKey) if rec.Code != http.StatusTooManyRequests { t.Fatalf("login over the limit status = %d, want %d", rec.Code, http.StatusTooManyRequests) } return rec } // TestLoginRateLimitRefusesAttemptOverLimit verifies the login attempt // after LoginAttemptsPerMinute failed ones from one client is refused with // 429 and a Retry-After header, and that the client cannot get around the // limit by sending X-Forwarded-For: from a peer that is not a trusted // proxy, the header is ignored. func TestLoginRateLimitRefusesAttemptOverLimit(t *testing.T) { t.Parallel() s := newTestServer(t) rec := tripLoginRateLimit(t, s, firstClient, "") retryAfter := rec.Header().Get("Retry-After") seconds, err := strconv.Atoi(retryAfter) if err != nil || seconds <= 0 { t.Errorf("Retry-After = %q, want a positive number of seconds", retryAfter) } rec = postLogin(t, s, firstClient, secondForwarded, wrongKey) if rec.Code != http.StatusTooManyRequests { t.Errorf("login with X-Forwarded-For from an untrusted peer "+ "status = %d, want %d", rec.Code, http.StatusTooManyRequests) } } // TestLoginRateLimitLeavesOtherClientsAlone verifies one client going over // the limit does not limit another: a failed login from a different // address is answered with the login form, and the signing key still logs // it in. func TestLoginRateLimitLeavesOtherClientsAlone(t *testing.T) { t.Parallel() s := newTestServer(t) tripLoginRateLimit(t, s, firstClient, "") rec := postLogin(t, s, secondClient, "", wrongKey) if rec.Code != http.StatusOK { t.Errorf("failed login from another client status = %d, want %d", rec.Code, http.StatusOK) } rec = postLogin(t, s, secondClient, "", testSigningKey) if rec.Code != http.StatusSeeOther { t.Errorf("login with the signing key from another client "+ "status = %d, want %d", rec.Code, http.StatusSeeOther) } } // TestLoginRateLimitCountsClientsBehindProxySeparately verifies the limit // counts the client address resolved from X-Forwarded-For, not the address // of the trusted proxy the requests arrive from, so two clients behind the // same proxy are counted separately. func TestLoginRateLimitCountsClientsBehindProxySeparately(t *testing.T) { t.Parallel() s := newTestServer(t) tripLoginRateLimit(t, s, proxyPeer, firstForwarded) rec := postLogin(t, s, proxyPeer, secondForwarded, wrongKey) if rec.Code != http.StatusOK { t.Errorf("failed login from a second client behind the proxy "+ "status = %d, want %d", rec.Code, http.StatusOK) } } // TestLoginRateLimitCountsIPv6ClientsByPrefix verifies an IPv6 client is // counted by its /64: another address in the same /64 is refused too, // while an address in a different /64 is not. func TestLoginRateLimitCountsIPv6ClientsByPrefix(t *testing.T) { t.Parallel() s := newTestServer(t) tripLoginRateLimit(t, s, proxyPeer, "2001:db8::1") rec := postLogin(t, s, proxyPeer, "2001:db8::2", wrongKey) if rec.Code != http.StatusTooManyRequests { t.Errorf("login from the same /64 status = %d, want %d", rec.Code, http.StatusTooManyRequests) } rec = postLogin(t, s, proxyPeer, "2001:db8:0:1::1", wrongKey) if rec.Code != http.StatusOK { t.Errorf("login from another /64 status = %d, want %d", rec.Code, http.StatusOK) } } // TestLoginRateLimitCountsIPv4MappedClientsSeparately verifies an IPv4 // client that the proxy forwards in IPv4-mapped IPv6 form (::ffff:a.b.c.d) // is counted by its IPv4 address, not by the /64 that every such address // shares, so two of them behind the proxy are counted separately. func TestLoginRateLimitCountsIPv4MappedClientsSeparately(t *testing.T) { t.Parallel() s := newTestServer(t) tripLoginRateLimit(t, s, proxyPeer, "::ffff:"+firstForwarded) rec := postLogin(t, s, proxyPeer, "::ffff:"+secondForwarded, wrongKey) if rec.Code != http.StatusOK { t.Errorf("failed login from a second IPv4-mapped client "+ "status = %d, want %d", rec.Code, http.StatusOK) } }