Skip to content

Commit 167015d

Browse files
committed
Implement distributed HTTP solver for ZeroSSL
1 parent aa4d957 commit 167015d

File tree

3 files changed

+138
-36
lines changed

3 files changed

+138
-36
lines changed

httphandler.go httphandlers.go

+91-3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package certmagic
1616

1717
import (
1818
"net/http"
19+
"net/url"
1920
"strings"
2021

2122
"github.com/mholt/acmez/v2/acme"
@@ -91,7 +92,7 @@ func solveHTTPChallenge(logger *zap.Logger, w http.ResponseWriter, r *http.Reque
9192
challengeReqPath := challenge.HTTP01ResourcePath()
9293
if r.URL.Path == challengeReqPath &&
9394
strings.EqualFold(hostOnly(r.Host), challenge.Identifier.Value) && // mitigate DNS rebinding attacks
94-
r.Method == "GET" {
95+
r.Method == http.MethodGet {
9596
w.Header().Add("Content-Type", "text/plain")
9697
w.Write([]byte(challenge.KeyAuthorization))
9798
r.Close = true
@@ -116,7 +117,94 @@ func SolveHTTPChallenge(logger *zap.Logger, w http.ResponseWriter, r *http.Reque
116117
// LooksLikeHTTPChallenge returns true if r looks like an ACME
117118
// HTTP challenge request from an ACME server.
118119
func LooksLikeHTTPChallenge(r *http.Request) bool {
119-
return r.Method == "GET" && strings.HasPrefix(r.URL.Path, challengeBasePath)
120+
return r.Method == http.MethodGet &&
121+
strings.HasPrefix(r.URL.Path, acmeHTTPChallengeBasePath)
120122
}
121123

122-
const challengeBasePath = "/.well-known/acme-challenge"
124+
// LooksLikeZeroSSLHTTPValidation returns true if the request appears to be
125+
// domain validation from a ZeroSSL/Sectigo CA. NOTE: This API is
126+
// non-standard and is subject to change.
127+
func LooksLikeZeroSSLHTTPValidation(r *http.Request) bool {
128+
return r.Method == http.MethodGet &&
129+
strings.HasPrefix(r.URL.Path, zerosslHTTPValidationBasePath)
130+
}
131+
132+
// HTTPValidationHandler wraps the ZeroSSL HTTP validation handler such that
133+
// it can pass verification checks from ZeroSSL's API.
134+
//
135+
// If a request is not a ZeroSSL HTTP validation request, h will be invoked.
136+
func (iss *ZeroSSLIssuer) HTTPValidationHandler(h http.Handler) http.Handler {
137+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
138+
if iss.HandleZeroSSLHTTPValidation(w, r) {
139+
return
140+
}
141+
h.ServeHTTP(w, r)
142+
})
143+
}
144+
145+
// HandleZeroSSLHTTPValidation is to ZeroSSL API HTTP validation requests like HandleHTTPChallenge
146+
// is to ACME HTTP challenge requests.
147+
func (iss *ZeroSSLIssuer) HandleZeroSSLHTTPValidation(w http.ResponseWriter, r *http.Request) bool {
148+
if iss == nil {
149+
return false
150+
}
151+
if !LooksLikeZeroSSLHTTPValidation(r) {
152+
return false
153+
}
154+
return iss.distributedHTTPValidationAnswer(w, r)
155+
}
156+
157+
func (iss *ZeroSSLIssuer) distributedHTTPValidationAnswer(w http.ResponseWriter, r *http.Request) bool {
158+
if iss == nil {
159+
return false
160+
}
161+
logger := iss.Logger
162+
if logger == nil {
163+
logger = zap.NewNop()
164+
}
165+
host := hostOnly(r.Host)
166+
valInfo, distributed, err := iss.getDistributedValidationInfo(r.Context(), host)
167+
if err != nil {
168+
logger.Error("looking up info for HTTP validation",
169+
zap.String("host", host),
170+
zap.String("remote_addr", r.RemoteAddr),
171+
zap.String("user_agent", r.Header.Get("User-Agent")),
172+
zap.Error(err))
173+
return false
174+
}
175+
return answerHTTPValidation(logger, w, r, valInfo, distributed)
176+
}
177+
178+
func answerHTTPValidation(logger *zap.Logger, rw http.ResponseWriter, req *http.Request, valInfo acme.Challenge, distributed bool) bool {
179+
// ensure URL matches
180+
validationURL, err := url.Parse(valInfo.URL)
181+
if err != nil {
182+
logger.Error("got invalid URL from CA",
183+
zap.String("file_validation_url", valInfo.URL),
184+
zap.Error(err))
185+
rw.WriteHeader(http.StatusInternalServerError)
186+
return true
187+
}
188+
if req.URL.Path != validationURL.Path {
189+
rw.WriteHeader(http.StatusNotFound)
190+
return true
191+
}
192+
193+
rw.Header().Add("Content-Type", "text/plain")
194+
req.Close = true
195+
196+
rw.Write([]byte(valInfo.Token))
197+
198+
logger.Info("served HTTP validation credential",
199+
zap.String("validation_path", valInfo.URL),
200+
zap.String("challenge", "http-01"),
201+
zap.String("remote", req.RemoteAddr),
202+
zap.Bool("distributed", distributed))
203+
204+
return true
205+
}
206+
207+
const (
208+
acmeHTTPChallengeBasePath = "/.well-known/acme-challenge"
209+
zerosslHTTPValidationBasePath = "/.well-known/pki-validation/"
210+
)
File renamed without changes.

zerosslissuer.go

+47-33
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ package certmagic
1717
import (
1818
"context"
1919
"crypto/x509"
20+
"encoding/json"
2021
"fmt"
2122
"net"
2223
"net/http"
23-
"net/url"
2424
"strconv"
2525
"strings"
2626
"time"
@@ -68,6 +68,9 @@ func (iss *ZeroSSLIssuer) Issue(ctx context.Context, csr *x509.CertificateReques
6868
client := iss.getClient()
6969

7070
identifiers := namesFromCSR(csr)
71+
if len(identifiers) == 0 {
72+
return nil, fmt.Errorf("no identifiers on CSR")
73+
}
7174

7275
logger := iss.Logger
7376
if logger == nil {
@@ -104,35 +107,7 @@ func (iss *ZeroSSLIssuer) Issue(ctx context.Context, csr *x509.CertificateReques
104107

105108
httpVerifier := &httpSolver{
106109
address: net.JoinHostPort(iss.ListenHost, strconv.Itoa(iss.getHTTPPort())),
107-
handler: http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
108-
if !strings.HasPrefix(req.URL.Path, zerosslValidationPathPrefix) {
109-
return
110-
}
111-
112-
validation, ok := cert.Validation.OtherMethods[req.Host]
113-
if !ok {
114-
rw.WriteHeader(http.StatusNotFound)
115-
return
116-
}
117-
118-
// ensure URL matches
119-
validationURL, err := url.Parse(validation.FileValidationURLHTTP)
120-
if err != nil {
121-
logger.Error("got invalid URL from CA",
122-
zap.String("file_validation_url", validation.FileValidationURLHTTP),
123-
zap.Error(err))
124-
rw.WriteHeader(http.StatusInternalServerError)
125-
return
126-
}
127-
if req.URL.Path != validationURL.Path {
128-
rw.WriteHeader(http.StatusNotFound)
129-
return
130-
}
131-
132-
logger.Info("served HTTP validation file")
133-
134-
fmt.Fprint(rw, strings.Join(validation.FileValidationContent, "\n"))
135-
}),
110+
handler: iss.HTTPValidationHandler(http.NewServeMux()),
136111
}
137112

138113
var solver acmez.Solver = httpVerifier
@@ -144,10 +119,23 @@ func (iss *ZeroSSLIssuer) Issue(ctx context.Context, csr *x509.CertificateReques
144119
}
145120
}
146121

147-
if err = solver.Present(ctx, acme.Challenge{}); err != nil {
148-
return nil, fmt.Errorf("presenting token for verification: %v", err)
122+
// since the distributed solver was originally designed for ACME,
123+
// the API is geared around ACME challenges. ZeroSSL's HTTP validation
124+
// is very similar to the HTTP challenge, but not quite compatible,
125+
// so we kind of shim the ZeroSSL validation data into a Challenge
126+
// object... it is not a perfect use of this type but it's pretty close
127+
valInfo := cert.Validation.OtherMethods[identifiers[0]]
128+
fakeChallenge := acme.Challenge{
129+
Identifier: acme.Identifier{
130+
Value: identifiers[0], // used for storage key
131+
},
132+
URL: valInfo.FileValidationURLHTTP,
133+
Token: strings.Join(cert.Validation.OtherMethods[identifiers[0]].FileValidationContent, "\n"),
134+
}
135+
if err = solver.Present(ctx, fakeChallenge); err != nil {
136+
return nil, fmt.Errorf("presenting validation file for verification: %v", err)
149137
}
150-
defer solver.CleanUp(ctx, acme.Challenge{})
138+
defer solver.CleanUp(ctx, fakeChallenge)
151139
} else {
152140
verificationMethod = zerossl.CNAMEVerification
153141
logger = logger.With(zap.String("verification_method", string(verificationMethod)))
@@ -273,6 +261,32 @@ func (iss *ZeroSSLIssuer) Revoke(ctx context.Context, cert CertificateResource,
273261
return iss.getClient().RevokeCertificate(ctx, cert.IssuerData.(zerossl.CertificateObject).ID, r)
274262
}
275263

264+
func (iss *ZeroSSLIssuer) getDistributedValidationInfo(ctx context.Context, identifier string) (acme.Challenge, bool, error) {
265+
ds := distributedSolver{
266+
storage: iss.Storage,
267+
storageKeyIssuerPrefix: StorageKeys.Safe(iss.IssuerKey()),
268+
}
269+
tokenKey := ds.challengeTokensKey(identifier)
270+
271+
valObjectBytes, err := iss.Storage.Load(ctx, tokenKey)
272+
if err != nil {
273+
return acme.Challenge{}, false, fmt.Errorf("opening distributed challenge token file %s: %v", tokenKey, err)
274+
}
275+
276+
if len(valObjectBytes) == 0 {
277+
return acme.Challenge{}, false, fmt.Errorf("no information found to solve challenge for identifier: %s", identifier)
278+
}
279+
280+
// since the distributed solver's API is geared around ACME challenges,
281+
// we crammed the validation info into a Challenge object
282+
var chal acme.Challenge
283+
if err = json.Unmarshal(valObjectBytes, &chal); err != nil {
284+
return acme.Challenge{}, false, fmt.Errorf("decoding HTTP validation token file %s (corrupted?): %v", tokenKey, err)
285+
}
286+
287+
return chal, true, nil
288+
}
289+
276290
const (
277291
zerosslAPIBase = "https://" + zerossl.BaseURL + "/acme"
278292
zerosslValidationPathPrefix = "/.well-known/pki-validation/"

0 commit comments

Comments
 (0)