Fix GitOps dry run issue with validating profiles with secrets (#32104)

Fixes #31477 

Docs PR: https://github.com/fleetdm/fleet/pull/32116

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [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] QA'd all new/changed functionality manually


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

## Summary by CodeRabbit

- New Features
- GitOps now supports FLEET_SECRET_ placeholders in macOS
(.mobileconfig/.xml) profiles. Secrets are expanded only for validation,
while remaining unexpanded in uploaded content.
- Improved environment variable handling: non-secret vars expand as
before; server-side secrets are preserved.
- Validation enforces that profile display names cannot contain
FLEET_SECRET_ values.

- Bug Fixes
- Resolves validation issues when FLEET_SECRET_ appears in <data> tags
by performing safe client-side expansion for validation.
  - More accurate error reporting during profile parsing and validation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Lucas Manuel Rodriguez <lucas@fleetdm.com>
This commit is contained in:
Victor Lyuboslavsky
2025-08-22 09:37:12 -05:00
committed by GitHub
co-authored by Lucas Manuel Rodriguez
parent 53b7a0628a
commit 1d7aab04ab
12 changed files with 702 additions and 33 deletions
+2
View File
@@ -0,0 +1,2 @@
- Fixed `fleetctl gitops` issue uploading an Apple configuration profile with a FLEET_SECRET in a `<data>` field.
- Added a check to disallow FLEET_SECRET variables in Apple configuration profile `<PayloadDisplayName>` fields for security.
@@ -6,9 +6,9 @@
<array>
<dict>
<key>PayloadDescription</key>
<string>Configures Passcode settings</string>
<string>Configures Passcode settings - $FLEET_SECRET_NAME</string>
<key>PayloadDisplayName</key>
<string>$FLEET_SECRET_NAME</string>
<string>Passcode Policy</string>
<key>PayloadIdentifier</key>
<string>com.github.erikberglund.ProfileCreator.F7CF282E-D91B-44E9-922F-A719634F9C8E.com.apple.mobiledevice.passwordpolicy.231DFC90-D5A7-41B8-9246-564056048AC5</string>
<key>PayloadOrganization</key>
@@ -1270,3 +1270,240 @@ queries:
require.Equal(t, secretVariables[0].Name, secretName)
require.Equal(t, secretVariables[0].Value, secretValue)
}
// TestEnvSubstitutionInProfiles tests that only FLEET_SECRET_ prefixed env vars are saved as secrets
func (s *enterpriseIntegrationGitopsTestSuite) TestEnvSubstitutionInProfiles() {
t := s.T()
ctx := t.Context()
tempDir := t.TempDir()
// Create a test configuration profile with both valid and invalid secret references
profileContent := `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>Test Profile</string>
<key>PayloadIdentifier</key>
<string>com.fleet.test.env</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>12345678-1234-1234-1234-123456789012</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>TestSecretValue</key>
<string>$FLEET_SECRET_TEST_SECRET</string>
<key>TestInvalidSecret</key>
<string>$FLEET_DUO_CERTIFICATE_SECRET</string>
<key>TestPlainValue</key>
<string>$HOME</string>
</dict>
</array>
<key>PayloadDisplayName</key>
<string>Test Profile</string>
<key>PayloadIdentifier</key>
<string>com.fleet.test.env</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>12345678-1234-1234-1234-123456789012</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`
// Write the profile to a file
profilePath := filepath.Join(tempDir, "test-profile.mobileconfig")
err := os.WriteFile(profilePath, []byte(profileContent), 0644) //nolint:gosec // test code
require.NoError(t, err)
// Create a GitOps config file that references the profile
// Note: Environment variables in the YAML config itself get expanded,
// but not in the referenced profile files
gitopsConfig := fmt.Sprintf(`
org_settings:
server_settings:
server_url: %s
secrets:
- secret: test_secret
agent_options:
config:
decorators:
load:
- SELECT uuid AS host_uuid FROM system_info;
controls:
macos_settings:
custom_settings:
- path: %s
queries: []
policies: []
`, s.Server.URL, profilePath)
configPath := filepath.Join(tempDir, "gitops.yml")
err = os.WriteFile(configPath, []byte(gitopsConfig), 0644) //nolint:gosec // test code
require.NoError(t, err)
// Create a GitOps user
gitOpsUser := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, gitOpsUser)
// Set the environment variable for the valid secret
t.Setenv("FLEET_SECRET_TEST_SECRET", "super_secret_value_123")
t.Setenv("FLEET_DUO_CERTIFICATE_SECRET", "should_not_be_saved")
t.Setenv("HOME", "also_not_saved")
// Run GitOps dry-run - should fail without the required secret
// First, unset the environment variable to trigger the error
_ = os.Unsetenv("FLEET_SECRET_TEST_SECRET")
_, err = fleetctl.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", configPath, "--dry-run"})
require.ErrorContains(t, err, "FLEET_SECRET_TEST_SECRET")
// Set the env var again and run for real
t.Setenv("FLEET_SECRET_TEST_SECRET", "super_secret_value_123")
_ = fleetctl.RunAppForTest(t, []string{"gitops", "--config", fleetctlConfig.Name(), "-f", configPath})
// Verify that the secret was saved to the server
secrets, err := s.DS.GetSecretVariables(ctx, []string{"TEST_SECRET"})
require.NoError(t, err)
require.Len(t, secrets, 1)
assert.Equal(t, "TEST_SECRET", secrets[0].Name)
assert.Equal(t, "super_secret_value_123", secrets[0].Value)
// Verify that non-FLEET_SECRET_ variables were NOT saved
notSaved, err := s.DS.GetSecretVariables(ctx, []string{"DUO_CERTIFICATE_SECRET", "HOME"})
require.NoError(t, err)
assert.Empty(t, notSaved, "Non-FLEET_SECRET_ variables should not be saved")
// Verify that the profile content has the expected substitutions:
// - $FLEET_SECRET_* variables should remain as-is (substituted at delivery time)
// - Other env vars should be expanded during GitOps
profiles, err := s.DS.ListMDMAppleConfigProfiles(ctx, nil)
require.NoError(t, err)
foundProfile := false
for _, profile := range profiles {
t.Logf("Found profile: %s", profile.Name)
if strings.Contains(profile.Name, "test-profile") || strings.Contains(profile.Identifier, "com.fleet.test.env") {
foundProfile = true
// $FLEET_SECRET_* variables should NOT be expanded (they're expanded at delivery time)
assert.Contains(t, string(profile.Mobileconfig), "$FLEET_SECRET_TEST_SECRET")
// Non-FLEET_SECRET_/FLEET_VAR_ variables SHOULD be expanded during GitOps
assert.Contains(t, string(profile.Mobileconfig), "should_not_be_saved") // Value of $FLEET_DUO_CERTIFICATE_SECRET
assert.Contains(t, string(profile.Mobileconfig), "also_not_saved") // Value of $HOME
// The original variable names should NOT be present
assert.NotContains(t, string(profile.Mobileconfig), "$FLEET_DUO_CERTIFICATE_SECRET")
assert.NotContains(t, string(profile.Mobileconfig), "$HOME")
break
}
}
assert.True(t, foundProfile, "Profile should be uploaded to the server")
}
// TestFleetSecretInDataTag tests that FLEET_SECRET_ variables in <data> tags of Apple profiles
// are handled properly.
func (s *enterpriseIntegrationGitopsTestSuite) TestFleetSecretInDataTag() {
t := s.T()
tempDir := t.TempDir()
ctx := context.Background()
// Sample certificate in base64 format (this is a dummy test certificate)
testCertBase64 := `MIIDaTCCAlGgAwIBAgIUNQLezMUpmZK18DcLKt/XTRcLlK8wDQYJKoZIhvcNAQELBQAwRDEfMB0GA1UEAwwWRHVtbXkgVGVzdCBDZXJ0aWZpY2F0ZTEUMBIGA1UECgwLRXhhbXBsZSBPcmcxCzAJBgNVBAYTAlVTMB4XDTI1MDgxOTE3NDMwN1oXDTI2MDgxOTE3NDMwN1owRDEfMB0GA1UEAwwWRHVtbXkgVGVzdCBDZXJ0aWZpY2F0ZTEUMBIGA1UECgwLRXhhbXBsZSBPcmcxCzAJBgNVBAYTAlVTMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA07Np/w5WpFVLlMKX3dZSxwo+c2uwP2glTN0HA5c/6UOQRR9c91yoGGJsD4pfqhtIMSTFw7po3n/PjhGDe/WH+utK+ZIcD0nGD6SvmOggyoohHs81eIOjJAEJjxzhk7eLTVpUI2EnPe/24ei/dgkK59As9qQyH/y+CoR8JIYbNCJH5YLC2Pa44V84QWa2I5DHKUKrUXo9WsrRp1N1JjyaG/6hxLBJZ69e0QTrxxScboreRqVUR6oIEJRTchB+rDG5dxXzCQE6/F8N3qR76t23wd3CLmrcXoEc1P2P331Qzi0KXNXjdJFf0plmfRkT/IWgfM81Vfon1QwENwRSBNmPfQIDAQABo1MwUTAdBgNVHQ4EFgQU9q7SDfQRbJ31snRt2sZzx5sdEpYwHwYDVR0jBBgwFoAU9q7SDfQRbJ31snRt2sZzx5sdEpYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAYwH42JP45SnZejSF74OcYt8fp08jCWHOiFC3QEo3cROXVn6AWjEbzuQpOxRWF9EizNA83c4E6I+kQVztiuv9bUKGuLFeYb9lUZe8HOvH+j22MtGvZrDPygsJc8TavdkxAsu6OiNQZrYiCFzixkKS9b5p/1B93GBh62OFnV1nUBS8PzAZhOAyJ8UcEhr+GNzZG99/wOkcB0uwxmIb8x8sB3KnQ0qef/qnmgeWxlJlDc/SZ2/4PgtaluZ+noDfNPzaQn4eJNnBz0OTqZ9yuKALeE1WHk8U13zSdc1GNVLhXOrEHegPK5bBmA/lpIQ6HrkwUX7MJ3vK0AD3LjaTzXltDQ==`
// Create a test team first
team, err := s.DS.NewTeam(ctx, &fleet.Team{
Name: "Test Team for Secret in Data Tag",
})
require.NoError(t, err)
// Create a profile with $FLEET_SECRET_DUO_CERTIFICATE in a <data> tag
// This mimics the real-world scenario where the certificate should be base64 encoded
profileContent := `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.apple.security.root</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.test.cert</string>
<key>PayloadUUID</key>
<string>11111111-2222-3333-4444-555555555555</string>
<key>PayloadDisplayName</key>
<string>Test Root Certificate</string>
<key>PayloadContent</key>
<data>$FLEET_SECRET_DUO_CERTIFICATE</data>
</dict>
</array>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.test.profile</string>
<key>PayloadUUID</key>
<string>aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee</string>
<key>PayloadDisplayName</key>
<string>Test MDM Profile with Base64</string>
</dict>
</plist>`
// Write the profile to a file
profilePath := filepath.Join(tempDir, "rootcert-secret.mobileconfig")
err = os.WriteFile(profilePath, []byte(profileContent), 0644) //nolint:gosec
require.NoError(t, err)
// Create a team GitOps config file that references the profile
teamConfig := fmt.Sprintf(`
name: %s
team_settings:
secrets:
- secret: test_secret
agent_options:
config:
decorators:
load:
- SELECT uuid AS host_uuid FROM system_info;
controls:
macos_settings:
custom_settings:
- path: %s
queries:
policies:
software:
`, team.Name, profilePath)
configPath := filepath.Join(tempDir, "team-gitops.yml")
err = os.WriteFile(configPath, []byte(teamConfig), 0644) //nolint:gosec
require.NoError(t, err)
// Create a GitOps user
gitOpsUser := s.createGitOpsUser(t)
fleetctlConfig := s.createFleetctlConfig(t, gitOpsUser)
// Set the environment variable with the base64-encoded certificate
t.Setenv("FLEET_SECRET_DUO_CERTIFICATE", testCertBase64)
// The fix expands FLEET_SECRET_ variables for validation only, allowing the profile to be parsed
_, err = fleetctl.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", configPath, "--dry-run"})
require.NoError(t, err, "GitOps dry-run should succeed with the fix")
// Also test without dry-run to confirm it works
_, err = fleetctl.RunAppNoChecks([]string{"gitops", "--config", fleetctlConfig.Name(), "-f", configPath})
require.NoError(t, err, "GitOps should succeed with the fix")
// Verify that the profile stored on the server still has the unexpanded variable
profiles, err := s.DS.ListMDMAppleConfigProfiles(ctx, &team.ID)
require.NoError(t, err)
require.Len(t, profiles, 1)
// The stored profile should still contain the unexpanded variable, not the actual secret
require.Contains(t, string(profiles[0].Mobileconfig), "$FLEET_SECRET_DUO_CERTIFICATE",
"Profile should still contain unexpanded FLEET_SECRET variable")
}
+42 -7
View File
@@ -156,8 +156,20 @@ func generateRandomString(sizeBytes int) string {
return hex.EncodeToString(b)
}
// secretHandling defines how to handle FLEET_SECRET_ variables
type secretHandling int
const (
// secretsReject returns an error if FLEET_SECRET_ variables are found
secretsReject secretHandling = iota
// secretsIgnore leaves FLEET_SECRET_ variables as-is (for server to handle)
secretsIgnore
// secretsExpand expands FLEET_SECRET_ variables (for client-side validation only)
secretsExpand
)
func ExpandEnv(s string) (string, error) {
out, err := expandEnv(s, true)
out, err := expandEnv(s, secretsReject)
return out, err
}
@@ -165,9 +177,8 @@ func ExpandEnv(s string) (string, error) {
// $ can be escaped with a backslash, e.g. \$VAR
// \$ can be escaped with another backslash, etc., e.g. \\\$VAR
// $FLEET_VAR_XXX will not be expanded. These variables are expanded on the server.
// If secretsMap is not nil, $FLEET_SECRET_XXX will be evaluated and put in the map
// If secretsMap is nil, $FLEET_SECRET_XXX will cause an error.
func expandEnv(s string, failOnSecret bool) (string, error) {
// The secretMode parameter controls how $FLEET_SECRET_XXX variables are handled.
func expandEnv(s string, secretMode secretHandling) (string, error) {
// Generate a random escaping prefix that doesn't exist in s.
var preventEscapingPrefix string
for {
@@ -190,11 +201,23 @@ func expandEnv(s string, failOnSecret bool) (string, error) {
// Don't expand fleet vars -- they will be expanded on the server
return "", false
case strings.HasPrefix(env, fleet.ServerSecretPrefix):
if failOnSecret {
switch secretMode {
case secretsExpand:
// Expand secrets for client-side validation
v, ok := os.LookupEnv(env)
if ok {
return v, true
}
// If secret not found, leave as-is for server to handle
return "", false
case secretsReject:
err = multierror.Append(err, fmt.Errorf("environment variables with %q prefix are only allowed in profiles and scripts: %q",
fleet.ServerSecretPrefix, env))
return "", false
default:
// Leave as-is for server to handle
return "", false
}
return "", false
}
// Don't expand fleet vars if they are inside an 'exclusion' zone,
@@ -227,7 +250,19 @@ func ExpandEnvBytes(b []byte) ([]byte, error) {
}
func ExpandEnvBytesIgnoreSecrets(b []byte) ([]byte, error) {
s, err := expandEnv(string(b), false)
s, err := expandEnv(string(b), secretsIgnore)
if err != nil {
return nil, err
}
return []byte(s), nil
}
// ExpandEnvBytesIncludingSecrets expands environment variables including FLEET_SECRET_ variables.
// This should only be used for client-side validation where the actual secrets are needed temporarily.
// The expanded secrets are never sent to the server.
// Missing FLEET_SECRET_ variables do not fail the method; they are just not expanded.
func ExpandEnvBytesIncludingSecrets(b []byte) ([]byte, error) {
s, err := expandEnv(string(b), secretsExpand)
if err != nil {
return nil, err
}
+30
View File
@@ -224,6 +224,36 @@ func TestLookupEnvSecrets(t *testing.T) {
}
}
// TestExpandEnvBytesIncludingSecrets tests that FLEET_SECRET_ variables are expanded when using ExpandEnvBytesIncludingSecrets
func TestExpandEnvBytesIncludingSecrets(t *testing.T) {
t.Setenv("FLEET_SECRET_API_KEY", "secret123")
t.Setenv("NORMAL_VAR", "normalvalue")
t.Setenv("FLEET_VAR_HOST", "hostname")
input := []byte(`API Key: $FLEET_SECRET_API_KEY
Normal: $NORMAL_VAR
Fleet Var: $FLEET_VAR_HOST
Missing: $FLEET_SECRET_MISSING`)
result, err := ExpandEnvBytesIncludingSecrets(input)
require.NoError(t, err)
expected := `API Key: secret123
Normal: normalvalue
Fleet Var: $FLEET_VAR_HOST
Missing: $FLEET_SECRET_MISSING`
assert.Equal(t, expected, string(result))
// Verify that FLEET_VAR_ is not expanded (reserved for server)
assert.Contains(t, string(result), "$FLEET_VAR_HOST")
// Verify that FLEET_SECRET_ is expanded
assert.Contains(t, string(result), "secret123")
assert.NotContains(t, string(result), "$FLEET_SECRET_API_KEY")
// Verify that missing secrets are left as-is
assert.Contains(t, string(result), "$FLEET_SECRET_MISSING")
}
func TestGetExclusionZones(t *testing.T) {
testCases := []struct {
fixturePath []string
+20
View File
@@ -5,9 +5,11 @@ import (
"crypto/md5" // nolint: gosec
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
@@ -258,6 +260,24 @@ func NewMDMAppleConfigProfile(raw []byte, teamID *uint) (*MDMAppleConfigProfile,
}, nil
}
// payloadDisplayNameRegex is used to extract PayloadDisplayName values from raw XML content
var payloadDisplayNameRegex = regexp.MustCompile(`<key>PayloadDisplayName</key>\s*<string>([^<]*)</string>`)
// ValidateNoSecretsInProfileName checks if PayloadDisplayName contains FLEET_SECRET_ variables
// in the raw XML content of a profile.
func ValidateNoSecretsInProfileName(xmlContent []byte) error {
matches := payloadDisplayNameRegex.FindAllSubmatch(xmlContent, -1)
for _, match := range matches {
if len(match) > 1 {
displayName := string(match[1])
if len(ContainsPrefixVars(displayName, ServerSecretPrefix)) > 0 {
return errors.New("PayloadDisplayName cannot contain FLEET_SECRET variables")
}
}
}
return nil
}
func (cp MDMAppleConfigProfile) ValidateUserProvided() error {
// first screen the top-level object for reserved identifiers and names
if _, ok := mobileconfig.FleetPayloadIdentifiers()[cp.Identifier]; ok {
+97
View File
@@ -654,3 +654,100 @@ func TestConfigurationProfileLabelEqual(t *testing.T) {
"Does cmp.Equal for ConfigurationProfileLabel needs to be updated for new/updated field(s)?")
assert.True(t, cmp.Equal(items[0], items[1]))
}
func TestValidateNoSecretsInProfileName(t *testing.T) {
testCases := []struct {
name string
xmlContent string
expectErr bool
errMsg string
}{
{
name: "no secrets",
xmlContent: `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadDisplayName</key>
<string>Test Profile</string>
<key>PayloadIdentifier</key>
<string>com.test.profile</string>
</dict>
</plist>`,
expectErr: false,
},
{
name: "secret in PayloadDisplayName",
xmlContent: `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadDisplayName</key>
<string>Test $FLEET_SECRET_PASSWORD Profile</string>
<key>PayloadIdentifier</key>
<string>com.test.profile</string>
</dict>
</plist>`,
expectErr: true,
errMsg: "PayloadDisplayName cannot contain FLEET_SECRET variables",
},
{
name: "multiple PayloadDisplayNames with secret in one",
xmlContent: `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadDisplayName</key>
<string>Main Profile</string>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>Sub Profile $FLEET_SECRET_KEY</string>
</dict>
</array>
</dict>
</plist>`,
expectErr: true,
errMsg: "PayloadDisplayName cannot contain FLEET_SECRET variables",
},
{
name: "secret in other field not PayloadDisplayName",
xmlContent: `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadDisplayName</key>
<string>Test Profile</string>
<key>PayloadDescription</key>
<string>Description with $FLEET_SECRET_VALUE</string>
</dict>
</plist>`,
expectErr: false,
},
{
name: "whitespace in PayloadDisplayName value",
xmlContent: `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadDisplayName</key>
<string> Test Profile </string>
</dict>
</plist>`,
expectErr: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateNoSecretsInProfileName([]byte(tc.xmlContent))
if tc.expectErr {
require.Error(t, err)
require.Contains(t, err.Error(), tc.errMsg)
} else {
require.NoError(t, err)
}
})
}
}
+10
View File
@@ -390,6 +390,11 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r
})
}
// Check for secrets in profile name before expansion
if err := fleet.ValidateNoSecretsInProfileName(b); err != nil {
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile", err.Error()))
}
// Expand and validate secrets in profile
expanded, secretsUpdatedAt, err := svc.ds.ExpandEmbeddedSecretsAndUpdatedAt(ctx, string(b))
if err != nil {
@@ -2594,6 +2599,11 @@ func (svc *Service) BatchSetMDMAppleProfiles(ctx context.Context, tmID *uint, tm
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), "maximum configuration profile file size is 1 MB"),
)
}
// Check for secrets in profile name before expansion
if err := fleet.ValidateNoSecretsInProfileName(prof); err != nil {
return ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), err.Error()))
}
// Expand profile for validation
expanded, secretsUpdatedAt, err := svc.ds.ExpandEmbeddedSecretsAndUpdatedAt(ctx, string(prof))
if err != nil {
+23
View File
@@ -778,6 +778,12 @@ func TestNewMDMAppleConfigProfile(t *testing.T) {
r = bytes.NewReader(mcBytes)
_, err = svc.NewMDMAppleConfigProfile(ctx, 0, r, nil, fleet.LabelsIncludeAll)
assert.ErrorContains(t, err, "Fleet variable")
// Test profile with FLEET_SECRET in PayloadDisplayName
mcBytes = mcBytesForTest("Profile $FLEET_SECRET_PASSWORD", "test.identifier", "UUID")
r = bytes.NewReader(mcBytes)
_, err = svc.NewMDMAppleConfigProfile(ctx, 0, r, nil, fleet.LabelsIncludeAll)
assert.ErrorContains(t, err, "PayloadDisplayName cannot contain FLEET_SECRET variables")
}
func mcBytesForTest(name, identifier, uuid string) []byte {
@@ -802,6 +808,23 @@ func mcBytesForTest(name, identifier, uuid string) []byte {
`, name, identifier, uuid))
}
func TestBatchSetMDMAppleProfilesWithSecrets(t *testing.T) {
svc, ctx, _ := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
// Test profile with FLEET_SECRET in PayloadDisplayName
profileWithSecret := mcBytesForTest("Profile $FLEET_SECRET_PASSWORD", "test.identifier", "UUID")
err := svc.BatchSetMDMAppleProfiles(ctx, nil, nil, [][]byte{profileWithSecret}, false, false)
assert.ErrorContains(t, err, "PayloadDisplayName cannot contain FLEET_SECRET variables")
// Test multiple profiles where one has a secret in PayloadDisplayName
goodProfile := mcBytesForTest("Good Profile", "good.identifier", "UUID1")
badProfile := mcBytesForTest("Bad $FLEET_SECRET_KEY Profile", "bad.identifier", "UUID2")
err = svc.BatchSetMDMAppleProfiles(ctx, nil, nil, [][]byte{goodProfile, badProfile}, false, false)
assert.ErrorContains(t, err, "PayloadDisplayName cannot contain FLEET_SECRET variables")
assert.ErrorContains(t, err, "profiles[1]")
}
func TestNewMDMAppleDeclaration(t *testing.T) {
svc, ctx, ds := setupAppleMDMService(t, &fleet.LicenseInfo{Tier: fleet.TierPremium})
ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
+38 -7
View File
@@ -363,15 +363,46 @@ func getProfilesContents(baseDir string, macProfiles []fleet.MDMProfileSpec, win
if platform == "macos" {
switch ext {
case ".mobileconfig", ".xml": // allowing .xml for backwards compatibility
mc, err := fleet.NewMDMAppleConfigProfile(fileContents, nil)
if err != nil {
errForMsg := errors.Unwrap(err)
if errForMsg == nil {
errForMsg = err
// For validation, we need to expand FLEET_SECRET_ variables in <data> tags so the XML parser
// can properly validate the profile structure. However, we must be careful not to expose
// secrets in the profile name.
containsSecrets := len(fleet.ContainsPrefixVars(string(fileContents), fleet.ServerSecretPrefix)) > 0
if containsSecrets {
// If profile contains secrets, check for secrets in PayloadDisplayName first
if err := fleet.ValidateNoSecretsInProfileName(fileContents); err != nil {
return nil, fmt.Errorf("%s: %w", prefixErrMsg, err)
}
return nil, fmt.Errorf("%s: %w", prefixErrMsg, errForMsg)
// Expand secrets for validation
validationContents, expandErr := spec.ExpandEnvBytesIncludingSecrets(fileContents)
if expandErr != nil {
return nil, fmt.Errorf("%s: expanding secrets for validation: %w", prefixErrMsg, expandErr)
}
// Validate the profile structure with expanded secrets
mcExpanded, validationErr := fleet.NewMDMAppleConfigProfile(validationContents, nil)
if validationErr != nil {
errForMsg := errors.Unwrap(validationErr)
if errForMsg == nil {
errForMsg = validationErr
}
return nil, fmt.Errorf("%s: %w", prefixErrMsg, errForMsg)
}
name = strings.TrimSpace(mcExpanded.Name)
} else {
// No secrets, parse normally
mc, err := fleet.NewMDMAppleConfigProfile(fileContents, nil)
if err != nil {
errForMsg := errors.Unwrap(err)
if errForMsg == nil {
errForMsg = err
}
return nil, fmt.Errorf("%s: %w", prefixErrMsg, errForMsg)
}
name = strings.TrimSpace(mc.Name)
}
name = strings.TrimSpace(mc.Name)
case ".json":
if mdm.GetRawProfilePlatform(fileContents) != "darwin" {
return nil, fmt.Errorf("%s: %s", prefixErrMsg, "Declaration profiles should include valid JSON.")
+159
View File
@@ -720,6 +720,165 @@ func TestGetProfilesContents(t *testing.T) {
expectError: true,
wantErr: "Couldn't edit macos_settings.custom_settings (bar.cfg): macOS configuration profiles must be .mobileconfig or .json files",
},
{
name: "with FLEET_SECRET in data tag",
baseDir: tempDir,
macSetupFiles: [][2]string{
{"cert.mobileconfig", `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.apple.security.root</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.cert</string>
<key>PayloadUUID</key>
<string>11111111-2222-3333-4444-555555555555</string>
<key>PayloadDisplayName</key>
<string>Test Certificate</string>
<key>PayloadContent</key>
<data>$FLEET_SECRET_CERT_DATA</data>
</dict>
</array>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.profile</string>
<key>PayloadUUID</key>
<string>aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee</string>
<key>PayloadDisplayName</key>
<string>Certificate Profile</string>
</dict>
</plist>`},
},
environment: map[string]string{
"FLEET_SECRET_CERT_DATA": "VGVzdENlcnREYXRhQmFzZTY0", // "TestCertDataBase64" in base64
},
expandEnv: true,
expectError: false,
want: []fleet.MDMProfileBatchPayload{
{
Name: "Certificate Profile",
Contents: []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>com.apple.security.root</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.cert</string>
<key>PayloadUUID</key>
<string>11111111-2222-3333-4444-555555555555</string>
<key>PayloadDisplayName</key>
<string>Test Certificate</string>
<key>PayloadContent</key>
<data>$FLEET_SECRET_CERT_DATA</data>
</dict>
</array>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.profile</string>
<key>PayloadUUID</key>
<string>aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee</string>
<key>PayloadDisplayName</key>
<string>Certificate Profile</string>
</dict>
</plist>`),
},
},
},
{
name: "with FLEET_SECRET in PayloadDisplayName - should reject",
baseDir: tempDir,
macSetupFiles: [][2]string{
{"secret_name.mobileconfig", `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.profile</string>
<key>PayloadUUID</key>
<string>aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee</string>
<key>PayloadDisplayName</key>
<string>Profile $FLEET_SECRET_NAME</string>
</dict>
</plist>`},
},
environment: map[string]string{
"FLEET_SECRET_NAME": "SecretProfileName",
},
expandEnv: true,
expectError: true,
wantErr: "PayloadDisplayName cannot contain FLEET_SECRET variables",
},
{
name: "with FLEET_VAR in profile - should not expand",
baseDir: tempDir,
macSetupFiles: [][2]string{
{"fleet_var.mobileconfig", `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.profile</string>
<key>PayloadUUID</key>
<string>aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee</string>
<key>PayloadDisplayName</key>
<string>Profile with FLEET_VAR</string>
<key>SomeValue</key>
<string>$FLEET_VAR_HOST_END_USER_IDP_USERNAME</string>
</dict>
</plist>`},
},
expandEnv: true,
expectError: false,
want: []fleet.MDMProfileBatchPayload{
{
Name: "Profile with FLEET_VAR",
Contents: []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>com.example.profile</string>
<key>PayloadUUID</key>
<string>aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee</string>
<key>PayloadDisplayName</key>
<string>Profile with FLEET_VAR</string>
<key>SomeValue</key>
<string>$FLEET_VAR_HOST_END_USER_IDP_USERNAME</string>
</dict>
</plist>`),
},
},
},
}
for _, tt := range tests {
+42 -17
View File
@@ -93,9 +93,9 @@ func (s *integrationMDMTestSuite) TestAppleProfileManagement() {
<key>PayloadContent</key>
<array/>
<key>PayloadDisplayName</key>
<string>$FLEET_SECRET_INVALID</string>
<string>My profile</string>
<key>PayloadIdentifier</key>
<string>N3</string>
<string>$FLEET_SECRET_INVALID</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
@@ -110,6 +110,31 @@ func (s *integrationMDMTestSuite) TestAppleProfileManagement() {
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "$FLEET_SECRET_INVALID")
invalidSecretsProfile = []byte(`
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array/>
<key>PayloadDisplayName</key>
<string>$FLEET_SECRET_INVALID</string>
<key>PayloadIdentifier</key>
<string>N3</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>601E0B42-0989-4FAD-A61B-18656BA3670E</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>
`)
res = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{invalidSecretsProfile}}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "PayloadDisplayName cannot contain FLEET_SECRET variables")
// create a new team
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
require.NoError(t, err)
@@ -204,7 +229,6 @@ func (s *integrationMDMTestSuite) TestAppleProfileManagement() {
// Use secret variables in a profile
secretIdentifier := "secret-identifier-1"
secretType := "secret.type.1"
secretName := "secretName"
secretProfile := string(mobileconfigForTest("NS1", "IS1"))
req := createSecretVariablesRequest{
SecretVariables: []fleet.SecretVariable{
@@ -216,10 +240,6 @@ func (s *integrationMDMTestSuite) TestAppleProfileManagement() {
Name: "FLEET_SECRET_TYPE",
Value: secretType,
},
{
Name: "FLEET_SECRET_NAME",
Value: secretName,
},
{
Name: "FLEET_SECRET_PROFILE",
Value: secretProfile,
@@ -233,7 +253,7 @@ func (s *integrationMDMTestSuite) TestAppleProfileManagement() {
teamProfiles = [][]byte{
mobileconfigForTest("N4", "I4"),
mobileconfigForTestWithContent("N5", "I5", "$FLEET_SECRET_IDENTIFIER", "${FLEET_SECRET_TYPE}",
"$FLEET_SECRET_NAME"),
"InnerName5"),
// The whole profile is one big secret.
[]byte("$FLEET_SECRET_PROFILE"),
}
@@ -253,7 +273,6 @@ func (s *integrationMDMTestSuite) TestAppleProfileManagement() {
// Manually replace the expected secret variables in the profile
wantTeamProfiles[1] = []byte(strings.ReplaceAll(string(wantTeamProfiles[1]), "$FLEET_SECRET_IDENTIFIER", secretIdentifier))
wantTeamProfiles[1] = []byte(strings.ReplaceAll(string(wantTeamProfiles[1]), "${FLEET_SECRET_TYPE}", secretType))
wantTeamProfiles[1] = []byte(strings.ReplaceAll(string(wantTeamProfiles[1]), "$FLEET_SECRET_NAME", secretName))
wantTeamProfiles[2] = []byte(secretProfile)
// verify that we should install the team profiles
s.signedProfilesMatch(wantTeamProfiles, installs)
@@ -272,12 +291,16 @@ func (s *integrationMDMTestSuite) TestAppleProfileManagement() {
require.Empty(t, removes)
// Change the secret variable and upload the profiles again. We should see the profile with updated secret installed.
secretName = "newSecretName"
secretType = "new.secret.type.1"
req = createSecretVariablesRequest{
SecretVariables: []fleet.SecretVariable{
{
Name: "FLEET_SECRET_NAME",
Value: secretName, // changed
Name: "FLEET_SECRET_IDENTIFIER",
Value: secretIdentifier, // did not change
},
{
Name: "FLEET_SECRET_TYPE",
Value: secretType, // changed
},
{
Name: "FLEET_SECRET_PROFILE",
@@ -297,7 +320,6 @@ func (s *integrationMDMTestSuite) TestAppleProfileManagement() {
wantTeamProfilesChanged[0] = []byte(strings.ReplaceAll(string(wantTeamProfilesChanged[0]), "$FLEET_SECRET_IDENTIFIER",
secretIdentifier))
wantTeamProfilesChanged[0] = []byte(strings.ReplaceAll(string(wantTeamProfilesChanged[0]), "${FLEET_SECRET_TYPE}", secretType))
wantTeamProfilesChanged[0] = []byte(strings.ReplaceAll(string(wantTeamProfilesChanged[0]), "$FLEET_SECRET_NAME", secretName))
// verify that we should install the team profiles
s.signedProfilesMatch(wantTeamProfilesChanged, installs)
wantTeamProfiles[1] = wantTeamProfilesChanged[0]
@@ -343,12 +365,16 @@ func (s *integrationMDMTestSuite) TestAppleProfileManagement() {
require.Empty(t, removes)
// Change the secret variable and upload the profiles again. We should see the profile with updated secret installed.
secretName = "new2SecretName"
secretType = "new2.secret.type.1"
req = createSecretVariablesRequest{
SecretVariables: []fleet.SecretVariable{
{
Name: "FLEET_SECRET_NAME",
Value: secretName, // changed
Name: "FLEET_SECRET_IDENTIFIER",
Value: secretIdentifier, // did not change
},
{
Name: "FLEET_SECRET_TYPE",
Value: secretType, // changed
},
{
Name: "FLEET_SECRET_PROFILE",
@@ -368,7 +394,6 @@ func (s *integrationMDMTestSuite) TestAppleProfileManagement() {
wantTeamProfilesChanged[0] = []byte(strings.ReplaceAll(string(wantTeamProfilesChanged[0]), "$FLEET_SECRET_IDENTIFIER",
secretIdentifier))
wantTeamProfilesChanged[0] = []byte(strings.ReplaceAll(string(wantTeamProfilesChanged[0]), "${FLEET_SECRET_TYPE}", secretType))
wantTeamProfilesChanged[0] = []byte(strings.ReplaceAll(string(wantTeamProfilesChanged[0]), "$FLEET_SECRET_NAME", secretName))
// verify that we should install the team profiles
s.signedProfilesMatch(wantTeamProfilesChanged, installs)
wantTeamProfiles[1] = wantTeamProfilesChanged[0]