From 4340cdc9e650af36527515d39fce48001a0071d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20Friedrich=20Dreyer?= Date: Mon, 24 Nov 2025 15:52:56 +0100 Subject: [PATCH] handle objectguid endianess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jörn Friedrich Dreyer --- services/graph/pkg/identity/ldap.go | 62 +++++++++++++++---- services/graph/pkg/identity/ldap_group.go | 4 +- services/graph/pkg/identity/ldap_test.go | 74 ++++++++++++++++++++++- 3 files changed, 124 insertions(+), 16 deletions(-) diff --git a/services/graph/pkg/identity/ldap.go b/services/graph/pkg/identity/ldap.go index 9ca26bf51b..2725ffe918 100644 --- a/services/graph/pkg/identity/ldap.go +++ b/services/graph/pkg/identity/ldap.go @@ -497,7 +497,7 @@ func (i *LDAP) searchLDAPEntryByFilter(basedn string, attrs []string, filter str return res.Entries[0], nil } -func filterEscapeUUID(binary bool, id string) (string, error) { +func filterEscapeAttribute(attribute string, binary bool, id string) (string, error) { var escaped string if binary { pid, err := uuid.Parse(id) @@ -505,17 +505,44 @@ func filterEscapeUUID(binary bool, id string) (string, error) { err := fmt.Errorf("error parsing id '%s' as UUID: %w", id, err) return "", err } - for _, b := range pid { - escaped = fmt.Sprintf("%s\\%02x", escaped, b) - } + escaped = filterEscapeBinaryUUID(attribute, pid) } else { escaped = ldap.EscapeFilter(id) } return escaped, nil } +// swapObjectGUIDBytes converts between AD's mixed-endian objectGUID format and standard UUID byte order +func swapObjectGUIDBytes(value []byte) []byte { + if len(value) != 16 { + return value + } + return []byte{ + value[3], value[2], value[1], value[0], // First component (4 bytes) - reverse + value[5], value[4], // Second component (2 bytes) - reverse + value[7], value[6], // Third component (2 bytes) - reverse + value[8], value[9], value[10], value[11], value[12], value[13], value[14], value[15], // Last 8 bytes - keep as-is + } +} + +func filterEscapeBinaryUUID(attribute string, value uuid.UUID) string { + bytes := value[:] + + // AD stores objectGUID with mixed endianness 🤪 - swap first 3 components + if strings.EqualFold(attribute, "objectguid") { + bytes = swapObjectGUIDBytes(bytes) + } + + var filtered strings.Builder + filtered.Grow(len(bytes) * 3) // Pre-allocate: each byte becomes "\xx" + for _, b := range bytes { + fmt.Fprintf(&filtered, "\\%02x", b) + } + return filtered.String() +} + func (i *LDAP) getLDAPUserByID(id string) (*ldap.Entry, error) { - idString, err := filterEscapeUUID(i.userIDisOctetString, id) + idString, err := filterEscapeAttribute(i.userAttributeMap.id, i.userIDisOctetString, id) if err != nil { return nil, fmt.Errorf("invalid User id: %w", err) } @@ -524,7 +551,7 @@ func (i *LDAP) getLDAPUserByID(id string) (*ldap.Entry, error) { } func (i *LDAP) getLDAPUserByNameOrID(nameOrID string) (*ldap.Entry, error) { - idString, err := filterEscapeUUID(i.userIDisOctetString, nameOrID) + idString, err := filterEscapeAttribute(i.userAttributeMap.id, i.userIDisOctetString, nameOrID) // err != nil just means that this is not an uuid, so we can skip the uuid filter part // and just filter by name var filter string @@ -812,16 +839,25 @@ func (i *LDAP) updateUserPassword(ctx context.Context, dn, password string) erro return err } -func (i *LDAP) ldapUUIDtoString(e *ldap.Entry, attrType string, binary bool) (string, error) { +func (i *LDAP) ldapUUIDtoString(e *ldap.Entry, attribute string, binary bool) (string, error) { if binary { - rawValue := e.GetEqualFoldRawAttributeValue(attrType) - value, err := uuid.FromBytes(rawValue) - if err == nil { - return value.String(), nil + value := e.GetEqualFoldRawAttributeValue(attribute) + + if len(value) != 16 { + return "", fmt.Errorf("invalid UUID in '%s' attribute (got %d bytes)", attribute, len(value)) } - return "", err + + // AD stores objectGUID with mixed endianness 🤪 - swap first 3 components + if strings.EqualFold(attribute, "objectguid") { + value = swapObjectGUIDBytes(value) + } + id, err := uuid.FromBytes(value) + if err != nil { + return "", fmt.Errorf("error parsing UUID from '%s' attribute bytes: %w", attribute, err) + } + return id.String(), nil } - return e.GetEqualFoldAttributeValue(attrType), nil + return e.GetEqualFoldAttributeValue(attribute), nil } func (i *LDAP) createUserModelFromLDAP(e *ldap.Entry) *libregraph.User { diff --git a/services/graph/pkg/identity/ldap_group.go b/services/graph/pkg/identity/ldap_group.go index 56817549b3..abcfa7abb6 100644 --- a/services/graph/pkg/identity/ldap_group.go +++ b/services/graph/pkg/identity/ldap_group.go @@ -455,7 +455,7 @@ func (i *LDAP) groupToLDAPAttrValues(group libregraph.Group) (map[string][]strin } func (i *LDAP) getLDAPGroupByID(id string, requestMembers bool) (*ldap.Entry, error) { - idString, err := filterEscapeUUID(i.groupIDisOctetString, id) + idString, err := filterEscapeAttribute(i.groupAttributeMap.id, i.groupIDisOctetString, id) if err != nil { return nil, fmt.Errorf("invalid group id: %w", err) } @@ -464,7 +464,7 @@ func (i *LDAP) getLDAPGroupByID(id string, requestMembers bool) (*ldap.Entry, er } func (i *LDAP) getLDAPGroupByNameOrID(nameOrID string, requestMembers bool) (*ldap.Entry, error) { - idString, err := filterEscapeUUID(i.groupIDisOctetString, nameOrID) + idString, err := filterEscapeAttribute(i.groupAttributeMap.id, i.groupIDisOctetString, nameOrID) // err != nil just means that this is not an uuid, so we can skip the uuid filter part // and just filter by name filter := "" diff --git a/services/graph/pkg/identity/ldap_test.go b/services/graph/pkg/identity/ldap_test.go index 31cf762b23..db03e33d88 100644 --- a/services/graph/pkg/identity/ldap_test.go +++ b/services/graph/pkg/identity/ldap_test.go @@ -2,6 +2,7 @@ package identity import ( "context" + "encoding/base64" "errors" "fmt" "net/url" @@ -9,10 +10,10 @@ import ( "github.com/CiscoM31/godata" "github.com/go-ldap/ldap/v3" + libregraph "github.com/opencloud-eu/libre-graph-api-go" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/services/graph/pkg/config" "github.com/opencloud-eu/opencloud/services/graph/pkg/identity/mocks" - libregraph "github.com/opencloud-eu/libre-graph-api-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -63,6 +64,33 @@ var userEntry = ldap.NewEntry("uid=user", "usertypeattribute": {"Member"}, }) +var lconfigAD = config.LDAP{ + UserBaseDN: "ou=users,dc=test", + UserObjectClass: "user", + UserSearchScope: "sub", + UserFilter: "", + UserDisplayNameAttribute: "displayname", + UserIDAttribute: "objectGUID", + UserIDIsOctetString: true, + UserEmailAttribute: "mail", + UserNameAttribute: "uid", + UserEnabledAttribute: "userEnabledAttribute", + UserTypeAttribute: "userTypeAttribute", + LdapDisabledUsersGroupDN: disableUsersGroup, + DisableUserMechanism: "attribute", + + GroupBaseDN: "ou=groups,dc=test", + GroupObjectClass: "group", + GroupSearchScope: "sub", + GroupFilter: "", + GroupNameAttribute: "cn", + GroupMemberAttribute: "member", + GroupIDAttribute: "objectGUID", + GroupIDIsOctetString: true, + + WriteEnabled: true, +} + var invalidUserEntry = ldap.NewEntry("uid=user", map[string][]string{ "uid": {"invalid"}, @@ -260,6 +288,50 @@ func TestGetUser(t *testing.T) { assert.ErrorContains(t, err, "itemNotFound:") } +func TestGetUserAD(t *testing.T) { + + // we have to simulate ldap / AD returning a binary encoded objectguid + byteID, err := base64.StdEncoding.DecodeString("js8n0m6YBUqIYK8ZMFYnig==") + if err != nil { + t.Error(err) + } + userEntryAD := ldap.NewEntry("uid=user", + map[string][]string{ + "uid": {"user"}, + "displayname": {"DisplayName"}, + "mail": {"user@example"}, + "objectguid": {string(byteID)}, // ugly but works + "sn": {"surname"}, + "givenname": {"givenName"}, + "userenabledattribute": {"TRUE"}, + "usertypeattribute": {"Member"}, + }) + + // Mock a valid Search Result + lm := &mocks.Client{} + lm.On("Search", mock.Anything). + Return( + &ldap.SearchResult{ + Entries: []*ldap.Entry{userEntryAD}, + }, + nil) + + odataReqDefault, err := godata.ParseRequest(context.Background(), "", + url.Values{}) + if err != nil { + t.Errorf("Expected success got '%s'", err.Error()) + } + + b, _ := getMockedBackend(lm, lconfigAD, &logger) + u, err := b.GetUser(context.Background(), "user", odataReqDefault) + if err != nil { + t.Errorf("Expected GetUser to succeed. Got %s", err.Error()) + } else if *u.Id != "d227cf8e-986e-4a05-8860-af193056278a" { // this checks if we decoded the objectguid correctly + t.Errorf("Expected GetUser to return a valid user") + } + +} + func TestGetUsers(t *testing.T) { // Mock a Sizelimit Error lm := &mocks.Client{}