Part of #38790 (iOS / iPadOS managed app configuration). Closes #43963. Adds `ValidateAppleAppConfiguration` and the `FleetVarsSupportedInAppleAppConfig` allow-list in `server/fleet/vpp.go`. Walks the decoded plist (keys + string values) so XML-entity-encoded `$FLEET_VAR_*` tokens can't slip past the disallow check, and rejects non-XML plist formats (binary, OpenStep, GNUStep) since Apple's `InstallApplication` only accepts XML. Stacked PRs (review bottom up): - #43963 validator (this PR) - 43964 datastore - 43965 service wiring - 43969 gitops - 43966 InstallApplication Configuration dict injection - 43967 Fleet variable expansion - 43968 send-paths audit <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added validation for Apple managed app configurations to allow only supported Fleet variable placeholders, reject malformed plist formats, and accept empty payloads. * **Bug Fixes** * Improved handling of app configuration payloads to ensure consistent validation and error responses across Android and iOS flows. * **Tests** * Added comprehensive tests covering plist validation, allowed/disallowed variables, and edge cases to increase reliability. [](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/44930) <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: jkatz01 <yehonatankatz@gmail.com>
173 lines
5.4 KiB
Go
173 lines
5.4 KiB
Go
package fleet
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestValidateAppleAppConfiguration(t *testing.T) {
|
|
const fragment = `<dict>
|
|
<key>ServerURL</key>
|
|
<string>https://example.com</string>
|
|
<key>EnableTelemetry</key>
|
|
<true/>
|
|
</dict>`
|
|
|
|
const fullDoc = `<?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>ServerURL</key>
|
|
<string>https://example.com</string>
|
|
</dict>
|
|
</plist>`
|
|
|
|
const nested = `<dict>
|
|
<key>Outer</key>
|
|
<dict>
|
|
<key>Inner</key>
|
|
<string>value</string>
|
|
</dict>
|
|
<key>List</key>
|
|
<array>
|
|
<dict>
|
|
<key>K</key>
|
|
<string>v</string>
|
|
</dict>
|
|
</array>
|
|
</dict>`
|
|
|
|
cases := []struct {
|
|
name string
|
|
input string
|
|
wantErr bool
|
|
errSub string
|
|
}{
|
|
{name: "empty", input: ""},
|
|
{name: "bare dict fragment", input: fragment},
|
|
{name: "full plist document", input: fullDoc},
|
|
{name: "nested dict and array of dicts", input: nested},
|
|
{name: "garbage non-XML", input: "not a plist", wantErr: true, errSub: "invalid plist"},
|
|
{name: "malformed XML unclosed tag", input: "<dict><key>foo</key><string>bar", wantErr: true, errSub: "invalid plist"},
|
|
{name: "root is array", input: `<array><string>x</string></array>`, wantErr: true, errSub: "invalid plist"},
|
|
{name: "root is string", input: `<string>oops</string>`, wantErr: true, errSub: "invalid plist"},
|
|
{
|
|
name: "allowed variable",
|
|
input: `<dict><key>HostID</key><string>$FLEET_VAR_HOST_UUID</string></dict>`,
|
|
},
|
|
{
|
|
name: "allowed variable with braces",
|
|
input: `<dict><key>HostID</key><string>${FLEET_VAR_HOST_UUID}</string></dict>`,
|
|
},
|
|
{
|
|
name: "multiple allowed variables in one string",
|
|
input: `<dict><key>K</key><string>https://x/$FLEET_VAR_HOST_UUID/$FLEET_VAR_HOST_HARDWARE_SERIAL</string></dict>`,
|
|
},
|
|
{
|
|
name: "credential variable not allowed in app config",
|
|
input: `<dict><key>K</key><string>$FLEET_VAR_NDES_SCEP_CHALLENGE</string></dict>`,
|
|
wantErr: true,
|
|
errSub: "unsupported variable $FLEET_VAR_NDES_SCEP_CHALLENGE",
|
|
},
|
|
{
|
|
name: "unknown variable name",
|
|
input: `<dict><key>K</key><string>$FLEET_VAR_BOGUS_NAME</string></dict>`,
|
|
wantErr: true,
|
|
errSub: "unsupported variable $FLEET_VAR_BOGUS_NAME",
|
|
},
|
|
{
|
|
name: "all standard plist value types accepted",
|
|
input: `<dict>
|
|
<key>S</key><string>val</string>
|
|
<key>I</key><integer>42</integer>
|
|
<key>R</key><real>3.14</real>
|
|
<key>T</key><true/>
|
|
<key>F</key><false/>
|
|
<key>D</key><data>YWJj</data>
|
|
<key>A</key><array><string>x</string><integer>1</integer></array>
|
|
</dict>`,
|
|
},
|
|
{
|
|
name: "ASCII control character in string value",
|
|
input: "<dict><key>K</key><string>x\x01y</string></dict>",
|
|
wantErr: true,
|
|
errSub: "invalid plist",
|
|
},
|
|
{
|
|
name: "json null token",
|
|
input: "null",
|
|
wantErr: true,
|
|
errSub: "invalid plist",
|
|
},
|
|
{
|
|
name: "hex-entity-encoded $ does not bypass disallow list",
|
|
input: `<dict><key>K</key><string>$FLEET_VAR_NDES_SCEP_CHALLENGE</string></dict>`,
|
|
wantErr: true,
|
|
errSub: "unsupported variable $FLEET_VAR_NDES_SCEP_CHALLENGE",
|
|
},
|
|
{
|
|
name: "decimal-entity-encoded $ does not bypass disallow list",
|
|
input: `<dict><key>K</key><string>$FLEET_VAR_NDES_SCEP_CHALLENGE</string></dict>`,
|
|
wantErr: true,
|
|
errSub: "unsupported variable $FLEET_VAR_NDES_SCEP_CHALLENGE",
|
|
},
|
|
{
|
|
name: "disallowed variable inside a CDATA section is caught",
|
|
input: `<dict><key>K</key><string><![CDATA[$FLEET_VAR_NDES_SCEP_CHALLENGE]]></string></dict>`,
|
|
wantErr: true,
|
|
errSub: "unsupported variable $FLEET_VAR_NDES_SCEP_CHALLENGE",
|
|
},
|
|
{
|
|
name: "disallowed variable nested inside an array is caught",
|
|
input: `<dict><key>K</key><array><dict><key>Inner</key><string>$FLEET_VAR_BOGUS</string></dict></array></dict>`,
|
|
wantErr: true,
|
|
errSub: "unsupported variable $FLEET_VAR_BOGUS",
|
|
},
|
|
{
|
|
name: "disallowed variable used as a key is caught",
|
|
input: `<dict><key>$FLEET_VAR_BOGUS</key><string>x</string></dict>`,
|
|
wantErr: true,
|
|
errSub: "unsupported variable $FLEET_VAR_BOGUS",
|
|
},
|
|
{
|
|
name: "duplicate key bypass: disallowed var hidden by last-wins map semantics",
|
|
input: `<dict><key>K</key><string>$FLEET_VAR_NDES_SCEP_CHALLENGE</string><key>K</key><string>safe_value</string></dict>`,
|
|
wantErr: true,
|
|
errSub: "unsupported variable $FLEET_VAR_NDES_SCEP_CHALLENGE",
|
|
},
|
|
{
|
|
name: "trailing sibling bypass: disallowed var in element dropped by parser",
|
|
input: `<dict><key>K</key><string>safe</string></dict><dict><key>X</key><string>$FLEET_VAR_NDES_SCEP_CHALLENGE</string></dict>`,
|
|
wantErr: true,
|
|
errSub: "unsupported variable $FLEET_VAR_NDES_SCEP_CHALLENGE",
|
|
},
|
|
{
|
|
name: "openstep dict format rejected",
|
|
input: `{ServerURL = "https://x.com";}`,
|
|
wantErr: true,
|
|
errSub: "must be an XML plist",
|
|
},
|
|
{
|
|
name: "binary plist rejected",
|
|
input: "bplist00\xd1\x01\x02Q1Q2\x08\x0b\r\x00\x00\x00\x00\x00\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0f",
|
|
wantErr: true,
|
|
errSub: "must be an XML plist",
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
err := ValidateAppleAppConfiguration([]byte(c.input))
|
|
if c.wantErr {
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), c.errSub)
|
|
var iae *InvalidArgumentError
|
|
require.ErrorAs(t, err, &iae)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
}
|