Scrub device policy responses in Fleet Desktop (#50094)

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.

## Testing

- [X] Added/updated automated tests
- [X] QA'd all new/changed functionality manually

## fleetd/orbit/Fleet Desktop

- [X] Verified compatibility with the latest released version of Fleet
(see [Must
rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md))
- [X] Verified auto-update works from the released version of component
to the new version (see [tools/tuf/test](../tools/tuf/test/README.md))


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

* **Security Improvements**
* Updated device-authenticated policy and host-detail responses to omit
policy author identity fields and any raw SQL/query data.
* Device policy endpoints now return a device-safe policy representation
consistently.

* **Bug Fixes**
* Prevented administrative policy information from appearing in
device-authenticated host details and policy listings.

* **Tests**
* Strengthened integration coverage to verify device-safe responses
(required user-facing fields present; sensitive fields absent).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Lucas Manuel Rodriguez
2026-07-29 09:34:31 -03:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 451319b384
commit 9c2ef14947
9 changed files with 144 additions and 16 deletions
@@ -0,0 +1 @@
- Fixed device-authenticated ("My device") endpoints so that host policies are returned in a device-safe representation that no longer exposes the policy author's name and email or the policy's raw SQL query.
+3 -3
View File
@@ -188,13 +188,13 @@ func (dc *DeviceClient) Ping() error {
// listDevicePoliciesResponse is a local response type for deserializing the device policies response.
// Definition duplicated for now (orbit should not depend server/service).
type listDevicePoliciesResponse struct {
Err error `json:"error,omitempty"`
Policies []*fleet.HostPolicy `json:"policies"`
Err error `json:"error,omitempty"`
Policies []*fleet.DevicePolicy `json:"policies"`
}
func (r listDevicePoliciesResponse) Error() error { return r.Err }
func (dc *DeviceClient) getListDevicePolicies(token string) ([]*fleet.HostPolicy, error) {
func (dc *DeviceClient) getListDevicePolicies(token string) ([]*fleet.DevicePolicy, error) {
verb, path := "GET", "/api/latest/fleet/device/%s/policies"
var responseBody listDevicePoliciesResponse
err := dc.request(verb, path, token, "", nil, &responseBody)
+8 -2
View File
@@ -15,8 +15,14 @@ import (
"github.com/fleetdm/fleet/v4/server/ptr"
)
func (svc *Service) ListDevicePolicies(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) {
return svc.ds.ListPoliciesForHost(ctx, host)
func (svc *Service) ListDevicePolicies(ctx context.Context, host *fleet.Host) ([]*fleet.DevicePolicy, error) {
policies, err := svc.ds.ListPoliciesForHost(ctx, host)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "list policies for host")
}
// return the device-safe representation of the policies, which excludes
// the policy author's identity and the raw SQL query.
return fleet.HostPoliciesToDevicePolicies(policies), nil
}
// TriggerMigrateMDMDevice triggers the webhook associated with the MDM
@@ -0,0 +1 @@
* Updated client response type for the `GET /api/latest/fleet/device/{token}/policies` endpoint for consistency with the server response type (`fleet.DevicePolicy`).
+59
View File
@@ -554,6 +554,65 @@ type HostPolicy struct {
Response string `json:"response" db:"response"`
}
// DevicePolicy is a device-safe representation of a policy in the context of
// a host, for device-authenticated ("My device") endpoints. It intentionally
// omits fields that must not be exposed to end users holding only a device
// token, such as the policy author's name and email and the raw SQL query.
type DevicePolicy struct {
// ID is the unique ID of the policy.
ID uint `json:"id"`
// Name is the name of the policy.
Name string `json:"name"`
// Description describes the policy.
Description string `json:"description"`
// Resolution describes how to solve a failing policy.
Resolution *string `json:"resolution,omitempty"`
// Platform is a comma-separated string to indicate the target platforms.
//
// Empty string targets all platforms.
Platform string `json:"platform"`
// Critical marks the policy as high impact.
Critical bool `json:"critical"`
// ConditionalAccessEnabled indicates whether this is a policy used for
// conditional access.
ConditionalAccessEnabled bool `json:"conditional_access_enabled"`
// Response can be one of the following values:
// - "pass": if the policy was executed and passed.
// - "fail": if the policy was executed and did not pass.
// - "": if the policy did not run yet.
Response string `json:"response"`
}
// ToDevicePolicy returns the device-safe representation of the host policy.
func (p *HostPolicy) ToDevicePolicy() *DevicePolicy {
return &DevicePolicy{
ID: p.ID,
Name: p.Name,
Description: p.Description,
Resolution: p.Resolution,
Platform: p.Platform,
Critical: p.Critical,
ConditionalAccessEnabled: p.ConditionalAccessEnabled,
Response: p.Response,
}
}
// HostPoliciesToDevicePolicies converts host policies to their device-safe
// representation for device-authenticated endpoints.
func HostPoliciesToDevicePolicies(policies []*HostPolicy) []*DevicePolicy {
if policies == nil {
return nil
}
devicePolicies := make([]*DevicePolicy, 0, len(policies))
for _, p := range policies {
if p == nil {
continue
}
devicePolicies = append(devicePolicies, p.ToDevicePolicy())
}
return devicePolicies
}
// PolicySpec is used to hold policy data to apply policy specs.
//
// Policies are currently identified by name (unique).
+4 -2
View File
@@ -461,8 +461,10 @@ type Service interface {
// HostLiteByIdentifier returns a host and a subset of its fields from its id.
HostLiteByID(ctx context.Context, id uint) (*HostLite, error)
// ListDevicePolicies lists all policies for the given host, including passing / failing summaries
ListDevicePolicies(ctx context.Context, host *Host) ([]*HostPolicy, error)
// ListDevicePolicies lists all policies for the given host in their
// device-safe representation (which excludes the policy author's identity
// and the raw SQL query), including passing / failing responses.
ListDevicePolicies(ctx context.Context, host *Host) ([]*DevicePolicy, error)
// BypassConditionalAccess lets a host skip conditional access checks for one check
BypassConditionalAccess(ctx context.Context, host *Host) error
+2 -2
View File
@@ -264,7 +264,7 @@ type HostLiteByIdentifierFunc func(ctx context.Context, identifier string) (*fle
type HostLiteByIDFunc func(ctx context.Context, id uint) (*fleet.HostLite, error)
type ListDevicePoliciesFunc func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error)
type ListDevicePoliciesFunc func(ctx context.Context, host *fleet.Host) ([]*fleet.DevicePolicy, error)
type BypassConditionalAccessFunc func(ctx context.Context, host *fleet.Host) error
@@ -3288,7 +3288,7 @@ func (s *Service) HostLiteByID(ctx context.Context, id uint) (*fleet.HostLite, e
return s.HostLiteByIDFunc(ctx, id)
}
func (s *Service) ListDevicePolicies(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) {
func (s *Service) ListDevicePolicies(ctx context.Context, host *fleet.Host) ([]*fleet.DevicePolicy, error) {
s.mu.Lock()
s.ListDevicePoliciesFuncInvoked = true
s.mu.Unlock()
+25 -5
View File
@@ -107,8 +107,17 @@ func (r *getDeviceHostRequest) deviceAuthToken() string {
return r.Token
}
// deviceHostDetailResponse wraps the host detail response to shadow the
// host's policies with their device-safe representation, which excludes the
// policy author's identity and the raw SQL query (this is a device-authenticated
// endpoint, so it must not expose admin-only data).
type deviceHostDetailResponse struct {
*fleet.HostDetailResponse
Policies *[]*fleet.DevicePolicy `json:"policies,omitempty"`
}
type getDeviceHostResponse struct {
Host *fleet.HostDetailResponse `json:"host"`
Host *deviceHostDetailResponse `json:"host"`
// Deprecated: use OrgLogoURLDarkMode.
OrgLogoURL string `json:"org_logo_url"`
// Deprecated: use OrgLogoURLLightMode.
@@ -237,8 +246,19 @@ func getDeviceHostEndpoint(ctx context.Context, request interface{}, svc fleet.S
},
}
deviceHost := &deviceHostDetailResponse{HostDetailResponse: resp}
if resp.Policies != nil {
devicePolicies := fleet.HostPoliciesToDevicePolicies(*resp.Policies)
deviceHost.Policies = &devicePolicies
// defense-in-depth: the shadow field above already wins over the
// embedded policies when marshaling, but clear the admin-facing
// policies anyway so they cannot leak if the wrapped response is ever
// marshaled directly.
resp.Policies = nil
}
return getDeviceHostResponse{
Host: resp,
Host: deviceHost,
OrgLogoURL: ac.OrgInfo.OrgLogoURL,
OrgLogoURLLightBackground: ac.OrgInfo.OrgLogoURLLightBackground,
OrgLogoURLDarkMode: ac.OrgInfo.OrgLogoURLDarkMode,
@@ -463,8 +483,8 @@ func (r *listDevicePoliciesRequest) deviceAuthToken() string {
}
type listDevicePoliciesResponse struct {
Err error `json:"error,omitempty"`
Policies []*fleet.HostPolicy `json:"policies"`
Err error `json:"error,omitempty"`
Policies []*fleet.DevicePolicy `json:"policies"`
}
func (r listDevicePoliciesResponse) Error() error { return r.Err }
@@ -484,7 +504,7 @@ func listDevicePoliciesEndpoint(ctx context.Context, request interface{}, svc fl
return listDevicePoliciesResponse{Policies: data}, nil
}
func (svc *Service) ListDevicePolicies(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) {
func (svc *Service) ListDevicePolicies(ctx context.Context, host *fleet.Host) ([]*fleet.DevicePolicy, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
+41 -2
View File
@@ -4439,23 +4439,49 @@ func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() {
err = res.Body.Close()
require.NoError(t, err)
// asserts that a JSON-decoded policy from a device-authenticated endpoint
// only contains device-safe fields, i.e. it never exposes the policy
// author's identity nor the raw SQL query.
assertDeviceSafePolicy := func(policy map[string]any) {
require.NotContains(t, policy, "query")
require.NotContains(t, policy, "author_id")
require.NotContains(t, policy, "author_name")
require.NotContains(t, policy, "author_email")
require.Contains(t, policy, "name")
require.Contains(t, policy, "response")
}
// GET `/api/_version_/fleet/device/{token}/policies`
listDevicePoliciesResp := listDevicePoliciesResponse{}
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/policies", nil, http.StatusOK)
err = json.NewDecoder(res.Body).Decode(&listDevicePoliciesResp)
rawBody, err := io.ReadAll(res.Body)
require.NoError(t, err)
err = res.Body.Close()
require.NoError(t, err)
err = json.Unmarshal(rawBody, &listDevicePoliciesResp)
require.NoError(t, err)
require.Len(t, listDevicePoliciesResp.Policies, 2)
require.NoError(t, listDevicePoliciesResp.Err)
// the response must not leak the policy author's identity nor the raw SQL query
var rawPoliciesResp struct {
Policies []map[string]any `json:"policies"`
}
err = json.Unmarshal(rawBody, &rawPoliciesResp)
require.NoError(t, err)
require.Len(t, rawPoliciesResp.Policies, 2)
for _, policy := range rawPoliciesResp.Policies {
assertDeviceSafePolicy(policy)
}
// GET `/api/_version_/fleet/device/{token}`
getDeviceHostResp := getDeviceHostResponse{}
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token, nil, http.StatusOK)
err = json.NewDecoder(res.Body).Decode(&getDeviceHostResp)
rawBody, err = io.ReadAll(res.Body)
require.NoError(t, err)
err = res.Body.Close()
require.NoError(t, err)
err = json.Unmarshal(rawBody, &getDeviceHostResp)
require.NoError(t, err)
require.NoError(t, getDeviceHostResp.Err)
require.Equal(t, host.ID, getDeviceHostResp.Host.ID)
require.False(t, getDeviceHostResp.Host.RefetchRequested)
@@ -4463,6 +4489,19 @@ func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() {
require.Equal(t, "http://example.com/contact", getDeviceHostResp.OrgContactURL)
require.Len(t, *getDeviceHostResp.Host.Policies, 2)
require.False(t, getDeviceHostResp.GlobalConfig.Features.EnableSoftwareInventory)
// the host's policies must not leak the policy author's identity nor the
// raw SQL query
var rawHostResp struct {
Host struct {
Policies []map[string]any `json:"policies"`
} `json:"host"`
}
err = json.Unmarshal(rawBody, &rawHostResp)
require.NoError(t, err)
require.Len(t, rawHostResp.Host.Policies, 2)
for _, policy := range rawHostResp.Host.Policies {
assertDeviceSafePolicy(policy)
}
// GET `/api/_version_/fleet/device/{token}/desktop`
getDesktopResp := fleetDesktopResponse{}