fleet/server/service/integration_desktop_test.go
Lucas Manuel Rodriguez d67fd73611
New rate limit algorithm for Fleet Desktop endpoints (#33344)
Resolves #31890

This new approach allows up to 1000 consecutive failing requests per
minute.
If the threshold of 1000 consecutive failures is reached for an IP, then
we ban request (return 429) from such IP for a duration of 1 minute.
(Any successful request for an IP clears the count.)

This supports the scenario where all hosts are behind a NAT (same IP)
AND still provides protection against brute force attacks (attackers can
only probe 1k requests per minute).

This approach was discussed in Slack with @rfairburn:
https://fleetdm.slack.com/archives/C051QJU3D0V/p1755625131298319?thread_ts=1755101701.844249&cid=C051QJU3D0V.

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [X] Added/updated automated tests
- [X] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [X] QA'd all new/changed functionality manually

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- New Features
- Introduced IP-based rate limiting for Fleet Desktop endpoints to
better support many hosts behind a single public IP (NAT). Requests from
abusive IPs may be temporarily blocked, returning 429 Too Many Requests
with a retry-after hint.
- Documentation
- Added README for a new desktop rate-limit tester, describing usage and
expected behavior.
- Tests
- Added integration tests covering desktop endpoint rate limiting and
Redis-backed banning logic.
- Chores
- Added a command-line tool to stress-test desktop endpoints and verify
rate limiting behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-26 15:03:50 -03:00

524 lines
22 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/datastore/redis"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func (s *integrationTestSuite) TestDeviceAuthenticatedEndpoints() {
t := s.T()
hosts := s.createHosts(t)
ac, err := s.ds.AppConfig(context.Background())
require.NoError(t, err)
ac.OrgInfo.OrgLogoURL = "http://example.com/logo"
ac.OrgInfo.ContactURL = "http://example.com/contact"
ac.Features.EnableSoftwareInventory = true
err = s.ds.SaveAppConfig(context.Background(), ac)
require.NoError(t, err)
// create some mappings and MDM/Munki data
require.NoError(t, s.ds.ReplaceHostDeviceMapping(context.Background(), hosts[0].ID, []*fleet.HostDeviceMapping{
{HostID: hosts[0].ID, Email: "a@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles},
{HostID: hosts[0].ID, Email: "b@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles},
}, fleet.DeviceMappingGoogleChromeProfiles))
_, err = s.ds.SetOrUpdateCustomHostDeviceMapping(context.Background(), hosts[0].ID, "c@b.c", fleet.DeviceMappingCustomInstaller)
require.NoError(t, err)
require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), hosts[0].ID, false, true, "url", false, "", "", false))
require.NoError(t, s.ds.SetOrUpdateMunkiInfo(context.Background(), hosts[0].ID, "1.3.0", nil, nil))
// create a battery for hosts[0]
require.NoError(t, s.ds.ReplaceHostBatteries(context.Background(), hosts[0].ID, []*fleet.HostBattery{
{HostID: hosts[0].ID, SerialNumber: "a", CycleCount: 1, Health: "Normal"},
}))
// create an auth token for hosts[0]
token := "much_valid"
mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(context.Background(), `INSERT INTO host_device_auth (host_id, token) VALUES (?, ?)`, hosts[0].ID, token)
return err
})
// get host without token
res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/", nil, http.StatusNotFound)
res.Body.Close()
// get host with invalid token
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/no_such_token", nil, http.StatusUnauthorized)
res.Body.Close()
// set the mdm configured flag
ctx := context.Background()
appCfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
appCfg.MDM.EnabledAndConfigured = true
err = s.ds.SaveAppConfig(ctx, appCfg)
require.NoError(t, err)
t.Cleanup(func() {
appCfg.MDM.EnabledAndConfigured = false
err = s.ds.SaveAppConfig(ctx, appCfg)
})
// get host with valid token
var getHostResp getDeviceHostResponse
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token, nil, http.StatusOK)
require.NoError(t, json.NewDecoder(res.Body).Decode(&getHostResp))
require.NoError(t, res.Body.Close())
require.Equal(t, hosts[0].ID, getHostResp.Host.ID)
require.False(t, getHostResp.Host.RefetchRequested)
require.Equal(t, "http://example.com/logo", getHostResp.OrgLogoURL)
require.Equal(t, "http://example.com/contact", getHostResp.OrgContactURL)
require.Nil(t, getHostResp.Host.Policies)
require.NotNil(t, getHostResp.Host.Batteries)
require.Equal(t, &fleet.HostBattery{CycleCount: 1, Health: "Normal"}, (*getHostResp.Host.Batteries)[0])
require.True(t, getHostResp.GlobalConfig.MDM.EnabledAndConfigured)
require.True(t, getHostResp.GlobalConfig.Features.EnableSoftwareInventory)
hostDevResp := getHostResp.Host
// make request for same host on the host details API endpoint,
// responses should match, except for policies and DEP assignment
getHostResp = getDeviceHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", hosts[0].ID), nil, http.StatusOK, &getHostResp)
getHostResp.Host.Policies = nil
getHostResp.Host.DEPAssignedToFleet = ptr.Bool(false)
require.Equal(t, hostDevResp, getHostResp.Host)
// request a refetch for that valid host
res = s.DoRawNoAuth("POST", "/api/latest/fleet/device/"+token+"/refetch", nil, http.StatusOK)
res.Body.Close()
// host should have that flag turned to true
getHostResp = getDeviceHostResponse{}
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token, nil, http.StatusOK)
require.NoError(t, json.NewDecoder(res.Body).Decode(&getHostResp))
require.NoError(t, res.Body.Close())
require.True(t, getHostResp.Host.RefetchRequested)
// request a refetch for an invalid token
res = s.DoRawNoAuth("POST", "/api/latest/fleet/device/no_such_token/refetch", nil, http.StatusUnauthorized)
require.NoError(t, res.Body.Close())
// list device mappings for valid token
var listDMResp listHostDeviceMappingResponse
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/device_mapping", nil, http.StatusOK)
require.NoError(t, json.NewDecoder(res.Body).Decode(&listDMResp))
require.NoError(t, res.Body.Close())
require.Equal(t, hosts[0].ID, listDMResp.HostID)
require.Len(t, listDMResp.DeviceMapping, 3)
require.ElementsMatch(t, listDMResp.DeviceMapping, []*fleet.HostDeviceMapping{
{Email: "a@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles},
{Email: "b@b.c", Source: fleet.DeviceMappingGoogleChromeProfiles},
{Email: "c@b.c", Source: fleet.DeviceMappingCustomReplacement},
})
devDMs := listDMResp.DeviceMapping
// compare response with standard list device mapping API for that same host
listDMResp = listHostDeviceMappingResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/device_mapping", hosts[0].ID), nil, http.StatusOK, &listDMResp)
require.Equal(t, hosts[0].ID, listDMResp.HostID)
require.Equal(t, devDMs, listDMResp.DeviceMapping)
// list device mappings for invalid token
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/no_such_token/device_mapping", nil, http.StatusUnauthorized)
require.NoError(t, res.Body.Close())
// get macadmins for valid token
var getMacadm macadminsDataResponse
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/macadmins", nil, http.StatusOK)
require.NoError(t, json.NewDecoder(res.Body).Decode(&getMacadm))
require.NoError(t, res.Body.Close())
require.Equal(t, "1.3.0", getMacadm.Macadmins.Munki.Version)
devMacadm := getMacadm.Macadmins
// compare response with standard macadmins API for that same host
getMacadm = macadminsDataResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/macadmins", hosts[0].ID), nil, http.StatusOK, &getMacadm)
require.Equal(t, devMacadm, getMacadm.Macadmins)
// get macadmins for invalid token
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/no_such_token/macadmins", nil, http.StatusUnauthorized)
require.NoError(t, res.Body.Close())
// response includes license info
getHostResp = getDeviceHostResponse{}
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token, nil, http.StatusOK)
require.NoError(t, json.NewDecoder(res.Body).Decode(&getHostResp))
require.NoError(t, res.Body.Close())
require.NotNil(t, getHostResp.License)
require.Equal(t, getHostResp.License.Tier, "free")
// device policies are not accessible for free endpoints
listPoliciesResp := listDevicePoliciesResponse{}
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/policies", nil, http.StatusPaymentRequired)
require.NoError(t, json.NewDecoder(res.Body).Decode(&getHostResp))
require.NoError(t, res.Body.Close())
require.Nil(t, listPoliciesResp.Policies)
// /device/desktop is not accessible for free endpoints
getDesktopResp := fleetDesktopResponse{}
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/desktop", nil, http.StatusPaymentRequired)
require.NoError(t, json.NewDecoder(res.Body).Decode(&getDesktopResp))
require.NoError(t, res.Body.Close())
require.Nil(t, getDesktopResp.FailingPolicies)
}
// TestDefaultTransparencyURL tests that Fleet Free licensees are restricted to the default transparency url.
func (s *integrationTestSuite) TestDefaultTransparencyURL() {
t := s.T()
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name()),
NodeKey: ptr.String(t.Name()),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "darwin",
})
require.NoError(t, err)
// create device token for host
token := "token_test_default_transparency_url"
mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(context.Background(), `INSERT INTO host_device_auth (host_id, token) VALUES (?, ?)`, host.ID, token)
return err
})
// confirm initial default url
acResp := appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
require.NotNil(t, acResp)
require.Equal(t, fleet.DefaultTransparencyURL, acResp.FleetDesktop.TransparencyURL)
// confirm device endpoint returns initial default url
deviceResp := &transparencyURLResponse{}
rawResp := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/transparency", nil, http.StatusTemporaryRedirect)
json.NewDecoder(rawResp.Body).Decode(deviceResp) //nolint:errcheck
rawResp.Body.Close() //nolint:errcheck
require.NoError(t, deviceResp.Err)
require.Equal(t, fleet.DefaultTransparencyURL, rawResp.Header.Get("Location"))
// empty string applies default url
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{"fleet_desktop": {"transparency_url":""}}`), http.StatusOK, &acResp)
require.NotNil(t, acResp)
require.Equal(t, fleet.DefaultTransparencyURL, acResp.FleetDesktop.TransparencyURL)
// device endpoint returns default url
deviceResp = &transparencyURLResponse{}
rawResp = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/transparency", nil, http.StatusTemporaryRedirect)
json.NewDecoder(rawResp.Body).Decode(deviceResp) //nolint:errcheck
rawResp.Body.Close() //nolint:errcheck
require.NoError(t, deviceResp.Err)
require.Equal(t, fleet.DefaultTransparencyURL, rawResp.Header.Get("Location"))
// modify transparency url with custom url fails
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", fleet.AppConfig{FleetDesktop: fleet.FleetDesktopSettings{TransparencyURL: "customURL"}}, http.StatusUnprocessableEntity, &acResp)
// device endpoint still returns default url
deviceResp = &transparencyURLResponse{}
rawResp = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/transparency", nil, http.StatusTemporaryRedirect)
json.NewDecoder(rawResp.Body).Decode(deviceResp) //nolint:errcheck
rawResp.Body.Close() //nolint:errcheck
require.NoError(t, deviceResp.Err)
require.Equal(t, fleet.DefaultTransparencyURL, rawResp.Header.Get("Location"))
}
func clearRedisKey(t *testing.T, redisPool fleet.RedisPool, key string) {
conn := redis.ConfigureDoer(redisPool, redisPool.Get())
defer conn.Close()
_, err := conn.Do("DEL", key)
require.NoError(t, err)
}
func (s *integrationTestSuite) TestRateLimitOfEndpoints() {
headers := map[string]string{
"X-Forwarded-For": "1.2.3.4",
}
// Clear any previous usage of forgot_password in the test suite to start from scatch.
clearKeys := func() {
clearRedisKey(s.T(), s.redisPool, "ratelimit::forgot_password")
}
clearKeys()
s.T().Cleanup(clearKeys)
testCases := []struct {
endpoint string
verb string
payload interface{}
burst int
status int
}{
{
endpoint: "/api/latest/fleet/forgot_password",
verb: "POST",
payload: forgotPasswordRequest{Email: "some@one.com"},
burst: forgotPasswordRateLimitMaxBurst + 1,
status: http.StatusAccepted,
},
}
// Mock working SMTP for password reset
config, err := s.ds.AppConfig(context.Background())
require.NoError(s.T(), err)
config.SMTPSettings.SMTPConfigured = true
require.NoError(s.T(), s.ds.SaveAppConfig(context.Background(), config))
for _, tCase := range testCases {
b, err := json.Marshal(tCase.payload)
require.NoError(s.T(), err)
for i := 0; i < tCase.burst; i++ {
s.DoRawWithHeaders(tCase.verb, tCase.endpoint, b, tCase.status, headers).Body.Close()
}
s.DoRawWithHeaders(tCase.verb, tCase.endpoint, b, http.StatusTooManyRequests, headers).Body.Close()
}
// Disable it again because integration tests leak state like a sieve
config, err = s.ds.AppConfig(context.Background())
require.NoError(s.T(), err)
config.SMTPSettings.SMTPConfigured = false
require.NoError(s.T(), s.ds.SaveAppConfig(context.Background(), config))
}
func (s *integrationTestSuite) TestErrorReporting() {
t := s.T()
hosts := s.createHosts(t)
token := "much_valid"
mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(context.Background(), `INSERT INTO host_device_auth (host_id, token) VALUES (?, ?)`, hosts[0].ID, token)
return err
})
// invalid token is unauthorized
res := s.DoRawNoAuth("POST", "/api/latest/fleet/device/no_such_token/debug/errors", []byte("{}"), http.StatusUnauthorized)
res.Body.Close()
// invalid request body is a bad request
res = s.DoRawNoAuth("POST", "/api/latest/fleet/device/"+token+"/debug/errors", []byte("{},{}"), http.StatusBadRequest)
res.Body.Close()
data := make(map[string]interface{})
for i := int64(0); i < (maxFleetdErrorReportSize+1024)/20; i++ {
key := fmt.Sprintf("key%d", i)
value := fmt.Sprintf("value%d", i)
data[key] = value
}
jsonData, err := json.Marshal(data)
require.NoError(t, err)
res = s.DoRawNoAuth("POST", "/api/latest/fleet/device/"+token+"/debug/errors", jsonData, http.StatusBadRequest)
res.Body.Close()
res = s.DoRawNoAuth("POST", "/api/latest/fleet/device/"+token+"/debug/errors", []byte("{}"), http.StatusOK)
res.Body.Close()
// Clear errors in error store
s.Do("GET", "/debug/errors", nil, http.StatusOK, "flush", "true")
testTime, err := time.Parse(time.RFC3339, "1969-06-19T21:44:05Z")
require.NoError(t, err)
ferr := fleet.FleetdError{
Vital: true,
ErrorSource: "orbit",
ErrorSourceVersion: "1.1.1",
ErrorTimestamp: testTime,
ErrorMessage: "test message",
ErrorAdditionalInfo: map[string]any{"foo": "bar"},
}
errBytes, err := json.Marshal(ferr)
require.NoError(t, err)
res = s.DoRawNoAuth("POST", "/api/latest/fleet/device/"+token+"/debug/errors", errBytes, http.StatusOK)
res.Body.Close()
time.Sleep(100 * time.Millisecond) // give time for the error to be saved
// Check that error was logged.
var errors []map[string]interface{}
s.DoJSON("GET", "/debug/errors", nil, http.StatusOK, &errors)
require.Len(t, errors, 1)
expectedCount := 1
checkError := func(errorItem map[string]interface{}, expectedCount int) {
assert.EqualValues(t, expectedCount, errorItem["count"])
errChain, ok := errorItem["chain"].([]interface{})
require.True(t, ok, fmt.Sprintf("%T", errorItem["chain"]))
require.Len(t, errChain, 2)
errChain0, ok := errChain[0].(map[string]interface{})
require.True(t, ok, fmt.Sprintf("%T", errChain[0]))
assert.EqualValues(t, "test message", errChain0["message"])
errChain1, ok := errChain[1].(map[string]interface{})
require.True(t, ok, fmt.Sprintf("%T", errChain[1]))
// Check that the exact fleetd error can be retrieved.
b, err := json.Marshal(errChain1["data"])
require.NoError(t, err)
var receivedErr fleet.FleetdError
require.NoError(t, json.Unmarshal(b, &receivedErr))
assert.EqualValues(t, ferr, receivedErr)
}
checkError(errors[0], expectedCount)
// Make sure metadata is present when error is aggregated.
srvCtx := s.server.Config.BaseContext(nil)
aggRaw, err := ctxerr.Aggregate(srvCtx)
require.NoError(t, err)
var errorAgg []ctxerr.ErrorAgg
require.NoError(t, json.Unmarshal(aggRaw, &errorAgg))
require.Len(t, errorAgg, 1)
assert.EqualValues(t, expectedCount, errorAgg[0].Count)
var receivedErr fleet.FleetdError
require.NoError(t, json.Unmarshal(errorAgg[0].Metadata, &receivedErr))
assert.EqualValues(t, ferr.ErrorSource, receivedErr.ErrorSource)
assert.EqualValues(t, ferr.ErrorSourceVersion, receivedErr.ErrorSourceVersion)
assert.EqualValues(t, ferr.ErrorMessage, receivedErr.ErrorMessage)
assert.EqualValues(t, ferr.ErrorAdditionalInfo, receivedErr.ErrorAdditionalInfo)
assert.NotEqual(t, ferr.ErrorTimestamp, receivedErr.ErrorTimestamp) // not included
// Sending error again should increment the count.
res = s.DoRawNoAuth("POST", "/api/latest/fleet/device/"+token+"/debug/errors", errBytes, http.StatusOK)
res.Body.Close()
expectedCount++
// Changing the timestamp should only increment the count.
testTime2, err := time.Parse(time.RFC3339, "2024-10-30T09:44:05Z")
require.NoError(t, err)
ferr = fleet.FleetdError{
Vital: true,
ErrorSource: "orbit",
ErrorSourceVersion: "1.1.1",
ErrorTimestamp: testTime2,
ErrorMessage: "test message",
ErrorAdditionalInfo: map[string]any{"foo": "bar"},
}
errBytes, err = json.Marshal(ferr)
require.NoError(t, err)
res = s.DoRawNoAuth("POST", "/api/latest/fleet/device/"+token+"/debug/errors", errBytes, http.StatusOK)
res.Body.Close()
expectedCount++
time.Sleep(100 * time.Millisecond) // give time for the error(s) to be saved
// Check that error was logged.
s.DoJSON("GET", "/debug/errors", nil, http.StatusOK, &errors)
require.Len(t, errors, 1)
checkError(errors[0], expectedCount)
// Changing vital flag should NOT create a new error, but will overwrite the existing one.
ferr = fleet.FleetdError{
Vital: false,
ErrorSource: "orbit",
ErrorSourceVersion: "1.1.1",
ErrorTimestamp: testTime,
ErrorMessage: "test message",
ErrorAdditionalInfo: map[string]any{"foo": "bar"},
}
errBytes, err = json.Marshal(ferr)
require.NoError(t, err)
res = s.DoRawNoAuth("POST", "/api/latest/fleet/device/"+token+"/debug/errors", errBytes, http.StatusOK)
res.Body.Close()
expectedCount++
time.Sleep(100 * time.Millisecond) // give time for the error(s) to be saved
aggRaw, err = ctxerr.Aggregate(srvCtx)
require.NoError(t, err)
errorAgg = nil
require.NoError(t, json.Unmarshal(aggRaw, &errorAgg))
require.Len(t, errorAgg, 1)
assert.EqualValues(t, expectedCount, errorAgg[0].Count)
// Since the error is not vital, the metadata should be empty.
assert.Empty(t, string(errorAgg[0].Metadata))
// Changing additional info should create a new error
ferr = fleet.FleetdError{
Vital: true,
ErrorSource: "orbit",
ErrorSourceVersion: "1.1.1",
ErrorTimestamp: testTime,
ErrorMessage: "test message",
ErrorAdditionalInfo: map[string]any{"foo": "bar2"},
}
errBytes, err = json.Marshal(ferr)
require.NoError(t, err)
res = s.DoRawNoAuth("POST", "/api/latest/fleet/device/"+token+"/debug/errors", errBytes, http.StatusOK)
res.Body.Close()
time.Sleep(100 * time.Millisecond) // give time for the error(s) to be saved
s.DoJSON("GET", "/debug/errors", nil, http.StatusOK, &errors)
require.Len(t, errors, 2)
}
func (s *integrationEnterpriseTestSuite) TestRateLimitOfDesktopEndpoints() {
createHostAndDeviceToken(s.T(), s.ds, "valid_token")
// Clear any previous usage of forgot_password in the test suite to start from scatch.
clearKeys := func() {
clearRedisKey(s.T(), s.redisPool, "ipbanner::{127.0.0.1}::banned")
clearRedisKey(s.T(), s.redisPool, "ipbanner::{127.0.0.1}::count")
}
clearKeys()
s.T().Cleanup(clearKeys)
allowedConsecutiveFailuresCount := 4
allowedConsecutiveFailuresTimeWindow := 2 * time.Second
banDuration := 2 * time.Second
redis.SetIPBannerTestValues(allowedConsecutiveFailuresCount, allowedConsecutiveFailuresTimeWindow, banDuration)
s.T().Cleanup(redis.UnsetIPBannerTestValues)
// Test that consecutive invalid requests get banned (for IP=127.0.0.1).
// Test different endpoints coming from the same IP.
s.DoRawNoAuth("GET", "/api/latest/fleet/device/invalid_token/desktop", nil, http.StatusUnauthorized).Body.Close()
s.DoRawNoAuth("POST", "/api/latest/fleet/device/invalid_token/refetch", nil, http.StatusUnauthorized).Body.Close()
s.DoRawNoAuth("GET", "/api/latest/fleet/device/invalid_token/transparency", nil, http.StatusUnauthorized).Body.Close()
s.DoRawNoAuth("HEAD", "/api/latest/fleet/device/invalid_token/ping", nil, http.StatusUnauthorized).Body.Close()
// A valid request from another IP does not clear the status of the IP 127.0.0.1 (banned).
s.DoRawWithHeaders("GET", "/api/latest/fleet/device/valid_token/desktop", nil, http.StatusOK, map[string]string{
"X-Forwarded-For": "1.2.3.4",
}).Body.Close()
// 127.0.0.1 IP should be banned.
s.DoRawNoAuth("GET", "/api/latest/fleet/device/invalid_token/desktop", nil, http.StatusTooManyRequests).Body.Close()
s.DoRawNoAuth("GET", "/api/latest/fleet/device/invalid_token/desktop", nil, http.StatusTooManyRequests).Body.Close()
// Wait for the ban window to finish, which should clear the ban on the IP.
time.Sleep(banDuration + 500*time.Millisecond)
s.DoRawNoAuth("GET", "/api/latest/fleet/device/invalid_token/desktop", nil, http.StatusUnauthorized).Body.Close()
// Clear rate limiting.
clearKeys()
// Test that a successful request clears the failing count.
for range allowedConsecutiveFailuresCount - 1 {
s.DoRawNoAuth("GET", "/api/latest/fleet/device/invalid_token/desktop", nil, http.StatusUnauthorized).Body.Close()
}
// Valid request clears the failing count.
s.DoRawNoAuth("GET", "/api/latest/fleet/device/valid_token/desktop", nil, http.StatusOK).Body.Close()
// A new failing request is not banned.
s.DoRawNoAuth("GET", "/api/latest/fleet/device/invalid_token/desktop", nil, http.StatusUnauthorized).Body.Close()
// Test that consecutive invalid requests in a time window bigger than the configured one does not ban the IP (for IP=127.0.0.1).
// Test different endpoints coming from the same IP.
s.DoRawNoAuth("GET", "/api/latest/fleet/device/invalid_token/desktop", nil, http.StatusUnauthorized).Body.Close()
s.DoRawNoAuth("POST", "/api/latest/fleet/device/invalid_token/refetch", nil, http.StatusUnauthorized).Body.Close()
time.Sleep(allowedConsecutiveFailuresTimeWindow + 500*time.Millisecond)
s.DoRawNoAuth("GET", "/api/latest/fleet/device/invalid_token/transparency", nil, http.StatusUnauthorized).Body.Close()
s.DoRawNoAuth("HEAD", "/api/latest/fleet/device/invalid_token/ping", nil, http.StatusUnauthorized).Body.Close()
// A new failing request is not banned.
s.DoRawNoAuth("GET", "/api/latest/fleet/device/invalid_token/desktop", nil, http.StatusUnauthorized).Body.Close()
}