// Copyright 2025 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package x509 import ( "bytes" "fmt" "net" "net/netip" "net/url" "slices" "strings" ) // This file contains the data structures and functions necessary for // efficiently checking X.509 name constraints. The method for constraint // checking implemented in this file is based on a technique originally // described by davidben@google.com. // // The basic concept is based on the fact that constraints describe possibly // overlapping subtrees that we need to match against. If sorted in lexicographic // order, and then pruned, removing any subtrees that overlap with preceding // subtrees, a simple binary search can be used to find the nearest matching // prefix. This reduces the complexity of name constraint checking from // quadratic to log linear complexity. // // A close reading of RFC 5280 may suggest that constraints could also be // implemented as a trie (or radix tree), which would present the possibility of // doing construction and matching in linear time, but the memory cost of // implementing them is actually quite high, and in the worst case (where each // node has a high number of children) can be abused to require a program to use // significant amounts of memory. The log linear approach taken here is // extremely cheap in terms of memory because we directly alias the already // parsed constraints, thus avoiding the need to do significant additional // allocations. // // The basic data structure is nameConstraintsSet, which implements the sorting, // pruning, and querying of the prefix sets. // // In order to check IP, DNS, URI, and email constraints, we need to use two // different techniques, one for IP addresses, which is quite simple, and one // for DNS names, which additionally compose the portions of URIs and emails we // care about (technically we also need some special logic for email addresses // as well for when constraints comprise of full email addresses) which is // slightly more complex. // // IP addresses use two nameConstraintsSets, one for IPv4 addresses and one for // IPv6 addresses, with no additional logic. // // DNS names require some extra logic in order to handle the distinctions // between permitted and excluded subtrees, as well as for wildcards, and the // semantics of leading period constraints (i.e. '.example.com'). This logic is // implemented in the dnsConstraints type. // // Email addresses also require some additional logic, which does not make use // of nameConstraintsSet, to handle constraints which define full email // addresses (i.e. 'test@example.com'). For bare domain constraints, we use the // dnsConstraints type described above, querying the domain portion of the email // address. For full email addresses, we also hold a map of email addresses that // map the local portion of the email to the domain. When querying full email // addresses we then check if the local portion of the email is present in the // map, and if so case insensitively compare the domain portion of the // email. type nameConstraintsSet[T *net.IPNet | string, V net.IP | string] struct { set []T } // sortAndPrune sorts the constraints using the provided comparison function, and then // prunes any constraints that are subsets of preceding constraints using the // provided subset function. func (nc *nameConstraintsSet[T, V]) sortAndPrune(cmp func(T, T) int, subset func(T, T) bool) { if len(nc.set) < 2 { return } slices.SortFunc(nc.set, cmp) if len(nc.set) < 2 { return } writeIndex := 1 for readIndex := 1; readIndex < len(nc.set); readIndex++ { if !subset(nc.set[writeIndex-1], nc.set[readIndex]) { nc.set[writeIndex] = nc.set[readIndex] writeIndex++ } } nc.set = nc.set[:writeIndex] } // search does a binary search over the constraints set for the provided value // s, using the provided comparison function cmp to find the lower bound, and // the match function to determine if the found constraint is a prefix of s. If // a matching constraint is found, it is returned along with true. If no // matching constraint is found, the zero value of T and false are returned. func (nc *nameConstraintsSet[T, V]) search(s V, cmp func(T, V) int, match func(T, V) bool) (lowerBound T, exactMatch bool) { if len(nc.set) == 0 { return lowerBound, false } // Look for the lower bound of s in the set. i, found := slices.BinarySearchFunc(nc.set, s, cmp) // If we found an exact match, return it if found { return nc.set[i], true } if i < 0 { return lowerBound, false } var constraint T if i == 0 { constraint = nc.set[0] } else { constraint = nc.set[i-1] } if match(constraint, s) { return constraint, true } return lowerBound, false } func ipNetworkSubset(a, b *net.IPNet) bool { if !a.Contains(b.IP) { return false } broadcast := make(net.IP, len(b.IP)) for i := range b.IP { broadcast[i] = b.IP[i] | (^b.Mask[i]) } return a.Contains(broadcast) } func ipNetworkCompare(a, b *net.IPNet) int { i := bytes.Compare(a.IP, b.IP) if i != 0 { return i } return bytes.Compare(a.Mask, b.Mask) } func ipBinarySearch(constraint *net.IPNet, target net.IP) int { return bytes.Compare(constraint.IP, target) } func ipMatch(constraint *net.IPNet, target net.IP) bool { return constraint.Contains(target) } type ipConstraints struct { // NOTE: we could store IP network prefixes as a pre-processed byte slice // (i.e. by masking the IP) and doing the byte prefix checking using faster // techniques, but this would require allocating new byte slices, which is // likely significantly more expensive than just operating on the // pre-allocated *net.IPNet and net.IP objects directly. ipv4 *nameConstraintsSet[*net.IPNet, net.IP] ipv6 *nameConstraintsSet[*net.IPNet, net.IP] } func newIPNetConstraints(l []*net.IPNet) interface { query(net.IP) (*net.IPNet, bool) } { if len(l) == 0 { return nil } var ipv4, ipv6 []*net.IPNet for _, n := range l { if len(n.IP) == net.IPv4len { ipv4 = append(ipv4, n) } else { ipv6 = append(ipv6, n) } } var v4c, v6c *nameConstraintsSet[*net.IPNet, net.IP] if len(ipv4) > 0 { v4c = &nameConstraintsSet[*net.IPNet, net.IP]{ set: ipv4, } v4c.sortAndPrune(ipNetworkCompare, ipNetworkSubset) } if len(ipv6) > 0 { v6c = &nameConstraintsSet[*net.IPNet, net.IP]{ set: ipv6, } v6c.sortAndPrune(ipNetworkCompare, ipNetworkSubset) } return &ipConstraints{ipv4: v4c, ipv6: v6c} } func (ipc *ipConstraints) query(ip net.IP) (*net.IPNet, bool) { var c *nameConstraintsSet[*net.IPNet, net.IP] if len(ip) == net.IPv4len { c = ipc.ipv4 } else { c = ipc.ipv6 } if c == nil { return nil, false } return c.search(ip, ipBinarySearch, ipMatch) } // dnsHasSuffix case-insensitively checks if DNS name b is a label suffix of DNS // name a, meaning that example.com is not considered a suffix of // testexample.com, but is a suffix of test.example.com. // // dnsHasSuffix supports the URI "leading period" constraint semantics, which // while not explicitly defined for dNSNames in RFC 5280, are widely supported // (see errata 5997). In particular, a constraint of ".example.com" is // considered to only match subdomains of example.com, but not example.com // itself. // // a and b must both be non-empty strings representing (mostly) valid DNS names. func dnsHasSuffix(a, b string) bool { lenA := len(a) lenB := len(b) if lenA > lenB { return false } i := lenA - 1 offset := lenA - lenB for ; i >= 0; i-- { ar, br := a[i], b[i-(offset)] if ar == br { continue } if br < ar { ar, br = br, ar } if 'A' <= ar && ar <= 'Z' && br == ar+'a'-'A' { continue } return false } if a[0] != '.' && lenB > lenA && b[lenB-lenA-1] != '.' { return false } return true } // dnsCompareTable contains the ASCII alphabet mapped from a characters index in // the table to its lowercased form. var dnsCompareTable [256]byte func init() { // NOTE: we don't actually need the // full alphabet, but calculating offsets would be more expensive than just // having redundant characters. for i := 0; i < 256; i++ { c := byte(i) if 'A' <= c && c <= 'Z' { // Lowercase uppercase characters A-Z. c += 'a' - 'A' } dnsCompareTable[i] = c } // Set the period character to 0 so that we get the right sorting behavior. // // In particular, we need the period character to sort before the only // other valid DNS name character which isn't a-z or 0-9, the hyphen, // otherwise a name with a dash would be incorrectly sorted into the middle // of another tree. // // For example, imagine a certificate with the constraints "a.com", "a.a.com", and // "a-a.com". These would sort as "a.com", "a-a.com", "a.a.com", which would break // the pruning step since we wouldn't see that "a.a.com" is a subset of "a.com". // Sorting the period before the hyphen ensures that "a.a.com" sorts before "a-a.com". dnsCompareTable['.'] = 0 } // dnsCompare is a case-insensitive reversed implementation of strings.Compare // that operates from the end to the start of the strings. This is more // efficient that allocating reversed version of a and b and using // strings.Compare directly (even though it is highly optimized). // // NOTE: this function treats the period character ('.') as sorting above every // other character, which is necessary for us to properly sort names into their // correct order. This is further discussed in the init function above. func dnsCompare(a, b string) int { idxA := len(a) - 1 idxB := len(b) - 1 for idxA >= 0 && idxB >= 0 { byteA := dnsCompareTable[a[idxA]] byteB := dnsCompareTable[b[idxB]] if byteA == byteB { idxA-- idxB-- continue } ret := 1 if byteA < byteB { ret = -1 } return ret } ret := 0 if idxA < idxB { ret = -1 } else if idxB < idxA { ret = 1 } return ret } type dnsConstraints struct { // all lets us short circuit the query logic if we see a zero length // constraint which permits or excludes everything. all bool // permitted indicates if these constraints are for permitted or excluded // names. permitted bool constraints *nameConstraintsSet[string, string] // parentConstraints contains a subset of constraints which are used for // wildcard SAN queries, which are constructed by removing the first label // from the constraints in constraints. parentConstraints is only populated // if permitted is false. parentConstraints map[string]string } func newDNSConstraints(l []string, permitted bool) interface{ query(string) (string, bool) } { if len(l) == 0 { return nil } for _, n := range l { if len(n) == 0 { return &dnsConstraints{all: true} } } constraints := slices.Clone(l) nc := &dnsConstraints{ constraints: &nameConstraintsSet[string, string]{ set: constraints, }, permitted: permitted, } nc.constraints.sortAndPrune(dnsCompare, dnsHasSuffix) if !permitted { parentConstraints := map[string]string{} for _, name := range nc.constraints.set { trimmedName := trimFirstLabel(name) if trimmedName == "" { continue } parentConstraints[trimmedName] = name } if len(parentConstraints) > 0 { nc.parentConstraints = parentConstraints } } return nc } func (dnc *dnsConstraints) query(s string) (string, bool) { if dnc.all { return "", true } constraint, match := dnc.constraints.search(s, dnsCompare, dnsHasSuffix) if match { return constraint, true } if !dnc.permitted && s[0] == '*' { trimmed := trimFirstLabel(s) if constraint, found := dnc.parentConstraints[trimmed]; found { return constraint, true } } return "", false } type emailConstraints struct { dnsConstraints interface{ query(string) (string, bool) } fullEmails map[string]string } func newEmailConstraints(l []string, permitted bool) interface { query(parsedEmail) (string, bool) } { if len(l) == 0 { return nil } exactMap := map[string]string{} var domains []string for _, c := range l { if !strings.ContainsRune(c, '@') { domains = append(domains, c) continue } parsed, ok := parseRFC2821Mailbox(c) if !ok { // We've already parsed these addresses in parseCertificate, and // treat failures as a hard failure for parsing. The only way we can // get a parse failure here is if the caller has mutated the // certificate since parsing. continue } exactMap[parsed.local] = parsed.domain } ec := &emailConstraints{ fullEmails: exactMap, } if len(domains) > 0 { ec.dnsConstraints = newDNSConstraints(domains, permitted) } return ec } func (ec *emailConstraints) query(s parsedEmail) (string, bool) { if len(ec.fullEmails) > 0 && strings.ContainsRune(s.email, '@') { if domain, ok := ec.fullEmails[s.mailbox.local]; ok && strings.EqualFold(domain, s.mailbox.domain) { return ec.fullEmails[s.email] + "@" + s.mailbox.domain, true } } if ec.dnsConstraints == nil { return "", false } constraint, found := ec.dnsConstraints.query(s.mailbox.domain) return constraint, found } type constraints[T any, V any] struct { constraintType string permitted interface{ query(V) (T, bool) } excluded interface{ query(V) (T, bool) } } func checkConstraints[T string | *net.IPNet, V any, P string | net.IP | parsedURI | parsedEmail](c constraints[T, V], s V, p P) error { if c.permitted != nil { if _, found := c.permitted.query(s); !found { return fmt.Errorf("%s %q is not permitted by any constraint", c.constraintType, p) } } if c.excluded != nil { if constraint, found := c.excluded.query(s); found { return fmt.Errorf("%s %q is excluded by constraint %q", c.constraintType, p, constraint) } } return nil } type chainConstraints struct { ip constraints[*net.IPNet, net.IP] dns constraints[string, string] uri constraints[string, string] email constraints[string, parsedEmail] index int next *chainConstraints } func (cc *chainConstraints) check(dns []string, uris []parsedURI, emails []parsedEmail, ips []net.IP) error { for _, ip := range ips { if err := checkConstraints(cc.ip, ip, ip); err != nil { return err } } for _, d := range dns { if !domainNameValid(d, false) { return fmt.Errorf("x509: cannot parse dnsName %q", d) } if err := checkConstraints(cc.dns, d, d); err != nil { return err } } for _, u := range uris { if !domainNameValid(u.domain, false) { return fmt.Errorf("x509: internal error: URI SAN %q failed to parse", u) } if err := checkConstraints(cc.uri, u.domain, u); err != nil { return err } } for _, e := range emails { if !domainNameValid(e.mailbox.domain, false) { return fmt.Errorf("x509: cannot parse rfc822Name %q", e.mailbox) } if err := checkConstraints(cc.email, e, e); err != nil { return err } } return nil } func checkChainConstraints(chain []*Certificate) error { var currentConstraints *chainConstraints var last *chainConstraints for i, c := range chain { if !c.hasNameConstraints() { continue } cc := &chainConstraints{ ip: constraints[*net.IPNet, net.IP]{"IP address", newIPNetConstraints(c.PermittedIPRanges), newIPNetConstraints(c.ExcludedIPRanges)}, dns: constraints[string, string]{"DNS name", newDNSConstraints(c.PermittedDNSDomains, true), newDNSConstraints(c.ExcludedDNSDomains, false)}, uri: constraints[string, string]{"URI", newDNSConstraints(c.PermittedURIDomains, true), newDNSConstraints(c.ExcludedURIDomains, false)}, email: constraints[string, parsedEmail]{"email address", newEmailConstraints(c.PermittedEmailAddresses, true), newEmailConstraints(c.ExcludedEmailAddresses, false)}, index: i, } if currentConstraints == nil { currentConstraints = cc last = cc } else if last != nil { last.next = cc last = cc } } if currentConstraints == nil { return nil } for i, c := range chain { if !c.hasSANExtension() { continue } if i >= currentConstraints.index { for currentConstraints.index <= i { if currentConstraints.next == nil { return nil } currentConstraints = currentConstraints.next } } uris, err := parseURIs(c.URIs) if err != nil { return err } emails, err := parseMailboxes(c.EmailAddresses) if err != nil { return err } for n := currentConstraints; n != nil; n = n.next { if err := n.check(c.DNSNames, uris, emails, c.IPAddresses); err != nil { return err } } } return nil } type parsedURI struct { uri *url.URL domain string } func (u parsedURI) String() string { return u.uri.String() } func parseURIs(uris []*url.URL) ([]parsedURI, error) { parsed := make([]parsedURI, 0, len(uris)) for _, uri := range uris { host := strings.ToLower(uri.Host) if len(host) == 0 { return nil, fmt.Errorf("URI with empty host (%q) cannot be matched against constraints", uri.String()) } if strings.Contains(host, ":") && !strings.HasSuffix(host, "]") { var err error host, _, err = net.SplitHostPort(uri.Host) if err != nil { return nil, fmt.Errorf("cannot parse URI host %q: %v", uri.Host, err) } } // netip.ParseAddr will reject the URI IPv6 literal form "[...]", so we // check if _either_ the string parses as an IP, or if it is enclosed in // square brackets. if _, err := netip.ParseAddr(host); err == nil || (strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]")) { return nil, fmt.Errorf("URI with IP (%q) cannot be matched against constraints", uri.String()) } parsed = append(parsed, parsedURI{uri, host}) } return parsed, nil } type parsedEmail struct { email string mailbox *rfc2821Mailbox } func (e parsedEmail) String() string { return e.mailbox.local + "@" + e.mailbox.domain } func parseMailboxes(emails []string) ([]parsedEmail, error) { parsed := make([]parsedEmail, 0, len(emails)) for _, email := range emails { mailbox, ok := parseRFC2821Mailbox(email) if !ok { return nil, fmt.Errorf("cannot parse rfc822Name %q", email) } mailbox.domain = strings.ToLower(mailbox.domain) parsed = append(parsed, parsedEmail{strings.ToLower(email), &mailbox}) } return parsed, nil } func trimFirstLabel(dnsName string) string { firstDotInd := strings.IndexByte(dnsName, '.') if firstDotInd < 0 { // Constraint is a single label, we cannot trim it. return "" } return dnsName[firstDotInd:] }