diff --git a/pkg/server/dns_server.go b/pkg/server/dns_server.go index c724795c..c49c0943 100644 --- a/pkg/server/dns_server.go +++ b/pkg/server/dns_server.go @@ -9,7 +9,6 @@ import ( "sync/atomic" "time" - jsoniter "github.com/json-iterator/go" "github.com/miekg/dns" "github.com/pkg/errors" "github.com/projectdiscovery/gologger" @@ -301,8 +300,6 @@ func toQType(ttype uint16) (rtype string) { // handleInteraction handles an interaction for the DNS server func (h *DNSServer) handleInteraction(domain string, w dns.ResponseWriter, r *dns.Msg, m *dns.Msg) { - var uniqueID, fullID string - requestMsg := r.String() responseMsg := m.String() @@ -317,10 +314,9 @@ func (h *DNSServer) handleInteraction(domain string, w dns.ResponseWriter, r *dn } } - // if root-tld is enabled stores any interaction towards the main domain + host, _, _ := net.SplitHostPort(w.RemoteAddr().String()) + if h.options.RootTLD && foundDomain != "" { - correlationID := foundDomain - host, _, _ := net.SplitHostPort(w.RemoteAddr().String()) interaction := &Interaction{ Protocol: "dns", UniqueID: domain, @@ -331,55 +327,18 @@ func (h *DNSServer) handleInteraction(domain string, w dns.ResponseWriter, r *dn RemoteAddress: host, Timestamp: time.Now(), } - - if nil != h.options.OnResult { + if h.options.OnResult != nil { h.options.OnResult(interaction) } - - data, err := jsoniter.Marshal(interaction) - if err != nil { - gologger.Warning().Msgf("Could not encode root tld dns interaction: %s\n", err) - } else { - gologger.Debug().Msgf("Root TLD DNS Interaction: \n%s\n", string(data)) - if err := h.options.Storage.AddInteractionWithId(correlationID, data); err != nil { - gologger.Warning().Msgf("Could not store dns interaction: %s\n", err) - } - } + h.options.storeRootTLDInteraction(interaction, foundDomain) } - if foundDomain != "" { - if h.options.ScanEverywhere { - chunks := stringsutil.SplitAny(requestMsg, ".\n\t\"'") - for _, chunk := range chunks { - for part := range stringsutil.SlideWithLength(chunk, h.options.GetIdLength()) { - normalizedPart := strings.ToLower(part) - if h.options.isCorrelationID(normalizedPart) { - uniqueID = normalizedPart - fullID = part - } - } - } - } else { - parts := strings.Split(domain, ".") - for i, part := range parts { - for partChunk := range stringsutil.SlideWithLength(part, h.options.GetIdLength()) { - normalizedPartChunk := strings.ToLower(partChunk) - if h.options.isCorrelationID(normalizedPartChunk) { - fullID = part - if i+1 <= len(parts) { - fullID = strings.Join(parts[:i+1], ".") - } - uniqueID = normalizedPartChunk - } - } - } - } + if foundDomain == "" { + return } - if uniqueID != "" { - correlationID := uniqueID[:h.options.CorrelationIdLength] - host, _, _ := net.SplitHostPort(w.RemoteAddr().String()) - interaction := &Interaction{ + storeMatch := func(uniqueID, fullID string) { + h.options.storeInteraction(&Interaction{ Protocol: "dns", UniqueID: uniqueID, FullId: fullID, @@ -388,15 +347,34 @@ func (h *DNSServer) handleInteraction(domain string, w dns.ResponseWriter, r *dn RawResponse: responseMsg, RemoteAddress: host, Timestamp: time.Now(), + }, uniqueID[:h.options.CorrelationIdLength]) + } + + if h.options.ScanEverywhere { + chunks := stringsutil.SplitAny(requestMsg, ".\n\t\"'") + for _, chunk := range chunks { + for part := range stringsutil.SlideWithLength(chunk, h.options.GetIdLength()) { + normalizedPart := strings.ToLower(part) + if h.options.isCorrelationID(normalizedPart) { + storeMatch(normalizedPart, part) + } + } } - data, err := jsoniter.Marshal(interaction) - if err != nil { - gologger.Warning().Msgf("Could not encode dns interaction: %s\n", err) - } else { - gologger.Debug().Msgf("DNS Interaction: \n%s\n", string(data)) - if err := h.options.Storage.AddInteraction(correlationID, data); err != nil { - gologger.Warning().Msgf("Could not store dns interaction: %s\n", err) + return + } + + parts := strings.Split(domain, ".") + for i, part := range parts { + for partChunk := range stringsutil.SlideWithLength(part, h.options.GetIdLength()) { + normalizedPartChunk := strings.ToLower(partChunk) + if !h.options.isCorrelationID(normalizedPartChunk) { + continue + } + fullID := part + if i+1 <= len(parts) { + fullID = strings.Join(parts[:i+1], ".") } + storeMatch(normalizedPartChunk, fullID) } } } diff --git a/pkg/server/dns_server_interaction_test.go b/pkg/server/dns_server_interaction_test.go new file mode 100644 index 00000000..1dcba12e --- /dev/null +++ b/pkg/server/dns_server_interaction_test.go @@ -0,0 +1,197 @@ +package server + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "net" + "strings" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/projectdiscovery/interactsh/pkg/storage" + "github.com/stretchr/testify/require" +) + +// Regression suite for https://github.com/projectdiscovery/interactsh/issues/1362. +// Each row exercises a different shape of correlation id and parent domain to +// guard against false-positive matches in ordinary domain labels overwriting +// legitimate ones, and against alphabet mismatches. +func TestDNSInteractionStored(t *testing.T) { + cases := []struct { + name string + cidl, cidn int + correlationID string + nonce string + parentDomain string + queryName func(preamble, parent string) string + expectStored int + }{ + { + name: "short ids with parent label rejected by alphabet (example.com)", + cidl: 3, cidn: 3, + correlationID: "d82", nonce: "yyy", + parentDomain: "example.com", + expectStored: 1, + }, + { + name: "short ids with alphabet-compatible parent label (oast.online)", + cidl: 3, cidn: 3, + correlationID: "d82", nonce: "yyy", + parentDomain: "oast.online", + expectStored: 1, + }, + { + name: "readme example cidl=4 cidn=6", + cidl: 4, cidn: 6, + correlationID: "abcd", nonce: "ybndrf", + parentDomain: "hackwithautomation.com", + expectStored: 1, + }, + { + name: "default lengths", + cidl: 20, cidn: 13, + correlationID: "c6rj61aciaeutn2ae680", nonce: "cg5ugboyyyyyn", + parentDomain: "example.com", + expectStored: 1, + }, + { + name: "uppercase query is normalized", + cidl: 3, cidn: 3, + correlationID: "d82", nonce: "yyy", + parentDomain: "example.com", + queryName: func(preamble, parent string) string { + return strings.ToUpper(preamble) + "." + parent + "." + }, + expectStored: 1, + }, + { + name: "multi-level subdomain prefix", + cidl: 3, cidn: 3, + correlationID: "d82", nonce: "yyy", + parentDomain: "example.com", + queryName: func(preamble, parent string) string { + return "extra." + preamble + "." + parent + "." + }, + expectStored: 1, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + store := newTestStorage(t) + registerTestKey(t, store, tc.correlationID) + + port := freeUDPPort(t) + startTestDNSServer(t, &Options{ + Domains: []string{tc.parentDomain}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + ListenIP: "127.0.0.1", + DnsPort: port, + Storage: store, + CorrelationIdLength: tc.cidl, + CorrelationIdNonceLength: tc.cidn, + Stats: &Metrics{}, + }) + + query := defaultQueryName(tc.correlationID+tc.nonce, tc.parentDomain) + if tc.queryName != nil { + query = tc.queryName(tc.correlationID+tc.nonce, tc.parentDomain) + } + sendDNSQuery(t, port, query) + + interactions, _, err := store.GetInteractions(tc.correlationID, "secret") + require.NoError(t, err) + require.Len(t, interactions, tc.expectStored) + }) + } +} + +// Unregistered preambles must not produce stored interactions, even though +// they pass the alphabet check. +func TestDNSInteractionNotStored_UnregisteredPreamble(t *testing.T) { + store := newTestStorage(t) + registerTestKey(t, store, "d82") + + port := freeUDPPort(t) + startTestDNSServer(t, &Options{ + Domains: []string{"example.com"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + ListenIP: "127.0.0.1", + DnsPort: port, + Storage: store, + CorrelationIdLength: 3, + CorrelationIdNonceLength: 3, + Stats: &Metrics{}, + }) + + sendDNSQuery(t, port, "abcybn.example.com.") + + interactions, _, err := store.GetInteractions("d82", "secret") + require.NoError(t, err) + require.Empty(t, interactions, "queries that do not target a registered preamble must not be stored under another id") +} + +func defaultQueryName(preamble, parent string) string { + return preamble + "." + parent + "." +} + +func freeUDPPort(t *testing.T) int { + t.Helper() + addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:0") + require.NoError(t, err) + conn, err := net.ListenUDP("udp", addr) + require.NoError(t, err) + port := conn.LocalAddr().(*net.UDPAddr).Port + require.NoError(t, conn.Close()) + return port +} + +func newTestStorage(t *testing.T) storage.Storage { + t.Helper() + s, err := storage.New(&storage.Options{EvictionTTL: time.Hour}) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + return s +} + +func registerTestKey(t *testing.T, store storage.Storage, correlationID string) { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + pubBytes, err := x509.MarshalPKIXPublicKey(&priv.PublicKey) + require.NoError(t, err) + pemEncoded := pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: pubBytes}) + require.NoError(t, store.SetIDPublicKey(correlationID, "secret", base64.StdEncoding.EncodeToString(pemEncoded))) +} + +func startTestDNSServer(t *testing.T, opts *Options) { + t.Helper() + server := NewDNSServer("udp", opts) + alive := make(chan bool, 2) + go server.ListenAndServe(alive) + <-alive + require.Eventually(t, func() bool { + c, err := net.DialTimeout("udp", fmt.Sprintf("127.0.0.1:%d", opts.DnsPort), 100*time.Millisecond) + if err != nil { + return false + } + _ = c.Close() + return true + }, time.Second, 20*time.Millisecond) + t.Cleanup(func() { _ = server.server.Shutdown() }) +} + +func sendDNSQuery(t *testing.T, port int, name string) { + t.Helper() + msg := new(dns.Msg) + msg.SetQuestion(name, dns.TypeA) + client := &dns.Client{Net: "udp", Timeout: 2 * time.Second} + _, _, err := client.Exchange(msg, fmt.Sprintf("127.0.0.1:%d", port)) + require.NoError(t, err) +} diff --git a/pkg/server/smtp_server.go b/pkg/server/smtp_server.go index 6c88ca06..f7cb2a2d 100644 --- a/pkg/server/smtp_server.go +++ b/pkg/server/smtp_server.go @@ -8,7 +8,6 @@ import ( "time" "git.mills.io/prologic/smtpd" - jsoniter "github.com/json-iterator/go" "github.com/projectdiscovery/gologger" stringsutil "github.com/projectdiscovery/utils/strings" ) @@ -84,77 +83,58 @@ func (h *SMTPServer) ListenAndServe(tlsConfig *tls.Config, smtpAlive, smtpsAlive func (h *SMTPServer) defaultHandler(remoteAddr net.Addr, from string, to []string, data []byte) error { atomic.AddUint64(&h.options.Stats.Smtp, 1) - var uniqueID, fullID string - dataString := string(data) gologger.Debug().Msgf("New SMTP request: %s %s %s %s\n", remoteAddr, from, to, dataString) - // if root-tld is enabled stores any interaction towards the main domain - for _, addr := range to { - if h.options.RootTLD { + host, _, _ := net.SplitHostPort(remoteAddr.String()) + + if h.options.RootTLD { + for _, addr := range to { for _, domain := range h.options.Domains { - if stringsutil.HasSuffixI(addr, domain) { - ID := domain - host, _, _ := net.SplitHostPort(remoteAddr.String()) - address := addr[strings.LastIndex(addr, "@"):] - interaction := &Interaction{ - Protocol: "smtp", - UniqueID: address, - FullId: address, - RawRequest: dataString, - SMTPFrom: from, - RemoteAddress: host, - Timestamp: time.Now(), - } - data, err := jsoniter.Marshal(interaction) - if err != nil { - gologger.Warning().Msgf("Could not encode root tld SMTP interaction: %s\n", err) - } else { - gologger.Debug().Msgf("Root TLD SMTP Interaction: \n%s\n", string(data)) - if err := h.options.Storage.AddInteractionWithId(ID, data); err != nil { - gologger.Warning().Msgf("Could not store root tld smtp interaction: %s\n", err) - } - } + if !stringsutil.HasSuffixI(addr, domain) { + continue } + address := addr[strings.LastIndex(addr, "@"):] + h.options.storeRootTLDInteraction(&Interaction{ + Protocol: "smtp", + UniqueID: address, + FullId: address, + RawRequest: dataString, + SMTPFrom: from, + RemoteAddress: host, + Timestamp: time.Now(), + }, domain) } } } + // Each matched correlation id is stored independently; previously the loop + // captured a single uniqueID/fullID and stored it once after the loop, so + // later false-positive labels could overwrite the legitimate match and + // the interaction would be persisted under an unregistered id (issue #1362). for _, addr := range to { - if len(addr) > h.options.GetIdLength() && strings.Contains(addr, "@") { - parts := strings.Split(addr[strings.LastIndex(addr, "@")+1:], ".") - for i, part := range parts { - if h.options.isCorrelationID(part) { - uniqueID = part - fullID = part - if i+1 <= len(parts) { - fullID = strings.Join(parts[:i+1], ".") - } - } - } + if len(addr) <= h.options.GetIdLength() || !strings.Contains(addr, "@") { + continue } - } - if uniqueID != "" { - host, _, _ := net.SplitHostPort(remoteAddr.String()) - - correlationID := uniqueID[:h.options.CorrelationIdLength] - interaction := &Interaction{ - Protocol: "smtp", - UniqueID: uniqueID, - FullId: fullID, - RawRequest: dataString, - SMTPFrom: from, - RemoteAddress: host, - Timestamp: time.Now(), - } - data, err := jsoniter.Marshal(interaction) - if err != nil { - gologger.Warning().Msgf("Could not encode smtp interaction: %s\n", err) - } else { - gologger.Debug().Msgf("%s\n", string(data)) - if err := h.options.Storage.AddInteraction(correlationID, data); err != nil { - gologger.Warning().Msgf("Could not store smtp interaction: %s\n", err) + parts := strings.Split(addr[strings.LastIndex(addr, "@")+1:], ".") + for i, part := range parts { + normalizedPart := strings.ToLower(part) + if !h.options.isCorrelationID(normalizedPart) { + continue + } + fullID := part + if i+1 <= len(parts) { + fullID = strings.Join(parts[:i+1], ".") } + h.options.storeInteraction(&Interaction{ + Protocol: "smtp", + UniqueID: normalizedPart, + FullId: fullID, + RawRequest: dataString, + SMTPFrom: from, + RemoteAddress: host, + Timestamp: time.Now(), + }, normalizedPart[:h.options.CorrelationIdLength]) } } return nil diff --git a/pkg/server/smtp_server_interaction_test.go b/pkg/server/smtp_server_interaction_test.go new file mode 100644 index 00000000..c7b48afd --- /dev/null +++ b/pkg/server/smtp_server_interaction_test.go @@ -0,0 +1,125 @@ +package server + +import ( + "net" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Regression suite for https://github.com/projectdiscovery/interactsh/issues/1362 +// covering the SMTP path. Each row exercises a different shape of correlation +// id and parent domain to guard against false-positive matches in ordinary +// domain labels overwriting legitimate ones, and against alphabet mismatches. +func TestSMTPInteractionStored(t *testing.T) { + cases := []struct { + name string + cidl, cidn int + correlationID string + nonce string + parentDomain string + toAddress func(preamble, parent string) string + expectStored int + }{ + { + name: "short ids with parent label rejected by alphabet (example.com)", + cidl: 3, cidn: 3, + correlationID: "d82", nonce: "yyy", + parentDomain: "example.com", + expectStored: 1, + }, + { + name: "short ids with alphabet-compatible parent label (oast.online)", + cidl: 3, cidn: 3, + correlationID: "d82", nonce: "yyy", + parentDomain: "oast.online", + expectStored: 1, + }, + { + name: "default lengths", + cidl: 20, cidn: 13, + correlationID: "c6rj61aciaeutn2ae680", nonce: "cg5ugboyyyyyn", + parentDomain: "example.com", + expectStored: 1, + }, + { + name: "uppercase recipient is normalized", + cidl: 3, cidn: 3, + correlationID: "d82", nonce: "yyy", + parentDomain: "example.com", + toAddress: func(preamble, parent string) string { + return "victim@" + strings.ToUpper(preamble) + "." + parent + }, + expectStored: 1, + }, + { + name: "multi-level subdomain prefix", + cidl: 3, cidn: 3, + correlationID: "d82", nonce: "yyy", + parentDomain: "example.com", + toAddress: func(preamble, parent string) string { + return "victim@extra." + preamble + "." + parent + }, + expectStored: 1, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + store := newTestStorage(t) + registerTestKey(t, store, tc.correlationID) + + opts := &Options{ + Domains: []string{tc.parentDomain}, + ListenIP: "127.0.0.1", + Storage: store, + CorrelationIdLength: tc.cidl, + CorrelationIdNonceLength: tc.cidn, + Stats: &Metrics{}, + } + srv := &SMTPServer{options: opts} + + addr := defaultRecipient(tc.correlationID+tc.nonce, tc.parentDomain) + if tc.toAddress != nil { + addr = tc.toAddress(tc.correlationID+tc.nonce, tc.parentDomain) + } + remote := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 12345} + require.NoError(t, srv.defaultHandler(remote, "attacker@example.org", []string{addr}, []byte("DATA"))) + + interactions, _, err := store.GetInteractions(tc.correlationID, "secret") + require.NoError(t, err) + require.Len(t, interactions, tc.expectStored) + }) + } +} + +// Unregistered preambles must not produce stored interactions, even though +// they pass the alphabet check. This is the structural defense against the +// previous "last match wins" overwrite bug. +func TestSMTPInteractionNotStored_UnregisteredPreamble(t *testing.T) { + store := newTestStorage(t) + registerTestKey(t, store, "d82") + + opts := &Options{ + Domains: []string{"example.com"}, + ListenIP: "127.0.0.1", + Storage: store, + CorrelationIdLength: 3, + CorrelationIdNonceLength: 3, + Stats: &Metrics{}, + } + srv := &SMTPServer{options: opts} + + remote := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 12345} + require.NoError(t, srv.defaultHandler(remote, "attacker@example.org", []string{"victim@abcybn.example.com"}, []byte("DATA"))) + + interactions, _, err := store.GetInteractions("d82", "secret") + require.NoError(t, err) + require.Empty(t, interactions, "recipients that do not target a registered preamble must not be stored under another id") +} + +func defaultRecipient(preamble, parent string) string { + return "victim@" + preamble + "." + parent +} diff --git a/pkg/server/util.go b/pkg/server/util.go index 172a9acb..c91259c6 100644 --- a/pkg/server/util.go +++ b/pkg/server/util.go @@ -5,22 +5,83 @@ import ( "strconv" "strings" - "github.com/asaskevich/govalidator" - "github.com/rs/xid" + jsoniter "github.com/json-iterator/go" + "github.com/projectdiscovery/gologger" ) -func (options *Options) isCorrelationID(s string) bool { - if len(s) == options.GetIdLength() && govalidator.IsAlphanumeric(s) { - // xid should be 12 - if options.CorrelationIdLength != 12 { - return true - } else if _, err := xid.FromString(strings.ToLower(s[:options.CorrelationIdLength])); err == nil { - return true +// Correlation ids are produced from xid (cidl prefix) and zbase32 (cidn suffix), +// so detection is restricted to those alphabets to avoid false-positive matches +// inside ordinary domain labels (see issue #1362). +const ( + xidAlphabet = "0123456789abcdefghijklmnopqrstuv" + zbase32Alphabet = "ybndrfg8ejkmcpqxot1uwisza345h769" +) + +var ( + xidAlphabetTable = newAlphabetTable(xidAlphabet) + zbase32AlphabetTable = newAlphabetTable(zbase32Alphabet) +) + +func newAlphabetTable(alphabet string) [256]bool { + var table [256]bool + for i := 0; i < len(alphabet); i++ { + c := alphabet[i] + table[c] = true + if c >= 'a' && c <= 'z' { + table[c-32] = true } } - return false + return table +} + +func inAlphabet(table [256]bool, s string) bool { + for i := 0; i < len(s); i++ { + if !table[s[i]] { + return false + } + } + return true +} + +func (options *Options) isCorrelationID(s string) bool { + if len(s) != options.GetIdLength() { + return false + } + if !inAlphabet(xidAlphabetTable, s[:options.CorrelationIdLength]) { + return false + } + return inAlphabet(zbase32AlphabetTable, s[options.CorrelationIdLength:]) } func formatAddress(host string, port int) string { return net.JoinHostPort(host, strconv.Itoa(port)) } + +// storeInteraction marshals interaction and persists it under correlationID via Storage.AddInteraction. +// Each protocol handler builds its own protocol-specific Interaction and delegates the marshal/log/store +// step here so every match is stored independently (see issue #1362). +func (options *Options) storeInteraction(interaction *Interaction, correlationID string) { + data, err := jsoniter.Marshal(interaction) + if err != nil { + gologger.Warning().Msgf("Could not encode %s interaction: %s\n", interaction.Protocol, err) + return + } + gologger.Debug().Msgf("%s Interaction: \n%s\n", strings.ToUpper(interaction.Protocol), string(data)) + if err := options.Storage.AddInteraction(correlationID, data); err != nil { + gologger.Warning().Msgf("Could not store %s interaction: %s\n", interaction.Protocol, err) + } +} + +// storeRootTLDInteraction marshals interaction and persists it under id via Storage.AddInteractionWithId. +// Used when RootTLD is enabled and the request targets a configured parent domain directly. +func (options *Options) storeRootTLDInteraction(interaction *Interaction, id string) { + data, err := jsoniter.Marshal(interaction) + if err != nil { + gologger.Warning().Msgf("Could not encode root tld %s interaction: %s\n", interaction.Protocol, err) + return + } + gologger.Debug().Msgf("Root TLD %s Interaction: \n%s\n", strings.ToUpper(interaction.Protocol), string(data)) + if err := options.Storage.AddInteractionWithId(id, data); err != nil { + gologger.Warning().Msgf("Could not store root tld %s interaction: %s\n", interaction.Protocol, err) + } +} diff --git a/pkg/server/util_test.go b/pkg/server/util_test.go new file mode 100644 index 00000000..4ad13ff8 --- /dev/null +++ b/pkg/server/util_test.go @@ -0,0 +1,38 @@ +package server + +import ( + "testing" + + "github.com/projectdiscovery/interactsh/pkg/settings" + "github.com/stretchr/testify/require" +) + +func TestIsCorrelationID_DefaultLengths(t *testing.T) { + opts := &Options{ + CorrelationIdLength: settings.CorrelationIdLengthDefault, + CorrelationIdNonceLength: settings.CorrelationIdNonceLengthDefault, + } + + require.True(t, opts.isCorrelationID("c6rj61aciaeutn2ae680cg5ugboyyyyyn")) + require.False(t, opts.isCorrelationID("too-short")) + require.False(t, opts.isCorrelationID("c6rj61aciaeutn2ae680cg5ugboyyyy!!")) +} + +func TestIsCorrelationID_ShortLengthsRejectDomainLabelFalsePositives(t *testing.T) { + opts := &Options{CorrelationIdLength: 3, CorrelationIdNonceLength: 3} + + const exampleLabel = "example" + candidates := []string{exampleLabel[:6], exampleLabel[1:7], "google"} + for _, candidate := range candidates { + require.False(t, opts.isCorrelationID(candidate), "%q must not be treated as a correlation id", candidate) + } + + require.True(t, opts.isCorrelationID("d82yyy"), "valid xid+zbase32 preamble must still match") +} + +func TestIsCorrelationID_AlphabetBoundaries(t *testing.T) { + opts := &Options{CorrelationIdLength: 3, CorrelationIdNonceLength: 3} + + require.False(t, opts.isCorrelationID("xyzbnd"), "xid prefix must reject chars outside 0-9a-v") + require.False(t, opts.isCorrelationID("abclmv"), "zbase32 suffix must reject chars outside its alphabet") +}