mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
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 -->
524 lines
22 KiB
Go
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()
|
|
}
|