ldap-upstream/internal/ldap/hang_test.go
Michael Aldridge d74308280b internal/ldap: Reply with the result type a request expects
An LDAP client only accepts the result that pairs with the request it
sent, and silently ignores anything else. Unrouted operations were
answered with a generic result, which encodes with the wrong tag, so a
search with an unexpected base DN (for example the empty base that
ldapsearch uses when -b is omitted) left the client waiting forever
instead of reporting an error.

Every failure path now terminates the request with the matching result
type, panics included, so a single wrapper covers all operations rather
than one responder per operation. NetAuth calls carry a 30s deadline
because the gRPC client dials lazily and applies none of its own, and
refused operations and failed authentication are logged, which is what
an operator needs when a client appears to hang.

💘 Generated with Crush

Assisted-by: Crush:qwen38_flash_next_mtp
2026-11-02 19:59:04 -06:00

93 lines
2.6 KiB
Go

package ldap
import (
"errors"
"io"
"net"
"testing"
"time"
"github.com/vjeantet/goldap/message"
)
// TestUnroutedSearchTerminatesOverTheWire is the end-to-end
// regression test for the hang reported in the field: a search whose
// base DN matches no route (for example the empty base an LDAP client
// uses when no -b flag is given) must still be answered with a
// searchResDone, otherwise the client waits for one forever.
func TestUnroutedSearchTerminatesOverTheWire(t *testing.T) {
s := New(WithLogger(testLogger()), WithNetAuth(&fakeClient{}))
if err := s.SetDomain("example.com"); err != nil {
t.Fatalf("SetDomain: %s", err)
}
addr := freeAddr(t)
if err := s.Serve(addr); err != nil {
t.Fatalf("Serve: %s", err)
}
t.Cleanup(func() { s.Listener.Close() })
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil {
t.Fatalf("dial: %s", err)
}
t.Cleanup(func() { conn.Close() })
// A search with an empty base DN and subtree scope, exactly what
// an LDAP client sends when no -b flag is given. It matches no
// route and must be answered, not silently dropped.
conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
if _, err := conn.Write(berSearchRequest("")); err != nil {
t.Fatalf("sending search: %s", err)
}
// Read responses until a searchResDone arrives or the deadline
// passes; a client would block here forever if none is sent.
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
conn.SetReadDeadline(deadline)
raw, err := readLDAPPacket(conn)
if err != nil {
t.Fatalf("reading response: %s", err)
}
m, err := message.ReadLDAPMessage(message.NewBytes(0, raw))
if err != nil {
t.Fatalf("decoding response: %s", err)
}
if _, ok := m.ProtocolOp().(message.SearchResultDone); ok {
return
}
}
t.Error("no searchResDone received; an unrouted search hangs the client")
}
// readLDAPPacket reads one BER TLV (the top-level LDAPMessage
// SEQUENCE) and returns its full encoding.
func readLDAPPacket(r io.Reader) ([]byte, error) {
head := make([]byte, 2)
if _, err := io.ReadFull(r, head); err != nil {
return nil, err
}
packet := head
length := int(head[1])
if head[1]&0x80 != 0 {
nb := int(head[1] & 0x7f)
if nb == 0 || nb > 4 {
return nil, errors.New("unsupported length encoding")
}
ext := make([]byte, nb)
if _, err := io.ReadFull(r, ext); err != nil {
return nil, err
}
length = 0
for _, b := range ext {
length = length<<8 | int(b)
}
packet = append(packet, ext...)
}
body := make([]byte, length)
if _, err := io.ReadFull(r, body); err != nil {
return nil, err
}
return append(packet, body...), nil
}