diff --git a/internal/adc/translator/apisixtls.go b/internal/adc/translator/apisixtls.go index ea428711..84783dd8 100644 --- a/internal/adc/translator/apisixtls.go +++ b/internal/adc/translator/apisixtls.go @@ -50,6 +50,14 @@ func (t *Translator) TranslateApisixTls(tctx *provider.TranslateContext, tls *ap return nil, err } + // APISIX serves the cert regardless of SAN, so this is advisory: warn when a + // declared host isn't covered by the cert SANs (clients may reject it). + if uncovered, sans := uncoveredSNIHosts(cert, tls.Spec.Hosts); len(uncovered) > 0 { + t.Log.Info("ApisixTls certificate does not cover all declared SNI hosts", + "namespace", tls.Namespace, "name", tls.Name, + "uncoveredHosts", uncovered, "certificateSANs", sans) + } + // Convert hosts to strings snis := make([]string, len(tls.Spec.Hosts)) for i, host := range tls.Spec.Hosts { @@ -97,3 +105,28 @@ func (t *Translator) TranslateApisixTls(tctx *provider.TranslateContext, tls *ap result.SSL = append(result.SSL, ssl) return result, nil } + +// uncoveredSNIHosts returns the declared hosts not covered by any DNS SAN in the +// certificate (wildcard-aware), plus the cert SANs for logging. Returns nothing +// when the cert declares no DNS SANs or can't be parsed, since coverage can't be +// judged there. +func uncoveredSNIHosts(cert []byte, hosts []apiv2.HostType) (uncovered []string, sans []string) { + sans, err := sslutils.ExtractHostsFromCertificate(cert) + if err != nil || len(sans) == 0 { + return nil, nil + } + for _, h := range hosts { + host := string(h) + covered := false + for _, san := range sans { + if sslutils.HostCoveredBy(host, san) { + covered = true + break + } + } + if !covered { + uncovered = append(uncovered, host) + } + } + return uncovered, sans +} diff --git a/internal/adc/translator/apisixtls_test.go b/internal/adc/translator/apisixtls_test.go new file mode 100644 index 00000000..6e49e36a --- /dev/null +++ b/internal/adc/translator/apisixtls_test.go @@ -0,0 +1,94 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 translator + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "slices" + "testing" + "time" + + apiv2 "github.com/apache/apisix-ingress-controller/api/v2" +) + +func genCertWithSANs(t *testing.T, sans []string) []byte { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + serial, err := rand.Int(rand.Reader, big.NewInt(1<<62)) + if err != nil { + t.Fatalf("failed to generate serial: %v", err) + } + cn := "test" + if len(sans) > 0 { + cn = sans[0] + } + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: cn}, + DNSNames: sans, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) + if err != nil { + t.Fatalf("failed to create cert: %v", err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func hostTypes(hosts ...string) []apiv2.HostType { + out := make([]apiv2.HostType, 0, len(hosts)) + for _, h := range hosts { + out = append(out, apiv2.HostType(h)) + } + return out +} + +func TestUncoveredSNIHosts(t *testing.T) { + cases := []struct { + name string + sans []string + hosts []string + wantUncovered []string + }{ + {"exact match", []string{"shop.example.com"}, []string{"shop.example.com"}, nil}, + {"wildcard covers subdomain", []string{"*.example.com"}, []string{"shop.example.com"}, nil}, + {"declared host not in SANs", []string{"internal.corp.local"}, []string{"shop.example.com"}, []string{"shop.example.com"}}, + {"one of many uncovered", []string{"a.example.com"}, []string{"a.example.com", "b.example.com"}, []string{"b.example.com"}}, + {"no DNS SANs is lenient", nil, []string{"shop.example.com"}, nil}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + uncovered, _ := uncoveredSNIHosts(genCertWithSANs(t, c.sans), hostTypes(c.hosts...)) + if !slices.Equal(uncovered, c.wantUncovered) { + t.Fatalf("uncoveredSNIHosts() = %v, want %v", uncovered, c.wantUncovered) + } + }) + } +} diff --git a/internal/ssl/util.go b/internal/ssl/util.go index ad34dd40..be2c8c74 100644 --- a/internal/ssl/util.go +++ b/internal/ssl/util.go @@ -180,6 +180,77 @@ func NormalizeHosts(hosts []string) []string { return normalized } +// HostsOverlap reports whether two SNI host patterns can both match a common +// concrete hostname. It understands single-label wildcards ("*.example.com" +// matches "app.example.com" but not "a.b.example.com"). Two distinct wildcards +// never share a concrete host. +func HostsOverlap(a, b string) bool { + a = strings.ToLower(strings.TrimSpace(a)) + b = strings.ToLower(strings.TrimSpace(b)) + if a == "" || b == "" { + return false + } + if a == b { + return true + } + aWild := strings.HasPrefix(a, "*.") + bWild := strings.HasPrefix(b, "*.") + switch { + case aWild && bWild: + return false + case aWild: + return wildcardCovers(a, b) + case bWild: + return wildcardCovers(b, a) + default: + return false + } +} + +// wildcardCovers reports whether wildcard "*.suffix" matches the exact host, +// i.e. host is exactly ".suffix". +func wildcardCovers(wildcard, host string) bool { + suffix := wildcard[1:] // ".example.com" + if !strings.HasSuffix(host, suffix) { + return false + } + label := host[:len(host)-len(suffix)] + return label != "" && !strings.Contains(label, ".") +} + +// HostCoveredBy reports whether a certificate SAN covers an SNI host. Coverage +// is directional: an exact SAN covers only the identical host; a wildcard SAN +// "*.suffix" covers single-label subdomains "