fleet/server/mdm/microsoft/wlanxml/wlanxml.go
Jordan Montgomery de7ce439ec
Update WLAN XML profile verification so they aren't resent (#28296)
Fixes https://github.com/fleetdm/fleet/issues/24394 by adding new
verification logic to detect and verify these profiles. We only verify a
subset of the properties because there are certain settings such as the
Authentication which Windows seems to upgrade in circumstances where it
can(e.g. WPA2 specified but interface + router supports WPA3 results in
WPA3 on the client and there are likely other similar scenarios). After
discussion with design team we've decided the limited verification is
better than what we had before and a good solution for now.

I know this is extremely heavy on comments but the behavior is strange
and non obvious.

Also see latest comment on the issue for some testing discussion:
https://github.com/fleetdm/fleet/issues/24394#issuecomment-2810261844

# Checklist for submitter

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

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [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/Committing-Changes.md#changes-files)
for more information.
- [x] Added/updated automated tests
- [x] A detailed QA plan exists on the associated ticket (if it isn't
there, work with the product group's QA engineer to add it)
- [x] Manual QA for all new/changed functionality
2025-04-18 08:45:18 -04:00

171 lines
5.7 KiB
Go

// Package wlanxml handles WLAN Profiles for Microsoft MDM server.
// See: https://learn.microsoft.com/en-us/windows/win32/nativewifi/wlan-profileschema-schema
// for samples: https://learn.microsoft.com/en-us/windows/win32/nativewifi/wireless-profile-samples
// finally for multi-SSID uses: https://learn.microsoft.com/en-us/windows-hardware/drivers/mobilebroadband/handling-large-numbers-of-ssids
package wlanxml
import (
"encoding/xml"
"fmt"
"slices"
"strings"
)
func IsWLANXML(text string) bool {
// We try to unmarshal the string to see if it looks like a valid WLAN XML profile
_, err := unmarshal(text)
return err == nil
}
func Equal(a, b string) (bool, error) {
aProfile, err := unmarshal(a)
if err != nil {
return false, fmt.Errorf("unmarshalling WLAN XML profile a: %w", err)
}
bProfile, err := unmarshal(b)
if err != nil {
return false, fmt.Errorf("unmarshalling WLAN XML profile b: %w", err)
}
return aProfile.Equal(bProfile), nil
}
func unmarshal(w string) (WlanXmlProfile, error) {
// This whole thing will be XML Encoded so step 1 is just to decode it
var unescaped string
err := xml.Unmarshal([]byte("<wlanxml>"+w+"</wlanxml>"), &unescaped)
if err != nil {
return WlanXmlProfile{}, fmt.Errorf("unmarshalling WLAN XML profile to string: %w", err)
}
var profile WlanXmlProfile
err = xml.Unmarshal([]byte(unescaped), &profile)
if err != nil {
return WlanXmlProfile{}, fmt.Errorf("unmarshalling WLAN XML profile: %w", err)
}
if profile.XMLName.Local != "WLANProfile" {
return WlanXmlProfile{}, fmt.Errorf("unmarshalling WLAN XML profile: expected <WLANProfile> tag, got <%s>", profile.XMLName.Local)
}
for i := 0; i < len(profile.SSIDConfig.SSID); i++ {
profile.SSIDConfig.SSID[i].normalize()
}
profile.SSIDConfig.SSIDPrefix.normalize()
return profile, nil
}
type WlanXmlProfile struct {
XMLName xml.Name `xml:"WLANProfile"`
Name string `xml:"name"`
SSIDConfig WlanXmlProfileSSIDConfig `xml:"SSIDConfig"`
}
type WlanXmlProfileSSIDConfig struct {
SSID []WlanXmlProfileSSID `xml:"SSID"`
SSIDPrefix WlanXmlProfileSSID `xml:"SSIDPrefix"`
NonBroadcast bool `xml:"nonBroadcast"`
}
type WlanXmlProfileSSID struct {
Hex string `xml:"hex,omitempty"`
Name string `xml:"name,omitempty"`
}
func (s *WlanXmlProfileSSID) normalize() {
// Microsoft's documentation says that the hex representation overrides the Name when both are
// present. In testing, if a profile is provided with only the Name and not the hex
// representation, Microsoft generates Hex and it is present in the profile returned. As such we
// will convert name to hex on the way in for use in comparisons
if s.Hex == "" && s.Name != "" {
s.Hex = fmt.Sprintf("%x", s.Name)
}
// Most of the profile settings are case sensitive however the hex representation of the SSID is not and
// in some cases Windows converts it to uppercase when writing a profile to the system which was provided
// with uppercase alpha characters.
s.Hex = strings.ToUpper(s.Hex)
}
func (s WlanXmlProfileSSID) Equal(b WlanXmlProfileSSID) bool {
return s.Hex == b.Hex
}
// We have seen cases where Windows will "upgrade" a profile based on what it sees when it actually
// connects to a network, for instance if a profile specifies WPA2 but the interface and network
// support WPA3 it will "upgrade" the profile to WPA3 and that is what gets returned when querying
// the device. This behavior is undocumented but precludes comparing profiles too strictly.
// Because of this we have opted for a simple comparison that ensures a profile matching
// basic fields like name, SSID and (non-)broadcast status is considered equal.
func (a WlanXmlProfile) Equal(b WlanXmlProfile) bool {
if a.Name != b.Name {
return false
}
if a.SSIDConfig.NonBroadcast != b.SSIDConfig.NonBroadcast {
return false
}
if !a.SSIDConfig.SSIDPrefix.Equal(b.SSIDConfig.SSIDPrefix) {
return false
}
if len(a.SSIDConfig.SSID) != len(b.SSIDConfig.SSID) {
return false
}
a.sortSSIDs()
b.sortSSIDs()
return slices.EqualFunc(a.SSIDConfig.SSID, b.SSIDConfig.SSID, func(i, j WlanXmlProfileSSID) bool {
return i.Equal(j)
})
}
// a profile may have multiple SSIDs.
func (a *WlanXmlProfile) sortSSIDs() {
slices.SortFunc(a.SSIDConfig.SSID, func(i, j WlanXmlProfileSSID) int {
return strings.Compare(i.Hex, j.Hex)
})
}
// Generates a WLAN XML profile with the given SSID Config and name for use in our tests both. This
// is exported so it can be used by tests outside this package.
func GenerateWLANXMLProfileForTests(name string, ssidConfig WlanXmlProfileSSIDConfig) (string, error) {
type wlanXmlProfileForTests struct {
WlanXmlProfile
MSM string `xml:",innerxml"`
ConnectionMode string `xml:"connectionMode"`
ConnectionType string `xml:"connectionType"`
}
profile := wlanXmlProfileForTests{
WlanXmlProfile: WlanXmlProfile{
XMLName: xml.Name{Local: "WLANProfile", Space: "http://www.microsoft.com/networking/WLAN/profile/v1"},
Name: name,
SSIDConfig: ssidConfig,
},
ConnectionType: "ESS",
ConnectionMode: "auto",
MSM: `<security>
<authEncryption>
<authentication>WPA2PSK</authentication>
<encryption>AES</encryption>
<useOneX>false</useOneX>
</authEncryption>
<sharedKey>
<keyType>passPhrase</keyType>
<protected>false</protected>
<keyMaterial>sup3rs3cr3t</keyMaterial>
</sharedKey>
</security>`,
}
xmlBytes, err := xml.Marshal(profile)
if err != nil {
return "", fmt.Errorf("Error marshaling WLAN XML profile: %w", err)
}
var buffer strings.Builder
err = xml.EscapeText(&buffer, xmlBytes)
if err != nil {
return "", fmt.Errorf("Error escaping marshalled WLAN XML profile: %w", err)
}
return buffer.String(), nil
}