fix: identify RFC 4533 sync control members by tag, not by count - #625
Merged
Merged
Conversation
NewControlSyncDone and NewControlSyncInfo switch on len(pkt.Children) and hardcode Children[0] as the cookie. RFC 4533 marks cookie OPTIONAL and the refresh flags DEFAULT, so the number of members present does not identify them. With the cookie omitted, syncIdSet reads Children[1].Value.(bool) on the SET, whose Value is nil, and panics; refreshDelete and refreshPresent read the boolean as the cookie and invert refreshDone; syncDone loses refreshDeletes; and a one-child syncIdSet drops every UUID. Identify members by tag, the way NewControlServerSideSorting already does after go-ldap#618, and guard the boolean assertion for the same reason it does.
cpuschma
approved these changes
Sep 7, 2026
Member
|
LGTM. I'm currently on vacation and I've started to work on a restructuring of how server responses are handled. There's atleast a dozen potential problems with no proper bound checking. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
On disclosure, first
I read
SECURITY.mdbefore opening this. I am sending it as a normal PR because the repository's own precedent for this exact class is public PRs -- #611, #612, #614 (guarding malformed decode input), and #617 and #618, both of which you authored. #617's description says in as many words that "a conformant server that omits it panics the caller with a nil-pointer dereference". If you would rather have had this privately, say so and I will close this immediately and resend it to the addresses inSECURITY.md.The bug
NewControlSyncDoneandNewControlSyncInfodecode RFC 4533 sync sequences by switching onlen(pkt.Children)and treatingChildren[0]as the cookie. RFC 4533 §2.4/§2.5 markcookieasOPTIONALand the refresh flags asDEFAULT, so the number of members present does not identify which members they are.With the cookie omitted:
syncIdSetChildren[1]is theSET, whoseValueis nil, andChildren[1].Value.(bool)asserts on itsyncIdSet, flags also omittedrefreshDelete/refreshPresentrefreshDonecomes out invertedsyncDoneCookiegets the flag's byte,RefreshDeletesis lostAll reproduced through the exported constructors, no network.
Your own
NewControlServerSideSortingalready does thisv3/control.go:961decodes the same shape -- a SEQUENCE with OPTIONAL and context-tagged members -- by iterating children and switching onClassType/Tag, and it guards the type assertion:That comment describes the exact failure in
NewControlSyncInfo, and it is your #618. This change applies the same shape to the sync controls: oneparseSyncMembershelper that dispatches on tag, with the boolean assertion guarded.What I did not change
NewControlSyncState(v3/control.go:1246) also switches on the child count, and it is correct -- there the cookie is the trailing optional, so position is unambiguous. I left it alone rather than churn it.Verification
gofmt -l .clean,go vet .clean -- CI runs exactly those, thengo test -v -cover -race -count=1 .. The offline half of that suite:ok github.com/go-ldap/ldap/v3on both master and this branch, coverage 32.3% either way. I did not run the integration half, which needsmake local-serverand a Docker OpenLDAP; the network tests hang in my sandbox and I would rather say so than imply I ran them.There was no test coverage at all for these constructors before this --
grepforNewControlSyncInfo|NewControlSyncDone|SyncInfoin*_test.goreturns nothing -- which is why this went unnoticed. The new tests include anall_members_presentrow that passes on both sides, as a regression guard on the path that already worked.I mutation-checked each site separately, anchored to its own
caseblock since several are textual twins. All eight fail when weakened:Cookie = "", want "csn=1"RefreshDeletes = false, want trueSyncUUIDs = [], want 1 entryRefreshDeletes = false, want trueRefreshDone = true, want falseRefreshDone = true, want falseRefreshDeletes = false, want truepanic: nil pointer dereferenceTwo notes on that, because both changed the diff:
if child.ClassType != ber.ClassUniversal { continue }guard I had copied from the sorting sibling. It is load-bearing there (SortKeyreally does have context-tagged members) but unreachable here, since nosyncInfoValuemember is context-tagged. I removed those lines rather than ship code no test can pin.uuidSetnil guard also survived at first. That one I kept and covered instead, withTestControlSyncInfoSyncIdSetWithoutUUIDs, because a server omitting the mandatorysyncUUIDswould otherwise nil-deref.Observable output
Identical for every input the old code handled -- the
all_members_presentrow passes unmodified on both sides. It changes only the cases that were wrong. No existing test row had to change, since there were none.Disclosure: AI-assisted. I found and prepared this with an AI assistant, and I ran and verified everything above myself.