domain_matcher.go raw

   1  package http01
   2  
   3  import (
   4  	"fmt"
   5  	"net/http"
   6  	"net/netip"
   7  	"strings"
   8  )
   9  
  10  // A domainMatcher tries to match a domain (the one we're requesting a certificate for)
  11  // in the HTTP request coming from the ACME validation servers.
  12  // This step is part of DNS rebind attack prevention,
  13  // where the webserver matches incoming requests to a list of domain the server acts authoritative for.
  14  //
  15  // The most simple check involves finding the domain in the HTTP Host header;
  16  // this is what hostMatcher does.
  17  // Use it, when the http01.ProviderServer is directly reachable from the internet,
  18  // or when it operates behind a transparent proxy.
  19  //
  20  // In many (reverse) proxy setups, Apache and NGINX traditionally move the Host header to a new header named X-Forwarded-Host.
  21  // Use arbitraryMatcher("X-Forwarded-Host") in this case,
  22  // or the appropriate header name for other proxy servers.
  23  //
  24  // RFC7239 has standardized the different forwarding headers into a single header named Forwarded.
  25  // The header value has a different format, so you should use forwardedMatcher
  26  // when the http01.ProviderServer operates behind a RFC7239 compatible proxy.
  27  // https://www.rfc-editor.org/rfc/rfc7239.html
  28  //
  29  // Note: RFC7239 also reminds us, "that an HTTP list [...] may be split over multiple header fields" (section 7.1),
  30  // meaning that
  31  //
  32  //	X-Header: a
  33  //	X-Header: b
  34  //
  35  // is equal to
  36  //
  37  //	X-Header: a, b
  38  //
  39  // All matcher implementations (explicitly not excluding arbitraryMatcher!)
  40  // have in common that they only match against the first value in such lists.
  41  type domainMatcher interface {
  42  	// matches checks whether the request is valid for the given domain.
  43  	matches(request *http.Request, domain string) bool
  44  
  45  	// name returns the header name used in the check.
  46  	// This is primarily used to create meaningful error messages.
  47  	name() string
  48  }
  49  
  50  // hostMatcher checks whether (*net/http).Request.Host starts with a domain name.
  51  type hostMatcher struct{}
  52  
  53  func (m *hostMatcher) name() string {
  54  	return "Host"
  55  }
  56  
  57  func (m *hostMatcher) matches(r *http.Request, domain string) bool {
  58  	return matchDomain(r.Host, domain)
  59  }
  60  
  61  // arbitraryMatcher checks whether the specified (*net/http.Request).Header value starts with a domain name.
  62  type arbitraryMatcher string
  63  
  64  func (m arbitraryMatcher) name() string {
  65  	return string(m)
  66  }
  67  
  68  func (m arbitraryMatcher) matches(r *http.Request, domain string) bool {
  69  	return matchDomain(r.Header.Get(m.name()), domain)
  70  }
  71  
  72  // forwardedMatcher checks whether the Forwarded header contains a "host" element starting with a domain name.
  73  // See https://www.rfc-editor.org/rfc/rfc7239.html for details.
  74  type forwardedMatcher struct{}
  75  
  76  func (m *forwardedMatcher) name() string {
  77  	return "Forwarded"
  78  }
  79  
  80  func (m *forwardedMatcher) matches(r *http.Request, domain string) bool {
  81  	fwds, err := parseForwardedHeader(r.Header.Get(m.name()))
  82  	if err != nil {
  83  		return false
  84  	}
  85  
  86  	if len(fwds) == 0 {
  87  		return false
  88  	}
  89  
  90  	host := fwds[0]["host"]
  91  
  92  	return matchDomain(host, domain)
  93  }
  94  
  95  // parsing requires some form of state machine.
  96  func parseForwardedHeader(s string) (elements []map[string]string, err error) {
  97  	cur := make(map[string]string)
  98  	key := ""
  99  	val := ""
 100  	inquote := false
 101  
 102  	pos := 0
 103  
 104  	l := len(s)
 105  	for i := 0; i < l; i++ {
 106  		r := rune(s[i])
 107  
 108  		if inquote {
 109  			if r == '"' {
 110  				cur[key] = s[pos:i]
 111  				key = ""
 112  				pos = i
 113  				inquote = false
 114  			}
 115  
 116  			continue
 117  		}
 118  
 119  		switch {
 120  		case r == '"': // start of quoted-string
 121  			if key == "" {
 122  				return nil, fmt.Errorf("unexpected quoted string as pos %d", i)
 123  			}
 124  
 125  			inquote = true
 126  			pos = i + 1
 127  
 128  		case r == ';': // end of forwarded-pair
 129  			cur[key] = s[pos:i]
 130  			key = ""
 131  			i = skipWS(s, i)
 132  			pos = i + 1
 133  
 134  		case r == '=': // end of token
 135  			key = strings.ToLower(strings.TrimFunc(s[pos:i], isWS))
 136  			i = skipWS(s, i)
 137  			pos = i + 1
 138  
 139  		case r == ',': // end of forwarded-element
 140  			if key != "" {
 141  				val = s[pos:i]
 142  				cur[key] = val
 143  			}
 144  
 145  			elements = append(elements, cur)
 146  			cur = make(map[string]string)
 147  			key = ""
 148  			val = ""
 149  
 150  			i = skipWS(s, i)
 151  			pos = i + 1
 152  		case tchar(r) || isWS(r): // valid token character or whitespace
 153  			continue
 154  		default:
 155  			return nil, fmt.Errorf("invalid token character at pos %d: %c", i, r)
 156  		}
 157  	}
 158  
 159  	if inquote {
 160  		return nil, fmt.Errorf("unterminated quoted-string at pos %d", len(s))
 161  	}
 162  
 163  	if key != "" {
 164  		if pos < len(s) {
 165  			val = s[pos:]
 166  		}
 167  
 168  		cur[key] = val
 169  	}
 170  
 171  	if len(cur) > 0 {
 172  		elements = append(elements, cur)
 173  	}
 174  
 175  	return elements, nil
 176  }
 177  
 178  func tchar(r rune) bool {
 179  	return strings.ContainsRune("!#$%&'*+-.^_`|~", r) ||
 180  		'0' <= r && r <= '9' ||
 181  		'a' <= r && r <= 'z' ||
 182  		'A' <= r && r <= 'Z'
 183  }
 184  
 185  func skipWS(s string, i int) int {
 186  	for isWS(rune(s[i+1])) {
 187  		i++
 188  	}
 189  
 190  	return i
 191  }
 192  
 193  func isWS(r rune) bool {
 194  	return strings.ContainsRune(" \t\v\r\n", r)
 195  }
 196  
 197  func matchDomain(src, domain string) bool {
 198  	addr, err := netip.ParseAddr(domain)
 199  	if err == nil && addr.Is6() {
 200  		domain = "[" + domain + "]"
 201  	}
 202  
 203  	return strings.HasPrefix(src, domain)
 204  }
 205