diff options
-rw-r--r-- | admin.go | 59 | ||||
-rw-r--r-- | caddyconfig/httpcaddyfile/httptype.go | 2 | ||||
-rw-r--r-- | caddyconfig/json5/json5.go | 43 | ||||
-rw-r--r-- | caddyconfig/jsonc/jsonc.go | 49 | ||||
-rw-r--r-- | cmd/main.go | 11 | ||||
-rw-r--r-- | go.mod | 2 | ||||
-rw-r--r-- | go.sum | 4 | ||||
-rw-r--r-- | modules/caddyhttp/caddyhttp.go | 30 | ||||
-rw-r--r-- | modules/caddyhttp/fileserver/caddyfile.go | 2 | ||||
-rw-r--r-- | modules/caddyhttp/fileserver/staticfiles.go | 44 | ||||
-rw-r--r-- | modules/caddyhttp/replacer.go | 6 | ||||
-rw-r--r-- | modules/caddyhttp/server.go | 28 | ||||
-rw-r--r-- | modules/caddytls/connpolicy.go | 139 |
13 files changed, 364 insertions, 55 deletions
@@ -22,6 +22,7 @@ import ( "io" "io/ioutil" "log" + "mime" "net" "net/http" "net/http/pprof" @@ -170,38 +171,44 @@ func handleLoadConfig(w http.ResponseWriter, r *http.Request) { // if the config is formatted other than Caddy's native // JSON, we need to adapt it before loading it - ct := r.Header.Get("Content-Type") - if !strings.Contains(ct, "/json") { - slashIdx := strings.Index(ct, "/") - if slashIdx < 0 { - http.Error(w, "Malformed Content-Type", http.StatusBadRequest) - return - } - adapterName := ct[slashIdx+1:] - cfgAdapter := caddyconfig.GetAdapter(adapterName) - if cfgAdapter == nil { - http.Error(w, "Unrecognized config adapter: "+adapterName, http.StatusBadRequest) - return - } - body, err := ioutil.ReadAll(http.MaxBytesReader(w, r.Body, 1024*1024)) - if err != nil { - http.Error(w, "Error reading request body: "+err.Error(), http.StatusBadRequest) - return - } - result, warnings, err := cfgAdapter.Adapt(body, nil) + if ctHeader := r.Header.Get("Content-Type"); ctHeader != "" { + ct, _, err := mime.ParseMediaType(ctHeader) if err != nil { - log.Printf("[ADMIN][ERROR] adapting config from %s: %v", adapterName, err) - http.Error(w, fmt.Sprintf("Adapting config from %s: %v", adapterName, err), http.StatusBadRequest) + http.Error(w, "Invalid Content-Type: "+err.Error(), http.StatusBadRequest) return } - if len(warnings) > 0 { - respBody, err := json.Marshal(warnings) + if !strings.HasSuffix(ct, "/json") { + slashIdx := strings.Index(ct, "/") + if slashIdx < 0 { + http.Error(w, "Malformed Content-Type", http.StatusBadRequest) + return + } + adapterName := ct[slashIdx+1:] + cfgAdapter := caddyconfig.GetAdapter(adapterName) + if cfgAdapter == nil { + http.Error(w, "Unrecognized config adapter: "+adapterName, http.StatusBadRequest) + return + } + body, err := ioutil.ReadAll(http.MaxBytesReader(w, r.Body, 1024*1024)) if err != nil { - log.Printf("[ADMIN][ERROR] marshaling warnings: %v", err) + http.Error(w, "Error reading request body: "+err.Error(), http.StatusBadRequest) + return + } + result, warnings, err := cfgAdapter.Adapt(body, nil) + if err != nil { + log.Printf("[ADMIN][ERROR] adapting config from %s: %v", adapterName, err) + http.Error(w, fmt.Sprintf("Adapting config from %s: %v", adapterName, err), http.StatusBadRequest) + return + } + if len(warnings) > 0 { + respBody, err := json.Marshal(warnings) + if err != nil { + log.Printf("[ADMIN][ERROR] marshaling warnings: %v", err) + } + w.Write(respBody) } - w.Write(respBody) + payload = bytes.NewReader(result) } - payload = bytes.NewReader(result) } err := Load(payload) diff --git a/caddyconfig/httpcaddyfile/httptype.go b/caddyconfig/httpcaddyfile/httptype.go index ecf6e94..d183c7c 100644 --- a/caddyconfig/httpcaddyfile/httptype.go +++ b/caddyconfig/httpcaddyfile/httptype.go @@ -93,7 +93,7 @@ func (st ServerType) Setup(originalServerBlocks []caddyfile.ServerBlock, "{scheme}", "{http.request.scheme}", "{file}", "{http.request.uri.path.file}", "{dir}", "{http.request.uri.path.dir}", - "{query}", "{http.request.uri.query}", + "{query}", "{http.request.uri.query_string}", ) for _, segment := range sb.block.Segments { for i := 0; i < len(segment); i++ { diff --git a/caddyconfig/json5/json5.go b/caddyconfig/json5/json5.go new file mode 100644 index 0000000..2c86301 --- /dev/null +++ b/caddyconfig/json5/json5.go @@ -0,0 +1,43 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package json5adapter + +import ( + "encoding/json" + + "github.com/caddyserver/caddy/v2/caddyconfig" + "github.com/ilibs/json5" +) + +func init() { + caddyconfig.RegisterAdapter("json5", Adapter{}) +} + +// Adapter adapts JSON5 to Caddy JSON. +type Adapter struct{} + +// Adapt converts the JSON5 config in body to Caddy JSON. +func (a Adapter) Adapt(body []byte, options map[string]interface{}) (result []byte, warnings []caddyconfig.Warning, err error) { + var decoded interface{} + err = json5.Unmarshal(body, &decoded) + if err != nil { + return + } + result, err = json.Marshal(decoded) + return +} + +// Interface guard +var _ caddyconfig.Adapter = (*Adapter)(nil) diff --git a/caddyconfig/jsonc/jsonc.go b/caddyconfig/jsonc/jsonc.go new file mode 100644 index 0000000..4f72c05 --- /dev/null +++ b/caddyconfig/jsonc/jsonc.go @@ -0,0 +1,49 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package jsoncadapter + +import ( + "encoding/json" + + "github.com/caddyserver/caddy/v2/caddyconfig" + "github.com/muhammadmuzzammil1998/jsonc" +) + +func init() { + caddyconfig.RegisterAdapter("jsonc", Adapter{}) +} + +// Adapter adapts JSON-C to Caddy JSON. +type Adapter struct{} + +// Adapt converts the JSON-C config in body to Caddy JSON. +func (a Adapter) Adapt(body []byte, options map[string]interface{}) (result []byte, warnings []caddyconfig.Warning, err error) { + result = jsonc.ToJSON(body) + + // any errors in the JSON will be + // reported during config load, but + // we can at least warn here that + // it is not valid JSON + if !json.Valid(result) { + warnings = append(warnings, caddyconfig.Warning{ + Message: "Resulting JSON is invalid.", + }) + } + + return +} + +// Interface guard +var _ caddyconfig.Adapter = (*Adapter)(nil) diff --git a/cmd/main.go b/cmd/main.go index e2f1233..3b44a85 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -114,10 +114,17 @@ func loadConfig(configFile, adapterName string) ([]byte, error) { cfgAdapter = caddyconfig.GetAdapter("caddyfile") if cfgAdapter != nil { config, err = ioutil.ReadFile("Caddyfile") - if err != nil && !os.IsNotExist(err) { + if os.IsNotExist(err) { + // okay, no default Caddyfile; pretend like this never happened + cfgAdapter = nil + err = nil + } else if err != nil { + // default Caddyfile exists, but error reading it return nil, fmt.Errorf("reading default Caddyfile: %v", err) + } else { + // success reading default Caddyfile + configFile = "Caddyfile" } - configFile = "Caddyfile" } } @@ -13,11 +13,13 @@ require ( github.com/google/go-cmp v0.3.1 // indirect github.com/google/uuid v1.1.1 // indirect github.com/huandu/xstrings v1.2.0 // indirect + github.com/ilibs/json5 v1.0.1 github.com/imdario/mergo v0.3.7 // indirect github.com/klauspost/compress v1.7.1-0.20190613161414-0b31f265a57b github.com/klauspost/cpuid v1.2.1 github.com/mholt/certmagic v0.6.2 github.com/mitchellh/go-ps v0.0.0-20170309133038-4fdf99ab2936 + github.com/muhammadmuzzammil1998/jsonc v0.0.0-20190902132743-e4903c4dea48 github.com/rs/cors v1.6.0 github.com/russross/blackfriday/v2 v2.0.1 github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect @@ -25,6 +25,8 @@ github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/huandu/xstrings v1.2.0 h1:yPeWdRnmynF7p+lLYz0H2tthW9lqhMJrQV/U7yy4wX0= github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4= +github.com/ilibs/json5 v1.0.1 h1:3e14wUQM8PyK6Hf1bM+zAQFxfG+N5oZj35x5vCNeQ58= +github.com/ilibs/json5 v1.0.1/go.mod h1:kXsGuzHMPuZZTN15l0IQzy5PR8DrDhPB24tFgwpdKME= github.com/imdario/mergo v0.3.7 h1:Y+UAYTZ7gDEuOfhxKWy+dvb5dRQ6rJjFSdX2HZY1/gI= github.com/imdario/mergo v0.3.7/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/klauspost/compress v1.7.1-0.20190613161414-0b31f265a57b h1:LHpBANNM/cw1PAXJtKV9dgfp6ztOKfdGXcltGmqU9aE= @@ -38,6 +40,8 @@ github.com/miekg/dns v1.1.3 h1:1g0r1IvskvgL8rR+AcHzUA+oFmGcQlaIm4IqakufeMM= github.com/miekg/dns v1.1.3/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mitchellh/go-ps v0.0.0-20170309133038-4fdf99ab2936 h1:kw1v0NlnN+GZcU8Ma8CLF2Zzgjfx95gs3/GN3vYAPpo= github.com/mitchellh/go-ps v0.0.0-20170309133038-4fdf99ab2936/go.mod h1:r1VsdOzOPt1ZSrGZWFoNhsAedKnEd6r9Np1+5blZCWk= +github.com/muhammadmuzzammil1998/jsonc v0.0.0-20190902132743-e4903c4dea48 h1:BM/fjd7MfvZuyoHXLv3YlWNIuNb47PLp6EyFBL1KIMg= +github.com/muhammadmuzzammil1998/jsonc v0.0.0-20190902132743-e4903c4dea48/go.mod h1:saF2fIVw4banK0H4+/EuqfFLpRnoy5S+ECwTOCcRcSU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rs/cors v1.6.0 h1:G9tHG9lebljV9mfp9SNPDL36nCDxmo3zTlAf1YgvzmI= diff --git a/modules/caddyhttp/caddyhttp.go b/modules/caddyhttp/caddyhttp.go index 174e316..9dfdf36 100644 --- a/modules/caddyhttp/caddyhttp.go +++ b/modules/caddyhttp/caddyhttp.go @@ -75,6 +75,15 @@ func (app *App) Provision(ctx caddy.Context) error { srv.AutoHTTPS = new(AutoHTTPSConfig) } + // disallow TLS client auth bypass which could + // otherwise be exploited by sending an unprotected + // SNI value during TLS handshake, then a protected + // Host header during HTTP request later on that + // connection + if srv.hasTLSClientAuth() { + srv.StrictSNIHost = true + } + // TODO: Test this function to ensure these replacements are performed for i := range srv.Listen { srv.Listen[i] = repl.ReplaceAll(srv.Listen[i], "") @@ -159,8 +168,7 @@ func (app *App) Start() error { return fmt.Errorf("%s: listening on %s: %v", network, addr, err) } - // enable HTTP/2 (and support for solving the - // TLS-ALPN ACME challenge) by default + // enable HTTP/2 by default for _, pol := range srv.TLSConnPolicies { if len(pol.ALPN) == 0 { pol.ALPN = append(pol.ALPN, defaultALPN...) @@ -226,6 +234,8 @@ func (app *App) automaticHTTPS() error { // skip if all listeners use the HTTP port if !srv.listenersUseAnyPortOtherThan(app.HTTPPort) { + log.Printf("[INFO] Server %v is only listening on the HTTP port %d, so no automatic HTTPS will be applied to this server", + srv.Listen, app.HTTPPort) continue } @@ -294,11 +304,11 @@ func (app *App) automaticHTTPS() error { return fmt.Errorf("%s: managing certificate for %s: %s", srvName, domains, err) } - // tell the server to use TLS by specifying a TLS - // connection policy (which supports HTTP/2 and the - // TLS-ALPN ACME challenge as well) - srv.TLSConnPolicies = caddytls.ConnectionPolicies{ - {ALPN: defaultALPN}, + // tell the server to use TLS if it is not already doing so + if srv.TLSConnPolicies == nil { + srv.TLSConnPolicies = caddytls.ConnectionPolicies{ + &caddytls.ConnectionPolicy{ALPN: defaultALPN}, + } } if srv.AutoHTTPS.DisableRedir { @@ -307,6 +317,12 @@ func (app *App) automaticHTTPS() error { log.Printf("[INFO] Enabling automatic HTTP->HTTPS redirects for %v", domains) + // notify user if their config might override the HTTP->HTTPS redirects + if srv.listenersIncludePort(app.HTTPPort) { + log.Printf("[WARNING] Server %v is listening on HTTP port %d, so automatic HTTP->HTTPS redirects may be overridden by your own configuration", + srv.Listen, app.HTTPPort) + } + // create HTTP->HTTPS redirects for _, addr := range srv.Listen { netw, host, port, err := caddy.SplitNetworkAddress(addr) diff --git a/modules/caddyhttp/fileserver/caddyfile.go b/modules/caddyhttp/fileserver/caddyfile.go index 4622af2..b7cb311 100644 --- a/modules/caddyhttp/fileserver/caddyfile.go +++ b/modules/caddyhttp/fileserver/caddyfile.go @@ -94,7 +94,7 @@ func parseTryFiles(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) } handler := rewrite.Rewrite{ - URI: "{http.matchers.file.relative}{http.request.uri.query}", + URI: "{http.matchers.file.relative}{http.request.uri.query_string}", } matcherSet := map[string]json.RawMessage{ diff --git a/modules/caddyhttp/fileserver/staticfiles.go b/modules/caddyhttp/fileserver/staticfiles.go index cfb79f8..3e4cccc 100644 --- a/modules/caddyhttp/fileserver/staticfiles.go +++ b/modules/caddyhttp/fileserver/staticfiles.go @@ -41,10 +41,11 @@ func init() { // FileServer implements a static file server responder for Caddy. type FileServer struct { - Root string `json:"root,omitempty"` // default is current directory - Hide []string `json:"hide,omitempty"` - IndexNames []string `json:"index_names,omitempty"` - Browse *Browse `json:"browse,omitempty"` + Root string `json:"root,omitempty"` // default is current directory + Hide []string `json:"hide,omitempty"` + IndexNames []string `json:"index_names,omitempty"` + Browse *Browse `json:"browse,omitempty"` + CanonicalURIs *bool `json:"canonical_uris,omitempty"` } // CaddyModule returns the Caddy module information. @@ -109,6 +110,7 @@ func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, _ cadd // if the request mapped to a directory, see if // there is an index file we can serve + var implicitIndexFile bool if info.IsDir() && len(fsrv.IndexNames) > 0 { for _, indexPage := range fsrv.IndexNames { indexPath := sanitizedPathJoin(filename, indexPage) @@ -122,12 +124,17 @@ func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, _ cadd continue } - // we found an index file that might work, - // so rewrite the request path - r.URL.Path = path.Join(r.URL.Path, indexPage) + // don't rewrite the request path to append + // the index file, because we might need to + // do a canonical-URL redirect below based + // on the URL as-is + // we've chosen to use this index file, + // so replace the last file info and path + // with that of the index file info = indexInfo filename = indexPath + implicitIndexFile = true break } } @@ -149,10 +156,22 @@ func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, _ cadd return caddyhttp.Error(http.StatusNotFound, nil) } + // if URL canonicalization is enabled, we need to enforce trailing + // slash convention: if a directory, trailing slash; if a file, no + // trailing slash - not enforcing this can break relative hrefs + // in HTML (see https://github.com/caddyserver/caddy/issues/2741) + if fsrv.CanonicalURIs == nil || *fsrv.CanonicalURIs { + if implicitIndexFile && !strings.HasSuffix(r.URL.Path, "/") { + return redirect(w, r, r.URL.Path+"/") + } else if !implicitIndexFile && strings.HasSuffix(r.URL.Path, "/") { + return redirect(w, r, r.URL.Path[:len(r.URL.Path)-1]) + } + } + // open the file file, err := fsrv.openFile(filename, w) if err != nil { - return err + return err // error is already structured } defer file.Close() @@ -309,6 +328,15 @@ func calculateEtag(d os.FileInfo) string { return `"` + t + s + `"` } +func redirect(w http.ResponseWriter, r *http.Request, to string) error { + for strings.HasPrefix(to, "//") { + // prevent path-based open redirects + to = strings.TrimPrefix(to, "/") + } + http.Redirect(w, r, to, http.StatusPermanentRedirect) + return nil +} + var defaultIndexNames = []string{"index.html", "index.txt"} var bufPool = sync.Pool{ diff --git a/modules/caddyhttp/replacer.go b/modules/caddyhttp/replacer.go index f7f69a4..e003259 100644 --- a/modules/caddyhttp/replacer.go +++ b/modules/caddyhttp/replacer.go @@ -89,6 +89,12 @@ func addHTTPVarsToReplacer(repl caddy.Replacer, req *http.Request, w http.Respon return dir, true case "http.request.uri.query": return req.URL.RawQuery, true + case "http.request.uri.query_string": + qs := req.URL.Query().Encode() + if qs != "" { + qs = "?" + qs + } + return qs, true } // hostname labels diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go index 04935e6..42f7a5a 100644 --- a/modules/caddyhttp/server.go +++ b/modules/caddyhttp/server.go @@ -41,7 +41,7 @@ type Server struct { TLSConnPolicies caddytls.ConnectionPolicies `json:"tls_connection_policies,omitempty"` AutoHTTPS *AutoHTTPSConfig `json:"automatic_https,omitempty"` MaxRehandles *int `json:"max_rehandles,omitempty"` - StrictSNIHost bool `json:"strict_sni_host,omitempty"` // TODO: see if we can turn this on by default when clientauth is configured + StrictSNIHost bool `json:"strict_sni_host,omitempty"` tlsApp *caddytls.TLS } @@ -183,6 +183,32 @@ func (s *Server) listenersUseAnyPortOtherThan(otherPort int) bool { return false } +// listenersIncludePort returns true if there are any +// listeners in s that use otherPort. +func (s *Server) listenersIncludePort(otherPort int) bool { + for _, lnAddr := range s.Listen { + _, addrs, err := caddy.ParseListenAddr(lnAddr) + if err == nil { + for _, a := range addrs { + _, port, err := net.SplitHostPort(a) + if err == nil && port == strconv.Itoa(otherPort) { + return true + } + } + } + } + return false +} + +func (s *Server) hasTLSClientAuth() bool { + for _, cp := range s.TLSConnPolicies { + if cp.ClientAuthentication != nil && cp.ClientAuthentication.Active() { + return true + } + } + return false +} + // AutoHTTPSConfig is used to disable automatic HTTPS // or certain aspects of it for a specific server. type AutoHTTPSConfig struct { diff --git a/modules/caddytls/connpolicy.go b/modules/caddytls/connpolicy.go index e061281..16fd4f1 100644 --- a/modules/caddytls/connpolicy.go +++ b/modules/caddytls/connpolicy.go @@ -17,6 +17,7 @@ package caddytls import ( "crypto/tls" "crypto/x509" + "encoding/base64" "encoding/json" "fmt" "strings" @@ -111,13 +112,12 @@ type ConnectionPolicy struct { Matchers map[string]json.RawMessage `json:"match,omitempty"` CertSelection json.RawMessage `json:"certificate_selection,omitempty"` - CipherSuites []string `json:"cipher_suites,omitempty"` - Curves []string `json:"curves,omitempty"` - ALPN []string `json:"alpn,omitempty"` - ProtocolMin string `json:"protocol_min,omitempty"` - ProtocolMax string `json:"protocol_max,omitempty"` - - // TODO: Client auth + CipherSuites []string `json:"cipher_suites,omitempty"` + Curves []string `json:"curves,omitempty"` + ALPN []string `json:"alpn,omitempty"` + ProtocolMin string `json:"protocol_min,omitempty"` + ProtocolMax string `json:"protocol_max,omitempty"` + ClientAuthentication *ClientAuthentication `json:"client_authentication,omitempty"` matchers []ConnectionMatcher certSelector certmagic.CertificateSelector @@ -167,7 +167,7 @@ func (p *ConnectionPolicy) buildStandardTLSConfig(ctx caddy.Context) error { tlsApp.SessionTickets.unregister(cfg) }) - // TODO: Clean up active locks if app (or process) is being closed! + // TODO: Clean up session ticket active locks in storage if app (or process) is being closed! // add all the cipher suites in order, without duplicates cipherSuitesAdded := make(map[uint16]struct{}) @@ -212,7 +212,15 @@ func (p *ConnectionPolicy) buildStandardTLSConfig(ctx caddy.Context) error { return fmt.Errorf("protocol min (%x) cannot be greater than protocol max (%x)", p.ProtocolMin, p.ProtocolMax) } - // TODO: client auth, and other fields + // client authentication + if p.ClientAuthentication != nil { + err := p.ClientAuthentication.ConfigureTLSConfig(cfg) + if err != nil { + return fmt.Errorf("configuring TLS client authentication: %v", err) + } + } + + // TODO: other fields setDefaultTLSParams(cfg) @@ -221,6 +229,119 @@ func (p *ConnectionPolicy) buildStandardTLSConfig(ctx caddy.Context) error { return nil } +// ClientAuthentication configures TLS client auth. +type ClientAuthentication struct { + // A list of base64 DER-encoded CA certificates + // against which to validate client certificates. + // Client certs which are not signed by any of + // these CAs will be rejected. + TrustedCACerts []string `json:"trusted_ca_certs,omitempty"` + + // A list of base64 DER-encoded client leaf certs + // to accept. If this list is not empty, client certs + // which are not in this list will be rejected. + TrustedLeafCerts []string `json:"trusted_leaf_certs,omitempty"` + + // state established with the last call to ConfigureTLSConfig + trustedLeafCerts []*x509.Certificate + existingVerifyPeerCert func([][]byte, [][]*x509.Certificate) error +} + +// Active returns true if clientauth has an actionable configuration. +func (clientauth ClientAuthentication) Active() bool { + return len(clientauth.TrustedCACerts) > 0 || len(clientauth.TrustedLeafCerts) > 0 +} + +// ConfigureTLSConfig sets up cfg to enforce clientauth's configuration. +func (clientauth *ClientAuthentication) ConfigureTLSConfig(cfg *tls.Config) error { + // if there's no actionable client auth, simply disable it + if !clientauth.Active() { + cfg.ClientAuth = tls.NoClientCert + return nil + } + + // otherwise, at least require any client certificate + cfg.ClientAuth = tls.RequireAnyClientCert + + // enforce CA verification by adding CA certs to the ClientCAs pool + if len(clientauth.TrustedCACerts) > 0 { + caPool := x509.NewCertPool() + for _, clientCAString := range clientauth.TrustedCACerts { + clientCA, err := decodeBase64DERCert(clientCAString) + if err != nil { + return fmt.Errorf("parsing certificate: %v", err) + } + caPool.AddCert(clientCA) + } + cfg.ClientCAs = caPool + + // now ensure the standard lib will verify client certificates + cfg.ClientAuth = tls.RequireAndVerifyClientCert + } + + // enforce leaf verification by writing our own verify function + if len(clientauth.TrustedLeafCerts) > 0 { + clientauth.trustedLeafCerts = []*x509.Certificate{} + + for _, clientCertString := range clientauth.TrustedLeafCerts { + clientCert, err := decodeBase64DERCert(clientCertString) + if err != nil { + return fmt.Errorf("parsing certificate: %v", err) + } + clientauth.trustedLeafCerts = append(clientauth.trustedLeafCerts, clientCert) + } + + // if a custom verification function already exists, wrap it + clientauth.existingVerifyPeerCert = cfg.VerifyPeerCertificate + + cfg.VerifyPeerCertificate = clientauth.verifyPeerCertificate + } + + return nil +} + +// verifyPeerCertificate is for use as a tls.Config.VerifyPeerCertificate +// callback to do custom client certificate verification. It is intended +// for installation only by clientauth.ConfigureTLSConfig(). +func (clientauth ClientAuthentication) verifyPeerCertificate(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + // first use any pre-existing custom verification function + if clientauth.existingVerifyPeerCert != nil { + err := clientauth.existingVerifyPeerCert(rawCerts, verifiedChains) + if err != nil { + return err + } + } + + if len(rawCerts) == 0 { + return fmt.Errorf("no client certificate provided") + } + + remoteLeafCert, err := x509.ParseCertificate(rawCerts[len(rawCerts)-1]) + if err != nil { + return fmt.Errorf("can't parse the given certificate: %s", err.Error()) + } + + for _, trustedLeafCert := range clientauth.trustedLeafCerts { + if remoteLeafCert.Equal(trustedLeafCert) { + return nil + } + } + + return fmt.Errorf("client leaf certificate failed validation") +} + +// decodeBase64DERCert base64-decodes, then DER-decodes, certStr. +func decodeBase64DERCert(certStr string) (*x509.Certificate, error) { + // decode base64 + derBytes, err := base64.StdEncoding.DecodeString(certStr) + if err != nil { + return nil, err + } + + // parse the DER-encoded certificate + return x509.ParseCertificate(derBytes) +} + // setDefaultTLSParams sets the default TLS cipher suites, protocol versions, // and server preferences of cfg if they are not already set; it does not // overwrite values, only fills in missing values. |