diff --git a/client/mdm/policy.go b/client/mdm/policy.go index 1e7768866..a799d1a17 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -11,6 +11,7 @@ package mdm import ( "sort" "strconv" + "strings" log "github.com/sirupsen/logrus" ) @@ -75,6 +76,19 @@ var SecretKeys = map[string]struct{}{ KeyPreSharedKey: {}, } +// canonicalKey maps the lowercase form of a managed-config value name to its +// canonical mdm.Key* form. Admins commonly write PascalCase value names in +// ADMX / Group Policy ("ManagementURL"), the iOS/AppConfig and macOS plist +// conventions are camelCase ("managementURL"); both must resolve to the +// same Policy lookup. Shared across all platform loaders. +var canonicalKey = func() map[string]string { + m := make(map[string]string, len(AllKeys)) + for _, k := range AllKeys { + m[strings.ToLower(k)] = k + } + return m +}() + // Policy holds MDM-managed settings read from the platform source. A nil or // empty Policy means no enforcement is active. type Policy struct { diff --git a/client/mdm/policy_darwin.go b/client/mdm/policy_darwin.go index a0281ac0a..0ec0180c3 100644 --- a/client/mdm/policy_darwin.go +++ b/client/mdm/policy_darwin.go @@ -2,8 +2,87 @@ package mdm -// loadPlatformPolicy reads the MDM configuration from the macOS managed -// preferences plist. Phase 1 ships a stub; the real reader lands in Phase 2. +import ( + "errors" + "fmt" + "io/fs" + "os" + "strings" + + log "github.com/sirupsen/logrus" + "howett.net/plist" +) + +// policyPlistPath is the well-known location where macOS writes the +// device-level mandatory MDM payload for NetBird. The path is fixed by +// Apple convention: when an MDM provider (Jamf / Kandji / Mosyle / +// Intune for Mac / Workspace ONE) pushes a Configuration Profile that +// contains a com.apple.ManagedClient.preferences payload targeting the +// bundle id io.netbird.client, the OS materializes the payload here. +// +// Read-only — only the OS (root) is supposed to write this file. The +// loader sanity-checks the file mode and refuses to honour a world- +// writable plist, as a defense against tampered installs. +const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist" + +// loadPlatformPolicy reads the MDM-managed configuration from the macOS +// managed-preferences plist. Returns: +// - (nil, nil) when the plist is absent (device not MDM-enrolled for +// NetBird, or admin has not yet pushed a payload) +// - (map, nil) with N entries when N managed values are present +// (N may be 0 — empty plist still signals enrollment to the caller) +// - (nil, err) on permission / parse / safety errors +// +// Value-type coercion mirrors the Windows loader: native plist types +// map naturally onto the Policy accessor expectations (GetString / +// GetBool / GetInt / GetStringSlice). Unknown top-level keys are +// logged and skipped so a stray entry in the payload does not block +// startup. func loadPlatformPolicy() (map[string]any, error) { - return nil, nil + f, err := os.Open(policyPlistPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + // Not enrolled for NetBird. Caller treats nil as + // "no MDM source present". + return nil, nil + } + return nil, fmt.Errorf("open %s: %w", policyPlistPath, err) + } + defer func() { + if closeErr := f.Close(); closeErr != nil { + log.Warnf("MDM close plist %s: %v", policyPlistPath, closeErr) + } + }() + + info, err := f.Stat() + if err != nil { + return nil, fmt.Errorf("stat %s: %w", policyPlistPath, err) + } + // World-writable plist => tampered install. Refuse rather than + // honour potentially attacker-controlled policy values. + if info.Mode().Perm()&0o002 != 0 { + return nil, fmt.Errorf("refusing to read world-writable MDM source %s (mode %o)", + policyPlistPath, info.Mode().Perm()) + } + + raw := make(map[string]any) + if err := plist.NewDecoder(f).Decode(&raw); err != nil { + return nil, fmt.Errorf("decode plist %s: %w", policyPlistPath, err) + } + + out := make(map[string]any, len(raw)) + for name, val := range raw { + // macOS / AppConfig conventions both use camelCase for managed + // preferences keys; canonicalize to the mdm.Key* form so a key + // written as "ManagementURL" (PascalCase, rare on macOS but + // possible if the admin reused an ADMX-style name) still + // resolves. + canonical, known := canonicalKey[strings.ToLower(name)] + if !known { + log.Warnf("MDM ignoring unknown plist key %s: %s", policyPlistPath, name) + continue + } + out[canonical] = val + } + return out, nil } diff --git a/client/mdm/policy_windows.go b/client/mdm/policy_windows.go index 55a0e0c9e..6ef380e88 100644 --- a/client/mdm/policy_windows.go +++ b/client/mdm/policy_windows.go @@ -11,18 +11,6 @@ import ( "golang.org/x/sys/windows/registry" ) -// canonicalKey maps the lowercase form of a registry value name to its -// canonical mdm.Key* name. Admins commonly write PascalCase value names in -// ADMX / Group Policy ("ManagementURL"), the iOS/AppConfig convention is -// camelCase ("managementURL"); both must resolve to the same Policy lookup. -var canonicalKey = func() map[string]string { - m := make(map[string]string, len(AllKeys)) - for _, k := range AllKeys { - m[strings.ToLower(k)] = k - } - return m -}() - // policyRegistryPath is the well-known MDM policy registry key for NetBird. // Admins push values here through Group Policy, Intune ADMX ingestion, an // Intune custom Registry CSP profile, or `reg add` during MSI deployment.