fix(ldap): search objectGUID by raw bytes, not an escaped filter string

The existence sweep in syncBoundGroupsForTenant() built its filter by
interpolating a byte-wise \xx escape of the stored objectGUID into a filter
string. ldapts parses that string before encoding it and does not turn the
escape sequences back into the bytes they stand for, so the assertion value
that reached the directory was a different value and matched nothing.

Measured read-only against a real Active Directory on 2026-08-11, probing a
group whose GUID had just been read from that same directory:

  (objectGUID=\1e\4b...)                          0 hits
  (objectGUID=\1E\4B...)                          0 hits
  EqualityFilter{attribute, value: <16 bytes>}    1 hit, correct DN
  (cn=Domain Admins)  [control]                   1 hit

Both the narrow base-DN sweep and the wider WR-03 move-detection sweep shared
that filter, so neither could ever hit: every AD-bound group looked deleted and
would have been removed together with its GroupMembership and ModuleGrant rows
on the first real sync, after handing off the default-group marker.

Build the filter as an EqualityFilter over the raw Buffer instead, and drop
escapeLdapFilterBuffer() -- it has no remaining caller and is the trap the code
walked into. escapeLdapFilterValue() is untouched: escaping STRING values into
a filter is correct and still in use.

The existing spec mocks matched on the escaped string, which is how the broken
shape passed review. They now match on the filter object's Buffer value, and
two added tests fail if a stringly-typed objectGUID filter ever comes back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 11:05:11 +02:00
parent ba21b0c74a
commit d2019dc527
2 changed files with 175 additions and 37 deletions
+27 -19
View File
@@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { Client, Entry } from 'ldapts';
import { Client, EqualityFilter, Entry } from 'ldapts';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { GroupsService } from '../groups/groups.service';
@@ -1179,9 +1179,9 @@ export class LdapService {
* 2. Existence sweep: the stored hex ldapObjectGuid is validated as
* exactly 32 [0-9a-f] characters BEFORE it is ever turned into a
* filter (T-16-01) — an invalid value is an error line, never a filter
* interpolation. A valid value is turned back into a Buffer and
* byte-wise escaped via escapeLdapFilterBuffer() into an
* (objectGUID=...) filter, searched across every configured base DN.
* interpolation. A valid value is turned back into a Buffer and handed
* to an EqualityFilter over objectGUID — raw bytes, never an escaped
* filter string — searched across every configured base DN.
* 3. A hit whose cn/dn differ from the stored name/ldapDn is a rename
* (SC-3): Group.name/ldapDn are updated to the AD state,
* groupsRenamed++. internalName is NEVER written here (D-04). A
@@ -1315,7 +1315,21 @@ export class LdapService {
continue;
}
const guidBuffer = Buffer.from(ldapObjectGuid, 'hex');
const filter = `(objectGUID=${LdapService.escapeLdapFilterBuffer(guidBuffer)})`;
// The GUID goes onto the wire as raw bytes via an EqualityFilter, NOT
// as a `\xx`-escaped filter string. A string filter is parsed by ldapts
// before it is encoded, and the parser does not turn `\1e\4b...` back
// into the 16 bytes it stands for — the assertion value that reaches the
// directory is then a different value entirely and matches nothing.
// Measured read-only against a real AD on 2026-08-11 (see the quick task
// 260811-f9i): the escaped string returned 0 hits for an object whose
// GUID had just been read from that same directory, while this
// EqualityFilter returned exactly that object. Both sweeps below share
// this value, so the WR-03 move-detection cannot silently inherit a
// broken filter again.
const filter = new EqualityFilter({
attribute: 'objectGUID',
value: guidBuffer,
});
let hit: Entry | null = null;
for (const baseDn of baseDns) {
@@ -1503,20 +1517,14 @@ export class LdapService {
.replace(/\x00/g, '\\00');
}
/**
* Escape a binary value (e.g. a stored objectGUID) for use in an LDAP
* search filter per RFC 4515 — a byte-wise `\XX` hex escape, distinct from
* escapeLdapFilterValue() which escapes a STRING value. Not yet called
* anywhere in this plan (Plan 16-01 only WRITES ldapObjectGuid); Plan
* 16-03's existence sweep is the first caller, reading it back via a
* binary (objectGUID=...) filter. [ASSUMED — RFC 4515-Praxis, nicht gegen
* ein echtes AD verifiziert, siehe RESEARCH.md Pattern 3/A2.]
*/
static escapeLdapFilterBuffer(buf: Buffer): string {
return Array.from(buf)
.map((b) => '\\' + b.toString(16).padStart(2, '0'))
.join('');
}
// NOTE: escapeLdapFilterBuffer() used to live here — a byte-wise `\XX` hex
// escape for binary values, written under the assumption (RESEARCH.md A2)
// that ldapts would pass such a string through to the directory unchanged.
// It does not, and the resulting filter matched nothing; the existence sweep
// in syncBoundGroupsForTenant() now builds an EqualityFilter over the raw
// Buffer instead. Do not reintroduce it: escapeLdapFilterValue() below is for
// STRING values and stays correct, but binary values belong in a filter
// object, never in an interpolated filter string.
/**
* Split a DN into its individual RDN components, respecting a