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