forked from TrueCloudLab/lego
feat: ease operation behind proxy servers (#974)
This commit is contained in:
parent
82778cf77c
commit
f69cd8d63d
8 changed files with 565 additions and 20 deletions
|
@ -58,6 +58,15 @@
|
||||||
[[issues.exclude-rules]]
|
[[issues.exclude-rules]]
|
||||||
path = "challenge/dns01/nameserver.go"
|
path = "challenge/dns01/nameserver.go"
|
||||||
text = "`(defaultNameservers|recursiveNameservers|dnsTimeout|fqdnToZone|muFqdnToZone)` is a global variable"
|
text = "`(defaultNameservers|recursiveNameservers|dnsTimeout|fqdnToZone|muFqdnToZone)` is a global variable"
|
||||||
|
[[issues.exclude-rules]]
|
||||||
|
path = "challenge/http01/domain_matcher.go"
|
||||||
|
text = "string `Host` has \\d occurrences, make it a constant"
|
||||||
|
[[issues.exclude-rules]]
|
||||||
|
path = "challenge/http01/domain_matcher.go"
|
||||||
|
text = "cyclomatic complexity \\d+ of func `parseForwardedHeader` is high"
|
||||||
|
[[issues.exclude-rules]]
|
||||||
|
path = "challenge/http01/domain_matcher.go"
|
||||||
|
text = "Function 'parseForwardedHeader' has too many statements"
|
||||||
[[issues.exclude-rules]]
|
[[issues.exclude-rules]]
|
||||||
path = "challenge/tlsalpn01/tls_alpn_challenge.go"
|
path = "challenge/tlsalpn01/tls_alpn_challenge.go"
|
||||||
text = "`idPeAcmeIdentifierV1` is a global variable"
|
text = "`idPeAcmeIdentifierV1` is a global variable"
|
||||||
|
|
184
challenge/http01/domain_matcher.go
Normal file
184
challenge/http01/domain_matcher.go
Normal file
|
@ -0,0 +1,184 @@
|
||||||
|
package http01
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A domainMatcher tries to match a domain (the one we're requesting a certificate for)
|
||||||
|
// in the HTTP request coming from the ACME validation servers.
|
||||||
|
// This step is part of DNS rebind attack prevention,
|
||||||
|
// where the webserver matches incoming requests to a list of domain the server acts authoritative for.
|
||||||
|
//
|
||||||
|
// The most simple check involves finding the domain in the HTTP Host header;
|
||||||
|
// this is what hostMatcher does.
|
||||||
|
// Use it, when the http01.ProviderServer is directly reachable from the internet,
|
||||||
|
// or when it operates behind a transparent proxy.
|
||||||
|
//
|
||||||
|
// In many (reverse) proxy setups, Apache and NGINX traditionally move the Host header to a new header named X-Forwarded-Host.
|
||||||
|
// Use arbitraryMatcher("X-Forwarded-Host") in this case,
|
||||||
|
// or the appropriate header name for other proxy servers.
|
||||||
|
//
|
||||||
|
// RFC7239 has standardized the different forwarding headers into a single header named Forwarded.
|
||||||
|
// The header value has a different format, so you should use forwardedMatcher
|
||||||
|
// when the http01.ProviderServer operates behind a RFC7239 compatible proxy.
|
||||||
|
// https://tools.ietf.org/html/rfc7239
|
||||||
|
//
|
||||||
|
// Note: RFC7239 also reminds us, "that an HTTP list [...] may be split over multiple header fields" (section 7.1),
|
||||||
|
// meaning that
|
||||||
|
// X-Header: a
|
||||||
|
// X-Header: b
|
||||||
|
// is equal to
|
||||||
|
// X-Header: a, b
|
||||||
|
//
|
||||||
|
// All matcher implementations (explicitly not excluding arbitraryMatcher!)
|
||||||
|
// have in common that they only match against the first value in such lists.
|
||||||
|
type domainMatcher interface {
|
||||||
|
// matches checks whether the request is valid for the given domain.
|
||||||
|
matches(request *http.Request, domain string) bool
|
||||||
|
|
||||||
|
// name returns the header name used in the check.
|
||||||
|
// This is primarily used to create meaningful error messages.
|
||||||
|
name() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostMatcher checks whether (*net/http).Request.Host starts with a domain name.
|
||||||
|
type hostMatcher struct{}
|
||||||
|
|
||||||
|
func (m *hostMatcher) name() string {
|
||||||
|
return "Host"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *hostMatcher) matches(r *http.Request, domain string) bool {
|
||||||
|
return strings.HasPrefix(r.Host, domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostMatcher checks whether the specified (*net/http.Request).Header value starts with a domain name.
|
||||||
|
type arbitraryMatcher string
|
||||||
|
|
||||||
|
func (m arbitraryMatcher) name() string {
|
||||||
|
return string(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m arbitraryMatcher) matches(r *http.Request, domain string) bool {
|
||||||
|
return strings.HasPrefix(r.Header.Get(m.name()), domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardedMatcher checks whether the Forwarded header contains a "host" element starting with a domain name.
|
||||||
|
// See https://tools.ietf.org/html/rfc7239 for details.
|
||||||
|
type forwardedMatcher struct{}
|
||||||
|
|
||||||
|
func (m *forwardedMatcher) name() string {
|
||||||
|
return "Forwarded"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *forwardedMatcher) matches(r *http.Request, domain string) bool {
|
||||||
|
fwds, err := parseForwardedHeader(r.Header.Get(m.name()))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(fwds) == 0 {
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
host := fwds[0]["host"]
|
||||||
|
return strings.HasPrefix(host, domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsing requires some form of state machine
|
||||||
|
func parseForwardedHeader(s string) (elements []map[string]string, err error) {
|
||||||
|
cur := make(map[string]string)
|
||||||
|
key := ""
|
||||||
|
val := ""
|
||||||
|
inquote := false
|
||||||
|
|
||||||
|
pos := 0
|
||||||
|
l := len(s)
|
||||||
|
for i := 0; i < l; i++ {
|
||||||
|
r := rune(s[i])
|
||||||
|
|
||||||
|
if inquote {
|
||||||
|
if r == '"' {
|
||||||
|
cur[key] = s[pos:i]
|
||||||
|
key = ""
|
||||||
|
pos = i
|
||||||
|
inquote = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case r == '"': // start of quoted-string
|
||||||
|
if key == "" {
|
||||||
|
return nil, fmt.Errorf("unexpected quoted string as pos %d", i)
|
||||||
|
}
|
||||||
|
inquote = true
|
||||||
|
pos = i + 1
|
||||||
|
|
||||||
|
case r == ';': // end of forwarded-pair
|
||||||
|
cur[key] = s[pos:i]
|
||||||
|
key = ""
|
||||||
|
i = skipWS(s, i)
|
||||||
|
pos = i + 1
|
||||||
|
|
||||||
|
case r == '=': // end of token
|
||||||
|
key = strings.ToLower(strings.TrimFunc(s[pos:i], isWS))
|
||||||
|
i = skipWS(s, i)
|
||||||
|
pos = i + 1
|
||||||
|
|
||||||
|
case r == ',': // end of forwarded-element
|
||||||
|
if key != "" {
|
||||||
|
if val == "" {
|
||||||
|
val = s[pos:i]
|
||||||
|
}
|
||||||
|
cur[key] = val
|
||||||
|
}
|
||||||
|
elements = append(elements, cur)
|
||||||
|
cur = make(map[string]string)
|
||||||
|
key = ""
|
||||||
|
val = ""
|
||||||
|
|
||||||
|
i = skipWS(s, i)
|
||||||
|
pos = i + 1
|
||||||
|
case tchar(r) || isWS(r): // valid token character or whitespace
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid token character at pos %d: %c", i, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if inquote {
|
||||||
|
return nil, fmt.Errorf("unterminated quoted-string at pos %d", len(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
if key != "" {
|
||||||
|
if pos < len(s) {
|
||||||
|
val = s[pos:]
|
||||||
|
}
|
||||||
|
cur[key] = val
|
||||||
|
}
|
||||||
|
if len(cur) > 0 {
|
||||||
|
elements = append(elements, cur)
|
||||||
|
}
|
||||||
|
return elements, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tchar(r rune) bool {
|
||||||
|
return strings.ContainsRune("!#$%&'*+-.^_`|~", r) ||
|
||||||
|
'0' <= r && r <= '9' ||
|
||||||
|
'a' <= r && r <= 'z' ||
|
||||||
|
'A' <= r && r <= 'Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
func skipWS(s string, i int) int {
|
||||||
|
for isWS(rune(s[i+1])) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWS(r rune) bool {
|
||||||
|
return strings.ContainsRune(" \t\v\r\n", r)
|
||||||
|
}
|
86
challenge/http01/domain_matcher_test.go
Normal file
86
challenge/http01/domain_matcher_test.go
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
package http01
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseForwardedHeader(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want []map[string]string
|
||||||
|
err string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty input",
|
||||||
|
input: "",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple case",
|
||||||
|
input: `for=1.2.3.4;host=example.com; by=127.0.0.1`,
|
||||||
|
want: []map[string]string{
|
||||||
|
{"for": "1.2.3.4", "host": "example.com", "by": "127.0.0.1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quoted-string",
|
||||||
|
input: `foo="bar"`,
|
||||||
|
want: []map[string]string{
|
||||||
|
{"foo": "bar"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple entries",
|
||||||
|
input: `a=1, b=2; c=3, d=4`,
|
||||||
|
want: []map[string]string{
|
||||||
|
{"a": "1"},
|
||||||
|
{"b": "2", "c": "3"},
|
||||||
|
{"d": "4"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace",
|
||||||
|
input: " a = 1,\tb\n=\r\n2,c=\" untrimmed \"",
|
||||||
|
want: []map[string]string{
|
||||||
|
{"a": "1"},
|
||||||
|
{"b": "2"},
|
||||||
|
{"c": " untrimmed "},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unterminated quote",
|
||||||
|
input: `x="y`,
|
||||||
|
err: "unterminated quoted-string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unexpected quote",
|
||||||
|
input: `"x=y"`,
|
||||||
|
err: "unexpected quote",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid token",
|
||||||
|
input: `a=b, ipv6=[fe80::1], x=y`,
|
||||||
|
err: "invalid token character at pos 10: [",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range testCases {
|
||||||
|
test := test
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
actual, err := parseForwardedHeader(test.input)
|
||||||
|
if test.err == "" {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, test.want, actual)
|
||||||
|
} else {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), test.err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-acme/lego/v3/log"
|
"github.com/go-acme/lego/v3/log"
|
||||||
|
@ -15,6 +16,7 @@ import (
|
||||||
type ProviderServer struct {
|
type ProviderServer struct {
|
||||||
iface string
|
iface string
|
||||||
port string
|
port string
|
||||||
|
matcher domainMatcher
|
||||||
done chan bool
|
done chan bool
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
}
|
}
|
||||||
|
@ -23,15 +25,15 @@ type ProviderServer struct {
|
||||||
// Setting iface and / or port to an empty string will make the server fall back to
|
// Setting iface and / or port to an empty string will make the server fall back to
|
||||||
// the "any" interface and port 80 respectively.
|
// the "any" interface and port 80 respectively.
|
||||||
func NewProviderServer(iface, port string) *ProviderServer {
|
func NewProviderServer(iface, port string) *ProviderServer {
|
||||||
return &ProviderServer{iface: iface, port: port}
|
if port == "" {
|
||||||
|
port = "80"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ProviderServer{iface: iface, port: port, matcher: &hostMatcher{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Present starts a web server and makes the token available at `ChallengePath(token)` for web requests.
|
// Present starts a web server and makes the token available at `ChallengePath(token)` for web requests.
|
||||||
func (s *ProviderServer) Present(domain, token, keyAuth string) error {
|
func (s *ProviderServer) Present(domain, token, keyAuth string) error {
|
||||||
if s.port == "" {
|
|
||||||
s.port = "80"
|
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
s.listener, err = net.Listen("tcp", s.GetAddress())
|
s.listener, err = net.Listen("tcp", s.GetAddress())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -57,14 +59,38 @@ func (s *ProviderServer) CleanUp(domain, token, keyAuth string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetProxyHeader changes the validation of incoming requests.
|
||||||
|
// By default, s matches the "Host" header value to the domain name.
|
||||||
|
//
|
||||||
|
// When the server runs behind a proxy server, this is not the correct place to look at;
|
||||||
|
// Apache and NGINX have traditionally moved the original Host header into a new header named "X-Forwarded-Host".
|
||||||
|
// Other webservers might use different names;
|
||||||
|
// and RFC7239 has standadized a new header named "Forwarded" (with slightly different semantics).
|
||||||
|
//
|
||||||
|
// The exact behavior depends on the value of headerName:
|
||||||
|
// - "" (the empty string) and "Host" will restore the default and only check the Host header
|
||||||
|
// - "Forwarded" will look for a Forwarded header, and inspect it according to https://tools.ietf.org/html/rfc7239
|
||||||
|
// - any other value will check the header value with the same name
|
||||||
|
func (s *ProviderServer) SetProxyHeader(headerName string) {
|
||||||
|
switch h := textproto.CanonicalMIMEHeaderKey(headerName); h {
|
||||||
|
case "", "Host":
|
||||||
|
s.matcher = &hostMatcher{}
|
||||||
|
case "Forwarded":
|
||||||
|
s.matcher = &forwardedMatcher{}
|
||||||
|
default:
|
||||||
|
s.matcher = arbitraryMatcher(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ProviderServer) serve(domain, token, keyAuth string) {
|
func (s *ProviderServer) serve(domain, token, keyAuth string) {
|
||||||
path := ChallengePath(token)
|
path := ChallengePath(token)
|
||||||
|
|
||||||
// The handler validates the HOST header and request type.
|
// The incoming request must will be validated to prevent DNS rebind attacks.
|
||||||
// For validation it then writes the token the server returned with the challenge
|
// We only respond with the keyAuth, when we're receiving a GET requests with
|
||||||
|
// the "Host" header matching the domain (the latter is configurable though SetProxyHeader).
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.HasPrefix(r.Host, domain) && r.Method == http.MethodGet {
|
if r.Method == http.MethodGet && s.matcher.matches(r, domain) {
|
||||||
w.Header().Add("Content-Type", "text/plain")
|
w.Header().Add("Content-Type", "text/plain")
|
||||||
_, err := w.Write([]byte(keyAuth))
|
_, err := w.Write([]byte(keyAuth))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -73,7 +99,7 @@ func (s *ProviderServer) serve(domain, token, keyAuth string) {
|
||||||
}
|
}
|
||||||
log.Infof("[%s] Served key authentication", domain)
|
log.Infof("[%s] Served key authentication", domain)
|
||||||
} else {
|
} else {
|
||||||
log.Warnf("Received request for domain %s with method %s but the domain did not match any challenge. Please ensure your are passing the HOST header properly.", r.Host, r.Method)
|
log.Warnf("Received request for domain %s with method %s but the domain did not match any challenge. Please ensure your are passing the %s header properly.", r.Host, r.Method, s.matcher.name())
|
||||||
_, err := w.Write([]byte("TEST"))
|
_, err := w.Write([]byte("TEST"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
|
@ -3,8 +3,10 @@ package http01
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/go-acme/lego/v3/acme"
|
"github.com/go-acme/lego/v3/acme"
|
||||||
|
@ -19,7 +21,7 @@ func TestChallenge(t *testing.T) {
|
||||||
_, apiURL, tearDown := tester.SetupFakeAPI()
|
_, apiURL, tearDown := tester.SetupFakeAPI()
|
||||||
defer tearDown()
|
defer tearDown()
|
||||||
|
|
||||||
providerServer := &ProviderServer{port: "23457"}
|
providerServer := NewProviderServer("", "23457")
|
||||||
|
|
||||||
validate := func(_ *api.Core, _ string, chlng acme.Challenge) error {
|
validate := func(_ *api.Core, _ string, chlng acme.Challenge) error {
|
||||||
uri := "http://localhost" + providerServer.GetAddress() + ChallengePath(chlng.Token)
|
uri := "http://localhost" + providerServer.GetAddress() + ChallengePath(chlng.Token)
|
||||||
|
@ -80,7 +82,7 @@ func TestChallengeInvalidPort(t *testing.T) {
|
||||||
|
|
||||||
validate := func(_ *api.Core, _ string, _ acme.Challenge) error { return nil }
|
validate := func(_ *api.Core, _ string, _ acme.Challenge) error { return nil }
|
||||||
|
|
||||||
solver := NewChallenge(core, validate, &ProviderServer{port: "123456"})
|
solver := NewChallenge(core, validate, NewProviderServer("", "123456"))
|
||||||
|
|
||||||
authz := acme.Authorization{
|
authz := acme.Authorization{
|
||||||
Identifier: acme.Identifier{
|
Identifier: acme.Identifier{
|
||||||
|
@ -96,3 +98,225 @@ func TestChallengeInvalidPort(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "invalid port")
|
assert.Contains(t, err.Error(), "invalid port")
|
||||||
assert.Contains(t, err.Error(), "123456")
|
assert.Contains(t, err.Error(), "123456")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testProxyHeader struct {
|
||||||
|
name string
|
||||||
|
values []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *testProxyHeader) update(r *http.Request) {
|
||||||
|
if h == nil || len(h.values) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.name == "Host" {
|
||||||
|
r.Host = h.values[0]
|
||||||
|
} else if h.name != "" {
|
||||||
|
r.Header[h.name] = h.values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChallengeWithProxy(t *testing.T) {
|
||||||
|
h := func(name string, values ...string) *testProxyHeader {
|
||||||
|
name = textproto.CanonicalMIMEHeaderKey(name)
|
||||||
|
return &testProxyHeader{name, values}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ok = "localhost:23457"
|
||||||
|
nook = "example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
var testCases = []struct {
|
||||||
|
name string
|
||||||
|
header *testProxyHeader
|
||||||
|
extra *testProxyHeader
|
||||||
|
isErr bool
|
||||||
|
}{
|
||||||
|
// tests for hostMatcher
|
||||||
|
{
|
||||||
|
name: "no proxy",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
header: h(""),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty Host",
|
||||||
|
header: h("host"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matching Host",
|
||||||
|
header: h("host", ok),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Host mismatch",
|
||||||
|
header: h("host", nook),
|
||||||
|
isErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Host mismatch (ignoring forwarding header)",
|
||||||
|
header: h("host", nook),
|
||||||
|
extra: h("X-Forwarded-Host", ok),
|
||||||
|
isErr: true,
|
||||||
|
},
|
||||||
|
// test for arbitraryMatcher
|
||||||
|
{
|
||||||
|
name: "matching X-Forwarded-Host",
|
||||||
|
header: h("X-Forwarded-Host", ok),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matching X-Forwarded-Host (multiple fields)",
|
||||||
|
header: h("X-Forwarded-Host", ok, nook),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matching X-Forwarded-Host (chain value)",
|
||||||
|
header: h("X-Forwarded-Host", ok+", "+nook),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "X-Forwarded-Host mismatch",
|
||||||
|
header: h("X-Forwarded-Host", nook),
|
||||||
|
extra: h("host", ok),
|
||||||
|
isErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "X-Forwarded-Host mismatch (multiple fields)",
|
||||||
|
header: h("X-Forwarded-Host", nook, ok),
|
||||||
|
isErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matching X-Something-Else",
|
||||||
|
header: h("X-Something-Else", ok),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matching X-Something-Else (multiple fields)",
|
||||||
|
header: h("X-Something-Else", ok, nook),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matching X-Something-Else (chain value)",
|
||||||
|
header: h("X-Something-Else", ok+", "+nook),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "X-Something-Else mismatch",
|
||||||
|
header: h("X-Something-Else", nook),
|
||||||
|
isErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "X-Something-Else mismatch (multiple fields)",
|
||||||
|
header: h("X-Something-Else", nook, ok),
|
||||||
|
isErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "X-Something-Else mismatch (chain value)",
|
||||||
|
header: h("X-Something-Else", nook+", "+ok),
|
||||||
|
isErr: true,
|
||||||
|
},
|
||||||
|
// tests for forwardedHeader
|
||||||
|
{
|
||||||
|
name: "matching Forwarded",
|
||||||
|
header: h("Forwarded", fmt.Sprintf("host=%q;foo=bar", ok)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matching Forwarded (multiple fields)",
|
||||||
|
header: h("Forwarded", fmt.Sprintf("host=%q", ok), "host="+nook),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "matching Forwarded (chain value)",
|
||||||
|
header: h("Forwarded", fmt.Sprintf("host=%q, host=%s", ok, nook)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Forwarded mismatch",
|
||||||
|
header: h("Forwarded", "host="+nook),
|
||||||
|
isErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Forwarded mismatch (missing information)",
|
||||||
|
header: h("Forwarded", "for=127.0.0.1"),
|
||||||
|
isErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Forwarded mismatch (multiple fields)",
|
||||||
|
header: h("Forwarded", "host="+nook, fmt.Sprintf("host=%q", ok)),
|
||||||
|
isErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Forwarded mismatch (chain value)",
|
||||||
|
header: h("Forwarded", fmt.Sprintf("host=%s, host=%q", nook, ok)),
|
||||||
|
isErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range testCases {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
testServeWithProxy(t, test.header, test.extra, test.isErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testServeWithProxy(t *testing.T, header, extra *testProxyHeader, expectError bool) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, apiURL, tearDown := tester.SetupFakeAPI()
|
||||||
|
defer tearDown()
|
||||||
|
|
||||||
|
providerServer := NewProviderServer("localhost", "23457")
|
||||||
|
if header != nil {
|
||||||
|
providerServer.SetProxyHeader(header.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
validate := func(_ *api.Core, _ string, chlng acme.Challenge) error {
|
||||||
|
uri := "http://" + providerServer.GetAddress() + ChallengePath(chlng.Token)
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, uri, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header.update(req)
|
||||||
|
extra.update(req)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if want := "text/plain"; resp.Header.Get("Content-Type") != want {
|
||||||
|
return fmt.Errorf("Get(%q) Content-Type: got %q, want %q", uri, resp.Header.Get("Content-Type"), want)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bodyStr := string(body)
|
||||||
|
|
||||||
|
if bodyStr != chlng.KeyAuthorization {
|
||||||
|
return fmt.Errorf("Get(%q) Body: got %q, want %q", uri, bodyStr, chlng.KeyAuthorization)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
|
||||||
|
require.NoError(t, err, "Could not generate test key")
|
||||||
|
|
||||||
|
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", privateKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
solver := NewChallenge(core, validate, providerServer)
|
||||||
|
|
||||||
|
authz := acme.Authorization{
|
||||||
|
Identifier: acme.Identifier{
|
||||||
|
Value: "localhost:23457",
|
||||||
|
},
|
||||||
|
Challenges: []acme.Challenge{
|
||||||
|
{Type: challenge.HTTP01.String(), Token: "http1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = solver.Solve(authz)
|
||||||
|
if expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -63,6 +63,11 @@ func CreateFlags(defaultPath string) []cli.Flag {
|
||||||
Usage: "Set the port and interface to use for HTTP based challenges to listen on.Supported: interface:port or :port.",
|
Usage: "Set the port and interface to use for HTTP based challenges to listen on.Supported: interface:port or :port.",
|
||||||
Value: ":80",
|
Value: ":80",
|
||||||
},
|
},
|
||||||
|
cli.StringFlag{
|
||||||
|
Name: "http.proxy-header",
|
||||||
|
Usage: "Validate against this HTTP header when solving HTTP based challenges behind a reverse proxy.",
|
||||||
|
Value: "Host",
|
||||||
|
},
|
||||||
cli.StringFlag{
|
cli.StringFlag{
|
||||||
Name: "http.webroot",
|
Name: "http.webroot",
|
||||||
Usage: "Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge.",
|
Usage: "Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge.",
|
||||||
|
|
|
@ -66,9 +66,17 @@ func setupHTTPProvider(ctx *cli.Context) challenge.Provider {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return http01.NewProviderServer(host, port)
|
srv := http01.NewProviderServer(host, port)
|
||||||
|
if header := ctx.GlobalString("http.proxy-header"); header != "" {
|
||||||
|
srv.SetProxyHeader(header)
|
||||||
|
}
|
||||||
|
return srv
|
||||||
case ctx.GlobalBool("http"):
|
case ctx.GlobalBool("http"):
|
||||||
return http01.NewProviderServer("", "")
|
srv := http01.NewProviderServer("", "")
|
||||||
|
if header := ctx.GlobalString("http.proxy-header"); header != "" {
|
||||||
|
srv.SetProxyHeader(header)
|
||||||
|
}
|
||||||
|
return srv
|
||||||
default:
|
default:
|
||||||
log.Fatal("Invalid HTTP challenge options.")
|
log.Fatal("Invalid HTTP challenge options.")
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -19,12 +19,12 @@ USAGE:
|
||||||
lego [global options] command [command options] [arguments...]
|
lego [global options] command [command options] [arguments...]
|
||||||
|
|
||||||
COMMANDS:
|
COMMANDS:
|
||||||
run Register an account, then create and install a certificate
|
run Register an account, then create and install a certificate
|
||||||
revoke Revoke a certificate
|
revoke Revoke a certificate
|
||||||
renew Renew a certificate
|
renew Renew a certificate
|
||||||
dnshelp Shows additional help for the '--dns' global option
|
dnshelp Shows additional help for the '--dns' global option
|
||||||
list Display certificates and accounts information.
|
list Display certificates and accounts information.
|
||||||
help, h Shows a list of commands or help for one command
|
help, h Shows a list of commands or help for one command
|
||||||
|
|
||||||
GLOBAL OPTIONS:
|
GLOBAL OPTIONS:
|
||||||
--domains value, -d value Add a domain to the process. Can be specified multiple times.
|
--domains value, -d value Add a domain to the process. Can be specified multiple times.
|
||||||
|
@ -40,6 +40,7 @@ GLOBAL OPTIONS:
|
||||||
--path value Directory to use for storing the data. (default: "./.lego")
|
--path value Directory to use for storing the data. (default: "./.lego")
|
||||||
--http Use the HTTP challenge to solve challenges. Can be mixed with other types of challenges.
|
--http Use the HTTP challenge to solve challenges. Can be mixed with other types of challenges.
|
||||||
--http.port value Set the port and interface to use for HTTP based challenges to listen on.Supported: interface:port or :port. (default: ":80")
|
--http.port value Set the port and interface to use for HTTP based challenges to listen on.Supported: interface:port or :port. (default: ":80")
|
||||||
|
--http.proxy-header value Validate against this HTTP header when solving HTTP based challenges behind a reverse proxy. (default: "Host")
|
||||||
--http.webroot value Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge.
|
--http.webroot value Set the webroot folder to use for HTTP based challenges to write directly in a file in .well-known/acme-challenge.
|
||||||
--http.memcached-host value Set the memcached host(s) to use for HTTP based challenges. Challenges will be written to all specified hosts.
|
--http.memcached-host value Set the memcached host(s) to use for HTTP based challenges. Challenges will be written to all specified hosts.
|
||||||
--tls Use the TLS challenge to solve challenges. Can be mixed with other types of challenges.
|
--tls Use the TLS challenge to solve challenges. Can be mixed with other types of challenges.
|
||||||
|
@ -87,8 +88,10 @@ lego to listen on that interface:port for any incoming challenges.
|
||||||
|
|
||||||
If you are using this option, make sure you proxy all of the following traffic to these ports.
|
If you are using this option, make sure you proxy all of the following traffic to these ports.
|
||||||
|
|
||||||
**HTTP Port:** All plaintext HTTP requests to port **80** which begin with a request path of `/.well-known/acme-challenge/` for the HTTP challenge.
|
**HTTP Port:** All plaintext HTTP requests to port **80** which begin with a request path of `/.well-known/acme-challenge/` for the HTTP challenge.[^header]
|
||||||
|
|
||||||
**TLS Port:** All TLS handshakes on port **443** for the TLS-ALPN challenge.
|
**TLS Port:** All TLS handshakes on port **443** for the TLS-ALPN challenge.
|
||||||
|
|
||||||
This traffic redirection is only needed as long as lego solves challenges. As soon as you have received your certificates you can deactivate the forwarding.
|
This traffic redirection is only needed as long as lego solves challenges. As soon as you have received your certificates you can deactivate the forwarding.
|
||||||
|
|
||||||
|
[^header]: You must ensure that incoming validation requests containt the correct value for the HTTP `Host` header. If you operate lego behind a non-transparent reverse proxy (such as Apache or NGINX), you might need to alter the header field using `--http.proxy-header X-Forwarded-Host`.
|
||||||
|
|
Loading…
Reference in a new issue