package proxy_test import ( "encoding/json" "net/http" "net/netip" "slices" "strconv" "strings" "testing" "time" "sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/proxy" "sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/state" ) const ( adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name // adminSecret is the SWWAF_ADMIN_TOKEN the tests set, and adminBearer // how a request carries it. adminSecret = "fedcba9876543210fedcba9876543210" adminBearer = "Bearer " + adminSecret // adminClient is the client the tests' admin sends its requests from. adminClient = "192.0.2.10" // banOtherClient is the body of a request to ban otherClient for an // hour. banOtherClient = `{"netblock": "` + otherClient + `", "duration": "1h", ` + `"reason": "probes for logins"}` ) func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) { t.Parallel() // The metrics token is set, and opens none of them. s, clk, server := startWithClock(t, "", map[string]string{metricsToken: token}) server.Ledger.BanForLimit(netip.MustParsePrefix(otherClient+"/32"), clk.Now(), bans.Notes{}) before := server.Ledger.Snapshot() // An empty token does not match the unset one either. for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} { for _, e := range adminEndpoints() { s.adminRequest(adminClient, authorization, e.method, e.path, e.body, http.StatusNotFound, requestlog.ActionAdmin) } } if after := server.Ledger.Snapshot(); !slices.Equal(after, before) { t.Errorf("the bans are now\n%+v\nwant them unchanged\n%+v", after, before) } } func TestAdminEndpointsNeedTheAdminToken(t *testing.T) { t.Parallel() s, _, server := startWithClock(t, "", map[string]string{ adminToken: adminSecret, metricsToken: token, }) // Listing the bans, banning otherClient, lifting that ban, and asking // about otherClient, in that order. Without the admin token, with the // metrics token, or with one that differs, each is refused, and // changes nothing; with the admin token, it is answered. for _, e := range adminEndpoints() { before := server.Ledger.Snapshot() for _, authorization := range []string{ "", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret, } { got := s.adminRequest(adminClient, authorization, e.method, e.path, e.body, http.StatusUnauthorized, requestlog.ActionAdmin) if got.header.Get("WWW-Authenticate") != "Bearer" { t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer", e.method, e.path, authorization) } } if after := server.Ledger.Snapshot(); !slices.Equal(after, before) { t.Errorf("%s %s without the token changed the bans to\n%+v\nfrom\n%+v", e.method, e.path, after, before) } got := s.admin(e.method, e.path, e.body, http.StatusOK) if got.header.Get("Content-Type") != "application/json" { t.Errorf("%s %s answered %q", e.method, e.path, got.header.Get("Content-Type")) } } // Any other request under /_smallwebwaf/ is not found. for _, e := range []adminEndpoint{ {http.MethodPut, proxy.BansPath, banOtherClient}, {http.MethodDelete, proxy.BansPath, ""}, {http.MethodGet, proxy.BansPath + "/" + otherClient, ""}, {http.MethodPost, proxy.ClientsPath + otherClient, ""}, {http.MethodGet, strings.TrimSuffix(proxy.ClientsPath, "/"), ""}, } { s.admin(e.method, e.path, e.body, http.StatusNotFound) } } func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) { t.Parallel() s, clk, _ := startWithClock(t, "", map[string]string{ adminToken: adminSecret, banScopeV4Prefix: "24", }) // A ban on otherClient bans the /24 a ban on that client covers, so it // refuses client too, for an hour. start := clk.Now() expires := start.Add(time.Hour) want := state.BanEntry{ Netblock: netip.MustParsePrefix("203.0.113.0/24"), Start: start, Expires: &expires, Cause: bans.CauseAdmin, Reason: "probes for logins", } wantBans(t, s.admin(http.MethodPost, proxy.BansPath, banOtherClient, http.StatusOK), want) line := s.get(client, http.StatusForbidden, requestlog.ActionBanned) if line.BanExpires != requestlog.FormatTime(expires) { t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires) } // Its notes count the request it refused. want.Notes.Requests, want.Notes.Refused = 1, 1 wantBans(t, s.admin(http.MethodGet, proxy.BansPath, "", http.StatusOK), want) // Ten minutes on, lifting the bans on client lifts that one, which is // kept, marked lifted. clk.advance(10 * time.Minute) lifted := clk.Now() want.Lifted = &lifted wantBans(t, s.admin(http.MethodDelete, proxy.BansPath+"/"+client, "", http.StatusOK), want) s.get(client, http.StatusOK, requestlog.ActionForward) wantBans(t, s.admin(http.MethodGet, proxy.BansPath, "", http.StatusOK), want) // No ban on it is active any more. s.admin(http.MethodDelete, proxy.BansPath+"/"+client, "", http.StatusNotFound) } func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) { t.Parallel() s, clk, _ := startWithClock(t, "", map[string]string{ adminToken: adminSecret, banScopeV4Prefix: "24", }) start := clk.Now() for _, tc := range []struct { netblock, duration string want string length time.Duration // 0 for a permanent ban }{ // An address stands for the netblock a ban on that client covers. {client, "7d", "203.0.113.0/24", 7 * 24 * time.Hour}, {"::ffff:198.51.100.7", "90m", "198.51.100.0/24", 90 * time.Minute}, {"2001:db8:5::1", "permanent", "2001:db8:5::/64", 0}, // A netblock stands for itself, its bits past its length cleared. {"198.51.100.7/16", "1h", "198.51.0.0/16", time.Hour}, {"2001:db8:6::/48", "1h", "2001:db8:6::/48", time.Hour}, } { body := `{"netblock": "` + tc.netblock + `", "duration": "` + tc.duration + `"}` want := state.BanEntry{ Netblock: netip.MustParsePrefix(tc.want), Start: start, Cause: bans.CauseAdmin, } if tc.length != 0 { expires := start.Add(tc.length) want.Expires = &expires } wantBans(t, s.admin(http.MethodPost, proxy.BansPath, body, http.StatusOK), want) } } func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) { t.Parallel() s, _, server := startWithClock(t, "", map[string]string{adminToken: adminSecret}) for _, tc := range []struct{ body, want string }{ {"", "the body is not a JSON object of netblock, duration and reason: EOF"}, {"netblock=203.0.113.9", "the body is not a JSON object"}, { `{"netblock": "203.0.113.9", "duration": "1h", "until": "2027"}`, `unknown field "until"`, }, { `{"netblock": "203.0.113", "duration": "1h"}`, `netblock "203.0.113" is not an address or a netblock`, }, {`{"duration": "1h"}`, `netblock "" is not an address or a netblock`}, {`{"netblock": "203.0.113.9"}`, `duration "" is not a duration above zero`}, { `{"netblock": "203.0.113.9", "duration": "off"}`, `duration "off" is not a duration above zero`, }, { `{"netblock": "203.0.113.9", "duration": "0s"}`, `duration "0s" is not a duration above zero`, }, { `{"netblock": "203.0.113.9", "duration": "forever"}`, `duration "forever" is not a duration above zero, such as 1h or 7d, ` + `or permanent`, }, // Over the 4 KiB read of a body. { `{"netblock": "203.0.113.9", "duration": "1h", "reason": "` + strings.Repeat("x", 4<<10) + `"}`, "request body too large", }, } { got := s.admin(http.MethodPost, proxy.BansPath, tc.body, http.StatusBadRequest) if !strings.Contains(string(got.body), tc.want) { t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want) } } if held := server.Ledger.Snapshot(); len(held) != 0 { t.Errorf("the ledger holds %+v, want no ban", held) } } func TestBanToAddOverTheRequestSizeLimitIsRefused(t *testing.T) { t.Parallel() s, _, server := startWithClock(t, "", map[string]string{ adminToken: adminSecret, requestMaxBytes: "16", }) // Sent in a chunk, its length is not announced, so that it is found // over SWWAF_REQUEST_MAX_BYTES only as it is read. chunk := `{"netblock": "203.0.113.9", "duration": "1h"}` s.adminRequest(adminClient, adminBearer+"\r\nTransfer-Encoding: chunked", http.MethodPost, proxy.BansPath, strconv.FormatInt(int64(len(chunk)), 16)+"\r\n"+chunk+"\r\n0\r\n\r\n", http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge) if held := server.Ledger.Snapshot(); len(held) != 0 { t.Errorf("the ledger holds %+v, want no ban", held) } } func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) { t.Parallel() s, clk, _ := startWithClock(t, "", map[string]string{ adminToken: adminSecret, rateLimitPerMinute: "2", rateLimitExemptNets: adminClient, }) start := clk.Now() // Two of otherClient's requests are let through; the third breaks the // limit of two a minute, and bans it. s.get(otherClient, http.StatusOK, requestlog.ActionForward) s.get(otherClient, http.StatusOK, requestlog.ActionForward) s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited) // Asked about by its address in IPv6 form too. for _, addr := range []string{otherClient, "::ffff:" + otherClient} { var got struct { Client *ratelimit.Client `json:"client"` Bans []state.BanEntry `json:"bans"` } decode(t, s.admin(http.MethodGet, proxy.ClientsPath+addr, "", http.StatusOK), &got) if got.Client == nil { t.Fatalf("%s: no client", addr) } history := got.Client.History if got.Client.Client != netip.MustParsePrefix(otherClient+"/32") || history.Requests != 3 || history.Forwarded != 2 || history.Refused != 1 || history.Offences.Limit != 1 || !history.FirstSeen.Equal(start) { t.Errorf("%s: client %+v", addr, got.Client) } if len(got.Bans) != 1 || got.Bans[0].Cause != bans.CauseLimit || got.Bans[0].Reason != "requests per minute over the limit of 2" || got.Bans[0].Notes.Count != 3 { t.Errorf("%s: bans %+v, want the one for the broken limit", addr, got.Bans) } } // Of an address no request came from and no ban covers, nothing is // known. got := s.admin(http.MethodGet, proxy.ClientsPath+"198.51.100.99", "", http.StatusOK) if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" { t.Errorf("an unknown client is answered\n%s", got.body) } s.admin(http.MethodGet, proxy.ClientsPath+"203.0.113", "", http.StatusBadRequest) s.admin(http.MethodDelete, proxy.BansPath+"/203.0.113.0/24", "", http.StatusBadRequest) } func TestBannedClientIsRefusedAtTheEndpointsEvenWithTheToken(t *testing.T) { t.Parallel() s, _, _ := startWithClock(t, "", map[string]string{adminToken: adminSecret}) s.admin(http.MethodPost, proxy.BansPath, banOtherClient, http.StatusOK) // otherClient cannot lift its own ban either. for _, e := range adminEndpoints() { s.adminRequest(otherClient, adminBearer, e.method, e.path, e.body, http.StatusForbidden, requestlog.ActionBanned) } } func TestAdminRequestsCountTowardTheLimits(t *testing.T) { t.Parallel() s, _, _ := startWithClock(t, "", map[string]string{ adminToken: adminSecret, rateLimitPerMinute: "2", }) // A request refused for a missing token and one answered count toward // the limit of two a minute, so the next breaks it. s.adminRequest(client, "", http.MethodGet, proxy.BansPath, "", http.StatusUnauthorized, requestlog.ActionAdmin) s.adminRequest(client, adminBearer, http.MethodGet, proxy.BansPath, "", http.StatusOK, requestlog.ActionAdmin) s.adminRequest(client, adminBearer, http.MethodGet, proxy.BansPath, "", http.StatusForbidden, requestlog.ActionRateLimited) } func TestClientInAllowNetsSkipsTheChecksButNeedsTheToken(t *testing.T) { t.Parallel() const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS s, clk, server := startWithClock(t, "", map[string]string{ adminToken: adminSecret, allowNets: allowed, rateLimitPerMinute: "1", }) // A ban on it refuses nothing, and its requests are not counted. server.Ledger.BanForAdmin(netip.MustParsePrefix(allowed+"/32"), clk.Now(), time.Time{}, "") for range 2 { s.adminRequest(allowed, "", http.MethodGet, proxy.BansPath, "", http.StatusUnauthorized, requestlog.ActionAdmin) s.adminRequest(allowed, adminBearer, http.MethodGet, proxy.BansPath, "", http.StatusOK, requestlog.ActionAdmin) } } func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) { t.Parallel() s, _, server := startWithClock(t, "", map[string]string{ adminToken: adminSecret, mode: observe, }) for _, e := range adminEndpoints() { s.adminRequest(adminClient, "", e.method, e.path, e.body, http.StatusUnauthorized, requestlog.ActionAdmin) } if held := server.Ledger.Snapshot(); len(held) != 0 { t.Errorf("the ledger holds %+v, want no ban", held) } } // adminEndpoint is a request to an endpoint SWWAF_ADMIN_TOKEN opens. type adminEndpoint struct { method, path, body string } // adminEndpoints returns a request to each endpoint SWWAF_ADMIN_TOKEN // opens: listing the bans, banning otherClient for an hour, lifting the // bans on otherClient, and asking about otherClient. func adminEndpoints() []adminEndpoint { return []adminEndpoint{ {http.MethodGet, proxy.BansPath, ""}, {http.MethodPost, proxy.BansPath, banOtherClient}, {http.MethodDelete, proxy.BansPath + "/" + otherClient, ""}, {http.MethodGet, proxy.ClientsPath + otherClient, ""}, } } // admin sends a request with method for path, with body, from // adminClient, with the admin token, and checks that it is answered with // status, its log line's action admin. It returns the answer. func (s *sender) admin(method, path, body string, status int) answer { s.t.Helper() return s.adminRequest(adminClient, adminBearer, method, path, body, status, requestlog.ActionAdmin) } // adminRequest sends a request with method for path, with body, from the // client at from, with authorization as its Authorization header unless // it is "", and checks its answer's status and its log line's action, as // request does. authorization may end in more header lines. A body that // is not "" has its length announced, unless authorization names // Transfer-Encoding. It returns the answer. func (s *sender) adminRequest( from, authorization, method, path, body string, status int, action string, ) answer { s.t.Helper() var header []string if authorization != "" { header = append(header, "Authorization: "+authorization) } if body != "" && !strings.Contains(authorization, "Transfer-Encoding") { header = append(header, "Content-Length: "+strconv.Itoa(len(body))) } _, got := s.requestWithBody(method, from, path, strings.Join(header, "\r\n"), body, status, action) return got } // wantBans checks that a ban endpoint answered with want, and no other // ban. func wantBans(t *testing.T, got answer, want ...state.BanEntry) { t.Helper() var decoded struct { Bans []state.BanEntry `json:"bans"` } decode(t, got, &decoded) gotJSON, err := json.Marshal(decoded.Bans) if err != nil { t.Fatalf("encode %+v: %v", decoded.Bans, err) } wantJSON, err := json.Marshal(want) if err != nil { t.Fatalf("encode %+v: %v", want, err) } if string(gotJSON) != string(wantJSON) { t.Errorf("bans\n%s\nwant\n%s", gotJSON, wantJSON) } } // decode reads the JSON answer of an endpoint into value. func decode(t *testing.T, got answer, value any) { t.Helper() err := json.Unmarshal(got.body, value) if err != nil { t.Fatalf("decode %s: %v", got.body, err) } }