VYPR
Medium severity6.5GHSA Advisory· Published Jun 12, 2026· Updated Jun 12, 2026

Fleet has observer-level enrollment secret extraction via ORDER BY oracle on labels host-listing endpoint

CVE-2026-46370

Description

Fleet's labels host-listing endpoint allowed low-privilege Observer users to extract host enrollment secrets via cursor-based binary search oracle due to unvalidated order_key parameter.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Fleet's labels host-listing endpoint allowed low-privilege Observer users to extract host enrollment secrets via cursor-based binary search oracle due to unvalidated order_key parameter.

Vulnerability

The vulnerability resides in the GET /api/v1/fleet/labels/{id}/hosts endpoint of Fleet, which constructs its database query using a deprecated helper that did not restrict which columns could appear in the ORDER BY clause. The endpoint accepted a user-supplied order_key parameter without validating it against a column allowlist, allowing the sort order to be driven by sensitive columns in a joined table. Versions prior to 4.85.0 are affected [1][2].

Exploitation

An attacker with Global Observer or Team Observer credentials can supply a sensitive column name (e.g., h.node_key) as the order_key parameter and combine it with the cursor-based after parameter to perform a binary-search oracle attack. By observing whether results are returned or not, the attacker can infer each character of the targeted secret value one at a time [1][2].

Impact

Successful extraction reveals the long-lived shared secrets node_key and orbit_node_key, which are used by osquery and Orbit agents to authenticate to the Fleet server. An attacker with these secrets can impersonate enrolled hosts, submit fabricated query results and host inventory data, retrieve pending scripts and MDM commands, and poison compliance and policy results across the Fleet deployment [1][2].

Mitigation

The vulnerability is fixed in Fleet version 4.85.0 [1][2]. If an immediate upgrade is not possible, administrators should restrict the Observer role to fully trusted users until the patch is applied and rotate node_key and orbit_node_key for any host suspected of exposure by re-enrolling the affected hosts [1][2].

AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

1
0541f7bb2c95

Fix filtering in /api/v1/fleet/labels/:id/hosts endpoint (#44293)

https://github.com/fleetdm/fleetLucas Manuel RodriguezApr 29, 2026Fixed in 4.84.2via llm-release-walk
5 files changed · +451 9
  • changes/fix-hosts-in-label-filtering+1 0 added
    @@ -0,0 +1 @@
    +* Fixed filtering in `/api/v1/fleet/labels/:id/hosts` endpoint.
    
  • server/datastore/mysql/labels.go+78 3 modified
    @@ -14,6 +14,7 @@ import (
     	"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
     	"github.com/fleetdm/fleet/v4/server/fleet"
     	microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
    +	common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
     	"github.com/jmoiron/sqlx"
     )
     
    @@ -1009,6 +1010,80 @@ func (ds *Datastore) ListLabelsForHost(ctx context.Context, hid uint) ([]*fleet.
     	return labels, nil
     }
     
    +// hostsInLabelAllowedOrderKeys defines the allowed order keys for the hosts in label endpoint.
    +// SECURITY: This prevents information disclosure via arbitrary column sorting.
    +// Sensitive columns like 'node_key' and 'orbit_node_key'.
    +// are intentionally excluded to prevent binary search extraction attacks.
    +var hostsInLabelAllowedOrderKeys = common_mysql.OrderKeyAllowlist{
    +	"id":                             "h.id",
    +	"osquery_host_id":                "h.osquery_host_id",
    +	"created_at":                     "h.created_at",
    +	"updated_at":                     "h.updated_at",
    +	"detail_updated_at":              "h.detail_updated_at",
    +	"hostname":                       "h.hostname",
    +	"uuid":                           "h.uuid",
    +	"platform":                       "h.platform",
    +	"osquery_version":                "h.osquery_version",
    +	"os_version":                     "h.os_version",
    +	"build":                          "h.build",
    +	"platform_like":                  "h.platform_like",
    +	"code_name":                      "h.code_name",
    +	"uptime":                         "h.uptime",
    +	"memory":                         "h.memory",
    +	"cpu_type":                       "h.cpu_type",
    +	"cpu_subtype":                    "h.cpu_subtype",
    +	"cpu_brand":                      "h.cpu_brand",
    +	"cpu_physical_cores":             "h.cpu_physical_cores",
    +	"cpu_logical_cores":              "h.cpu_logical_cores",
    +	"hardware_vendor":                "h.hardware_vendor",
    +	"hardware_model":                 "h.hardware_model",
    +	"hardware_version":               "h.hardware_version",
    +	"hardware_serial":                "h.hardware_serial",
    +	"computer_name":                  "h.computer_name",
    +	"primary_ip_id":                  "h.primary_ip_id",
    +	"distributed_interval":           "h.distributed_interval",
    +	"logger_tls_period":              "h.logger_tls_period",
    +	"config_tls_refresh":             "h.config_tls_refresh",
    +	"primary_ip":                     "h.primary_ip",
    +	"primary_mac":                    "h.primary_mac",
    +	"label_updated_at":               "h.label_updated_at",
    +	"last_enrolled_at":               "h.last_enrolled_at",
    +	"refetch_requested":              "h.refetch_requested",
    +	"refetch_critical_queries_until": "h.refetch_critical_queries_until",
    +	"team_id":                        "h.team_id",
    +	"policy_updated_at":              "h.policy_updated_at",
    +	"public_ip":                      "h.public_ip",
    +
    +	"display_name": "hdn.display_name",
    +
    +	// COALESCE required on the following:
    +	// must match SELECT clause so cursor pagination (WHERE) and ORDER BY are consistent
    +	"gigs_disk_space_available":    "COALESCE(hd.gigs_disk_space_available, 0)",
    +	"percent_disk_space_available": "COALESCE(hd.percent_disk_space_available, 0)",
    +	"gigs_total_disk_space":        "COALESCE(hd.gigs_total_disk_space, 0)",
    +	"seen_time":                    "COALESCE(hst.seen_time, h.created_at)",
    +	"software_updated_at":          "COALESCE(hu.software_updated_at, h.created_at)",
    +
    +	"last_restarted_at": "h.last_restarted_at",
    +	"timezone":          "h.timezone",
    +	// must match SELECT clause subquery so cursor pagination (WHERE) and
    +	// ORDER BY are consistent — MySQL disallows SELECT aliases in WHERE.
    +	"team_name": "(SELECT name FROM teams t WHERE t.id = h.team_id)",
    +
    +	// COALESCE required on the following:
    +	// must match SELECT clause so cursor pagination (WHERE) and ORDER BY are consistent
    +	"failing_policies_count":         "COALESCE(host_issues.failing_policies_count, 0)",
    +	"critical_vulnerabilities_count": "COALESCE(host_issues.critical_vulnerabilities_count, 0)",
    +	"total_issues_count":             "COALESCE(host_issues.total_issues_count, 0)",
    +	"device_mapping":                 "COALESCE(dm.device_mapping, 'null')",
    +
    +	"issues": "COALESCE(host_issues.total_issues_count, 0)",
    +
    +	//
    +	// SECURITY:
    +	// Note: 'h.node_key', 'h.orbit_node_key' intentionally EXCLUDED
    +}
    +
     // ListHostsInLabel returns a list of fleet.Host that are associated
     // with fleet.Label referenced by Label ID
     func (ds *Datastore) ListHostsInLabel(ctx context.Context, filter fleet.TeamFilter, lid uint, opt fleet.HostListOptions) ([]*fleet.Host, error) {
    @@ -1244,10 +1319,10 @@ func (ds *Datastore) applyHostLabelFilters(ctx context.Context, filter fleet.Tea
     	// TODO: should search columns include display_name (requires join to host_display_names)?
     	query, whereParams = hostSearchLike(query, whereParams, opt.MatchQuery, hostSearchColumns...)
     
    -	if opt.ListOptions.OrderKey == "issues" {
    -		opt.ListOptions.OrderKey = "host_issues.total_issues_count"
    +	query, whereParams, err = appendListOptionsWithCursorToSQLSecure(query, whereParams, &opt.ListOptions, hostsInLabelAllowedOrderKeys)
    +	if err != nil {
    +		return "", nil, ctxerr.Wrap(ctx, err, "apply host list options")
     	}
    -	query, whereParams = appendListOptionsWithCursorToSQL(query, whereParams, &opt.ListOptions)
     	return query, append(joinParams, whereParams...), nil
     }
     
    
  • server/service/integration_core_test.go+300 0 modified
    @@ -5037,6 +5037,81 @@ func (s *integrationTestSuite) TestLabels() {
     		assert.Equal(t, fleet.WellKnownMDMSimpleMDM, listHostsResp.MDMSolution.Name)
     		assert.Equal(t, "https://simplemdm.com", listHostsResp.MDMSolution.ServerURL)
     
    +		// invalid order_key returns 422 (sensitive columns must not be sortable to prevent
    +		// information disclosure via binary search extraction).
    +		for _, key := range []string{
    +			"node_key", "h.node_key",
    +			"orbit_node_key", "h.orbit_node_key",
    +			"invalid_column", "h.invalid_column",
    +			// computer_name is a valid field, but must not contain the table alias
    +			// (previous version of the endpoint allowed setting aliases here).
    +			"h.computer_name",
    +		} {
    +			res := s.Do("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusUnprocessableEntity, "order_key", key)
    +			errMsg := extractServerErrorText(res.Body)
    +			assert.Contains(t, errMsg, "invalid order_key")
    +			assert.Contains(t, errMsg, key)
    +		}
    +
    +		// all allowed order_key values must be accepted by the endpoint and return the
    +		// hosts in lbl2.
    +		allowedOrderKeys := []string{
    +			"id", "osquery_host_id", "created_at", "updated_at", "detail_updated_at",
    +			"hostname", "uuid", "platform", "osquery_version", "os_version", "build",
    +			"platform_like", "code_name", "uptime", "memory", "cpu_type", "cpu_subtype",
    +			"cpu_brand", "cpu_physical_cores", "cpu_logical_cores",
    +			"hardware_vendor", "hardware_model", "hardware_version", "hardware_serial",
    +			"computer_name", "primary_ip_id", "distributed_interval", "logger_tls_period",
    +			"config_tls_refresh", "primary_ip", "primary_mac", "label_updated_at",
    +			"last_enrolled_at", "refetch_requested", "refetch_critical_queries_until",
    +			"team_id", "policy_updated_at", "public_ip",
    +			"gigs_disk_space_available", "percent_disk_space_available",
    +			"gigs_total_disk_space", "seen_time", "software_updated_at",
    +			"last_restarted_at", "timezone", "team_name",
    +			"failing_policies_count", "critical_vulnerabilities_count",
    +			"total_issues_count",
    +			"issues", // supported as alias for "total_issues_count"
    +			"device_mapping",
    +			"display_name",
    +		}
    +		for _, key := range allowedOrderKeys {
    +			listHostsResp = listHostsResponse{}
    +			s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp, "order_key", key, "device_mapping", "true")
    +			assert.Len(t, listHostsResp.Hosts, len(lbl2Hosts), "order_key=%s", key)
    +		}
    +
    +		// every allowed order_key must also accept the `after` cursor without
    +		// erroring — guards against SELECT-list aliases leaking into the WHERE
    +		// clause (MySQL disallows aliases in WHERE).
    +		for _, key := range allowedOrderKeys {
    +			listHostsResp = listHostsResponse{}
    +			s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusOK, &listHostsResp,
    +				"order_key", key, "order_direction", "asc", "after", "0", "device_mapping", "true")
    +		}
    +
    +		// issue-related order_keys are rejected when disable_issues=true.
    +		for _, key := range []string{"issues", "failing_policies_count", "critical_vulnerabilities_count", "total_issues_count"} {
    +			res := s.Do("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusBadRequest,
    +				"order_key", key, "disable_issues", "true")
    +			errMsg := extractServerErrorText(res.Body)
    +			assert.Contains(t, errMsg, "Invalid order_key")
    +			assert.Contains(t, errMsg, key)
    +		}
    +
    +		// device_mapping order_key is rejected when device_mapping is not enabled.
    +		res = s.Do("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusBadRequest,
    +			"order_key", "device_mapping")
    +		errMsg = extractServerErrorText(res.Body)
    +		assert.Contains(t, errMsg, "Invalid order_key")
    +		assert.Contains(t, errMsg, "device_mapping")
    +
    +		// device_mapping=false is also rejected.
    +		res = s.Do("GET", fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", lbl2.ID), nil, http.StatusBadRequest,
    +			"order_key", "device_mapping", "device_mapping", "false")
    +		errMsg = extractServerErrorText(res.Body)
    +		assert.Contains(t, errMsg, "Invalid order_key")
    +		assert.Contains(t, errMsg, "device_mapping")
    +
     		// delete a label by id
     		var delIDResp deleteLabelByIDResponse
     		s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/labels/id/%d", lbl1.ID), nil, http.StatusOK, &delIDResp)
    @@ -5318,6 +5393,231 @@ func (s *integrationTestSuite) TestLabels() {
     			})
     		})
     	})
    +
    +	t.Run("Sort by order_key", func(t *testing.T) {
    +		ctx := context.Background()
    +
    +		// Create three teams so each host has a distinct team_id and team_name.
    +		// Names sort A < B < C and team IDs are assigned in creation order.
    +		teamPrefix := strings.ReplaceAll(t.Name(), "/", "_")
    +		teamA, err := s.ds.NewTeam(ctx, &fleet.Team{Name: teamPrefix + "-team-A"})
    +		require.NoError(t, err)
    +		teamB, err := s.ds.NewTeam(ctx, &fleet.Team{Name: teamPrefix + "-team-B"})
    +		require.NoError(t, err)
    +		teamC, err := s.ds.NewTeam(ctx, &fleet.Team{Name: teamPrefix + "-team-C"})
    +		require.NoError(t, err)
    +		teamIDs := []*uint{&teamA.ID, &teamB.ID, &teamC.ID}
    +
    +		// Each sortHost[i] is set up with field values that sort in the same direction
    +		// as the index: sortHost[0] is "smallest", sortHost[2] is "largest", for every
    +		// orderable field below. This means ASC order is always [h0, h1, h2] and DESC
    +		// order is always [h2, h1, h0], which keeps the per-field test cases compact.
    +		base := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
    +		platforms := []string{"darwin", "linux", "windows"}
    +		sortHosts := make([]*fleet.Host, 3)
    +		for i := range 3 {
    +			h, err := s.ds.NewHost(ctx, &fleet.Host{
    +				OsqueryHostID:               ptr.String(fmt.Sprintf("sort-osq-%d", i)),
    +				NodeKey:                     ptr.String(fmt.Sprintf("sort-nk-%d", i)),
    +				UUID:                        fmt.Sprintf("aaaaaaaa-0000-0000-0000-00000000000%d", i+1),
    +				Hostname:                    fmt.Sprintf("sort-host-%d", i),
    +				ComputerName:                fmt.Sprintf("sort-comp-%d", i),
    +				HardwareSerial:              fmt.Sprintf("sort-ser-%d", i),
    +				Platform:                    platforms[i],
    +				PlatformLike:                fmt.Sprintf("sort-plike-%d", i),
    +				OsqueryVersion:              fmt.Sprintf("%d.0.0", i+1),
    +				OSVersion:                   fmt.Sprintf("OS-%d.0", i+1),
    +				Uptime:                      time.Duration(i+1) * time.Hour,
    +				Memory:                      int64(i+1) * 1024,
    +				DistributedInterval:         uint(i+1) * 10,
    +				LoggerTLSPeriod:             uint(i+1) * 100,
    +				ConfigTLSRefresh:            uint(i+1) * 5,
    +				DetailUpdatedAt:             base.Add(time.Duration(i+1) * time.Hour),
    +				LabelUpdatedAt:              base.Add(time.Duration(i+1) * 2 * time.Hour),
    +				PolicyUpdatedAt:             base.Add(time.Duration(i+1) * 3 * time.Hour),
    +				RefetchCriticalQueriesUntil: ptr.Time(base.Add(time.Duration(i+1) * 4 * time.Hour)),
    +				RefetchRequested:            i == 2, // only the "largest" host has refetch_requested = true
    +				TeamID:                      teamIDs[i],
    +			})
    +			require.NoError(t, err)
    +			sortHosts[i] = h
    +		}
    +
    +		// Set columns not handled by NewHost via direct UPDATEs.
    +		for i, h := range sortHosts {
    +			mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
    +				_, err := db.ExecContext(ctx, `
    +					UPDATE hosts SET
    +						created_at = ?,
    +						updated_at = ?,
    +						last_enrolled_at = ?,
    +						last_restarted_at = ?,
    +						primary_ip_id = ?,
    +						primary_ip = ?,
    +						primary_mac = ?,
    +						public_ip = ?,
    +						timezone = ?,
    +						build = ?,
    +						code_name = ?,
    +						cpu_type = ?,
    +						cpu_subtype = ?,
    +						cpu_brand = ?,
    +						cpu_physical_cores = ?,
    +						cpu_logical_cores = ?,
    +						hardware_vendor = ?,
    +						hardware_model = ?,
    +						hardware_version = ?
    +					WHERE id = ?`,
    +					base.Add(time.Duration(i+1)*5*time.Hour),
    +					base.Add(time.Duration(i+1)*6*time.Hour),
    +					base.Add(time.Duration(i+1)*7*time.Hour),
    +					base.Add(time.Duration(i+1)*8*time.Hour),
    +					uint(i+1)*100, //nolint:gosec // ignore G115
    +					fmt.Sprintf("10.0.0.%d", i+1),
    +					fmt.Sprintf("aa:bb:cc:00:00:0%d", i+1),
    +					fmt.Sprintf("8.0.0.%d", i+1),
    +					fmt.Sprintf("UTC-%d", i),
    +					fmt.Sprintf("sort-build-%d", i),
    +					fmt.Sprintf("sort-code-%d", i),
    +					fmt.Sprintf("sort-ct-%d", i),
    +					fmt.Sprintf("sort-cs-%d", i),
    +					fmt.Sprintf("sort-cb-%d", i),
    +					i+1,
    +					(i+1)*2,
    +					fmt.Sprintf("sort-hv-%d", i),
    +					fmt.Sprintf("sort-hm-%d", i),
    +					fmt.Sprintf("sort-hver-%d", i),
    +					h.ID,
    +				)
    +				return err
    +			})
    +		}
    +
    +		// Populate joined tables (host_disks, host_seen_times, host_updates,
    +		// host_issues, host_emails) with values that also sort by host index.
    +		for i, h := range sortHosts {
    +			mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
    +				_, err := db.ExecContext(ctx, `
    +					INSERT INTO host_disks (host_id, gigs_disk_space_available, percent_disk_space_available, gigs_total_disk_space)
    +					VALUES (?, ?, ?, ?)`,
    +					h.ID, float64((i+1)*100), float64((i+1)*10), float64((i+1)*1000))
    +				return err
    +			})
    +			mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
    +				_, err := db.ExecContext(ctx,
    +					`UPDATE host_seen_times SET seen_time = ? WHERE host_id = ?`,
    +					base.Add(time.Duration(i+1)*9*time.Hour), h.ID)
    +				return err
    +			})
    +			mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
    +				_, err := db.ExecContext(ctx,
    +					`INSERT INTO host_updates (host_id, software_updated_at) VALUES (?, ?)`,
    +					h.ID, base.Add(time.Duration(i+1)*10*time.Hour))
    +				return err
    +			})
    +			mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
    +				_, err := db.ExecContext(ctx, `
    +					INSERT INTO host_issues (host_id, failing_policies_count, critical_vulnerabilities_count, total_issues_count)
    +					VALUES (?, ?, ?, ?)`,
    +					h.ID, uint(i+1), uint(i+1)*2, uint(i+1)*3) //nolint:gosec // ignore G115
    +				return err
    +			})
    +			mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
    +				_, err := db.ExecContext(ctx,
    +					`INSERT INTO host_emails (host_id, email, source) VALUES (?, ?, ?)`,
    +					h.ID, fmt.Sprintf("sort-%d@example.com", i), "src")
    +				return err
    +			})
    +		}
    +
    +		// Create a dynamic label and add the three hosts to it.
    +		var createResp fleet.CreateLabelResponse
    +		s.DoJSON("POST", "/api/latest/fleet/labels",
    +			&fleet.LabelPayload{Name: teamPrefix + "-sort", Query: "select 1"}, http.StatusOK, &createResp)
    +		sortLabel := createResp.Label.Label
    +		for _, h := range sortHosts {
    +			require.NoError(t, s.ds.RecordLabelQueryExecutions(ctx, h,
    +				map[uint]*bool{sortLabel.ID: new(true)}, time.Now(), false))
    +		}
    +
    +		ascIDs := []uint{sortHosts[0].ID, sortHosts[1].ID, sortHosts[2].ID}
    +		descIDs := []uint{sortHosts[2].ID, sortHosts[1].ID, sortHosts[0].ID}
    +		labelHostsURL := fmt.Sprintf("/api/latest/fleet/labels/%d/hosts", sortLabel.ID)
    +
    +		// orderKeys lists every field for which sortHosts is set up to produce a
    +		// strict, deterministic ASC ordering of [h0, h1, h2]. Each field gets its
    +		// own subtest verifying both ASC and DESC orderings.
    +		orderKeys := []string{
    +			"id", "osquery_host_id", "created_at", "updated_at", "detail_updated_at",
    +			"hostname", "uuid", "platform", "osquery_version", "os_version", "build",
    +			"platform_like", "code_name", "uptime", "memory", "cpu_type", "cpu_subtype",
    +			"cpu_brand", "cpu_physical_cores", "cpu_logical_cores",
    +			"hardware_vendor", "hardware_model", "hardware_version", "hardware_serial",
    +			"computer_name", "primary_ip_id", "distributed_interval", "logger_tls_period",
    +			"config_tls_refresh", "primary_ip", "primary_mac", "label_updated_at",
    +			"last_enrolled_at", "refetch_critical_queries_until", "team_id",
    +			"policy_updated_at", "public_ip",
    +			"gigs_disk_space_available", "percent_disk_space_available",
    +			"gigs_total_disk_space", "seen_time", "software_updated_at",
    +			"last_restarted_at", "timezone", "team_name",
    +			"failing_policies_count", "critical_vulnerabilities_count",
    +			"total_issues_count", "issues",
    +			"device_mapping",
    +			"display_name",
    +		}
    +		for _, key := range orderKeys {
    +			t.Run(key, func(t *testing.T) {
    +				params := []string{"order_key", key, "order_direction", "asc"}
    +				if key == "device_mapping" {
    +					params = append(params, "device_mapping", "true")
    +				}
    +
    +				var resp listHostsResponse
    +				s.DoJSON("GET", labelHostsURL, nil, http.StatusOK, &resp, params...)
    +				require.Len(t, resp.Hosts, 3)
    +				gotAsc := []uint{resp.Hosts[0].ID, resp.Hosts[1].ID, resp.Hosts[2].ID}
    +				assert.Equal(t, ascIDs, gotAsc, "asc order mismatch")
    +
    +				params[3] = "desc"
    +				resp = listHostsResponse{}
    +				s.DoJSON("GET", labelHostsURL, nil, http.StatusOK, &resp, params...)
    +				require.Len(t, resp.Hosts, 3)
    +				gotDesc := []uint{resp.Hosts[0].ID, resp.Hosts[1].ID, resp.Hosts[2].ID}
    +				assert.Equal(t, descIDs, gotDesc, "desc order mismatch")
    +			})
    +		}
    +
    +		// refetch_requested is a bool, so only h2 (true) has a unique value. ASC must
    +		// place h2 last; DESC must place h2 first. The order between h0 and h1 (both
    +		// false) is not deterministic, so we only assert the position of h2.
    +		t.Run("refetch_requested", func(t *testing.T) {
    +			var resp listHostsResponse
    +			s.DoJSON("GET", labelHostsURL, nil, http.StatusOK, &resp,
    +				"order_key", "refetch_requested", "order_direction", "asc")
    +			require.Len(t, resp.Hosts, 3)
    +			assert.Equal(t, sortHosts[2].ID, resp.Hosts[2].ID, "asc: h2 should be last")
    +
    +			resp = listHostsResponse{}
    +			s.DoJSON("GET", labelHostsURL, nil, http.StatusOK, &resp,
    +				"order_key", "refetch_requested", "order_direction", "desc")
    +			require.Len(t, resp.Hosts, 3)
    +			assert.Equal(t, sortHosts[2].ID, resp.Hosts[0].ID, "desc: h2 should be first")
    +		})
    +
    +		// Cursor pagination (`after`) injects the order_key into the WHERE
    +		// clause; SELECT-list aliases like team_name would error there. Verify
    +		// that paging through team_name with a cursor returns the expected
    +		// hosts and does not error.
    +		t.Run("team_name with after cursor", func(t *testing.T) {
    +			var resp listHostsResponse
    +			s.DoJSON("GET", labelHostsURL, nil, http.StatusOK, &resp,
    +				"order_key", "team_name", "order_direction", "asc",
    +				"after", teamA.Name, "per_page", "10")
    +			require.Len(t, resp.Hosts, 2)
    +			assert.Equal(t, sortHosts[1].ID, resp.Hosts[0].ID)
    +			assert.Equal(t, sortHosts[2].ID, resp.Hosts[1].ID)
    +		})
    +	})
     }
     
     // Sanity test to make sure fleet/labels/<all>/hosts and fleet/hosts return the same thing.
    
  • server/service/transport.go+35 6 modified
    @@ -330,12 +330,33 @@ func hostListOptionsFromRequest(r *http.Request) (fleet.HostListOptions, error)
     		}
     		hopt.DisableIssues = boolVal
     	}
    -	if hopt.DisableIssues && r.URL.Query().Get("order_key") == "issues" {
    -		return hopt, ctxerr.Wrap(
    -			r.Context(), badRequest(
    -				"Invalid order_key (issues cannot be ordered when they are disabled)",
    -			),
    -		)
    +	if hopt.DisableIssues {
    +		switch {
    +		case r.URL.Query().Get("order_key") == "issues":
    +			return hopt, ctxerr.Wrap(
    +				r.Context(), badRequest(
    +					"Invalid order_key (issues cannot be ordered when they are disabled)",
    +				),
    +			)
    +		case r.URL.Query().Get("order_key") == "failing_policies_count":
    +			return hopt, ctxerr.Wrap(
    +				r.Context(), badRequest(
    +					"Invalid order_key (failing_policies_count cannot be ordered when they are disabled)",
    +				),
    +			)
    +		case r.URL.Query().Get("order_key") == "critical_vulnerabilities_count":
    +			return hopt, ctxerr.Wrap(
    +				r.Context(), badRequest(
    +					"Invalid order_key (critical_vulnerabilities_count cannot be ordered when they are disabled)",
    +				),
    +			)
    +		case r.URL.Query().Get("order_key") == "total_issues_count":
    +			return hopt, ctxerr.Wrap(
    +				r.Context(), badRequest(
    +					"Invalid order_key (total_issues_count cannot be ordered when they are disabled)",
    +				),
    +			)
    +		}
     	}
     
     	deviceMapping := r.URL.Query().Get("device_mapping")
    @@ -347,6 +368,14 @@ func hostListOptionsFromRequest(r *http.Request) (fleet.HostListOptions, error)
     		hopt.DeviceMapping = boolVal
     	}
     
    +	if !hopt.DeviceMapping && r.URL.Query().Get("order_key") == "device_mapping" {
    +		return hopt, ctxerr.Wrap(
    +			r.Context(), badRequest(
    +				"Invalid order_key (device_mapping cannot be ordered when they are disabled)",
    +			),
    +		)
    +	}
    +
     	mdmID := r.URL.Query().Get("mdm_id")
     	if mdmID != "" {
     		id, err := strconv.ParseUint(mdmID, 10, 32)
    
  • server/service/transport_test.go+37 0 modified
    @@ -308,6 +308,43 @@ func TestHostListOptionsFromRequest(t *testing.T) {
     			url:          "/foo?disable_failing_policies=true&order_key=issues",
     			errorMessage: "Invalid order_key",
     		},
    +		"error in failing_policies_count order key when disable_issues is set": {
    +			url:          "/foo?disable_issues=true&order_key=failing_policies_count",
    +			errorMessage: "Invalid order_key (failing_policies_count cannot be ordered when they are disabled)",
    +		},
    +		"error in critical_vulnerabilities_count order key when disable_issues is set": {
    +			url:          "/foo?disable_issues=true&order_key=critical_vulnerabilities_count",
    +			errorMessage: "Invalid order_key (critical_vulnerabilities_count cannot be ordered when they are disabled)",
    +		},
    +		"error in total_issues_count order key when disable_issues is set": {
    +			url:          "/foo?disable_issues=true&order_key=total_issues_count",
    +			errorMessage: "Invalid order_key (total_issues_count cannot be ordered when they are disabled)",
    +		},
    +		"failing_policies_count order key allowed when disable_issues is not set": {
    +			url: "/foo?order_key=failing_policies_count",
    +			hostListOptions: fleet.HostListOptions{
    +				ListOptions: fleet.ListOptions{
    +					OrderKey: "failing_policies_count",
    +				},
    +			},
    +		},
    +		"error in device_mapping order key when device_mapping is not enabled": {
    +			url:          "/foo?order_key=device_mapping",
    +			errorMessage: "Invalid order_key (device_mapping cannot be ordered when they are disabled)",
    +		},
    +		"error in device_mapping order key when device_mapping is explicitly false": {
    +			url:          "/foo?device_mapping=false&order_key=device_mapping",
    +			errorMessage: "Invalid order_key (device_mapping cannot be ordered when they are disabled)",
    +		},
    +		"device_mapping order key allowed when device_mapping is enabled": {
    +			url: "/foo?device_mapping=true&order_key=device_mapping",
    +			hostListOptions: fleet.HostListOptions{
    +				ListOptions: fleet.ListOptions{
    +					OrderKey: "device_mapping",
    +				},
    +				DeviceMapping: true,
    +			},
    +		},
     		"error in device_mapping": {
     			url:          "/foo?device_mapping=foo",
     			errorMessage: "Invalid device_mapping",
    

Vulnerability mechanics

Root cause

"Missing column allowlist validation on the order_key parameter allowed sorting by sensitive columns (node_key, orbit_node_key) in the joined hosts table."

Attack vector

The `GET /api/v1/fleet/labels/{id}/hosts` endpoint accepted a user-supplied `order_key` parameter that was not validated against a column allowlist. An attacker with Global Observer or Team Observer credentials could supply a sensitive column name such as `h.node_key` as the `order_key` and combine it with the cursor-based `after` parameter to perform a binary search extraction of the column's values one character at a time. Although the values never appeared in the response body, the presence or absence of results revealed each character, enabling extraction of the long-lived `node_key` and `orbit_node_key` shared secrets used by osquery and Orbit agents to authenticate to the Fleet server.

What the fix does

The patch introduces `hostsInLabelAllowedOrderKeys`, a strict allowlist mapping allowed user-facing order keys to their corresponding SQL expressions. The deprecated `appendListOptionsWithCursorToSQL` helper is replaced with `appendListOptionsWithCursorToSQLSecure`, which rejects any `order_key` not present in the allowlist and returns HTTP 422. Sensitive columns such as `h.node_key` and `h.orbit_node_key` are intentionally excluded from the allowlist [patch_id=5750054]. Additional validation in `hostListOptionsFromRequest` denies issue-related and device-mapping order keys when those features are disabled, preventing edge cases where the allowlist alone might be bypassed via feature-flag logic.

Preconditions

  • authAuthenticated session with Global Observer or Team Observer role
  • networkNetwork access to the Fleet server's `/api/v1/fleet/labels/{id}/hosts` endpoint
  • configThe endpoint must not have been patched (only affects versions prior to v4.85.0)

Generated on Jun 12, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.