Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions internal/adc/translator/apisixtls.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
94 changes: 94 additions & 0 deletions internal/adc/translator/apisixtls_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
71 changes: 71 additions & 0 deletions internal/ssl/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<single-label>.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 "<label>.suffix". A wildcard host is
// covered only by an identical wildcard SAN (an exact SAN can't serve it).
func HostCoveredBy(host, san string) bool {
host = strings.ToLower(strings.TrimSpace(host))
san = strings.ToLower(strings.TrimSpace(san))
if host == "" || san == "" {
return false
}
if host == san {
return true
}
if strings.HasPrefix(san, "*.") {
return wildcardCovers(san, host)
}
return false
}

// ParentWildcard returns the single-label covering wildcard for an exact host,
// e.g. "app.example.com" -> "*.example.com". Returns "" if host is empty, is
// itself a wildcard, or has no parent label.
func ParentWildcard(host string) string {
if host == "" || strings.HasPrefix(host, "*.") {
return ""
}
i := strings.IndexByte(host, '.')
if i < 0 || i == len(host)-1 {
return ""
}
return "*." + host[i+1:]
}

// CertificateHash returns the SHA-256 hash of the leaf certificate contained in the PEM data.
// The hash is calculated from the DER-encoded bytes so that formatting differences (whitespace,
// line endings, certificate ordering) do not affect the result.
Expand Down
81 changes: 81 additions & 0 deletions internal/ssl/util_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// 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 ssl

import "testing"

func TestHostsOverlap(t *testing.T) {
cases := []struct {
a, b string
want bool
}{
{"example.com", "example.com", true}, // identical exact
{"*.example.com", "*.example.com", true}, // identical wildcard
{"app.example.com", "*.example.com", true}, // exact covered by wildcard
{"*.example.com", "app.example.com", true}, // wildcard covers exact (inverse)
{"App.Example.com", "*.EXAMPLE.com", true}, // case-insensitive
{"a.b.example.com", "*.example.com", false}, // multi-label not covered
{"example.com", "*.example.com", false}, // apex not covered by its wildcard
{"app.example.com", "app.other.com", false}, // different exacts
{"*.example.com", "*.other.com", false}, // distinct wildcards never overlap
{"*.a.example.com", "*.example.com", false}, // wildcards at different depths
{"", "example.com", false}, // empty
}
for _, c := range cases {
if got := HostsOverlap(c.a, c.b); got != c.want {
t.Errorf("HostsOverlap(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
}
}
}

func TestHostCoveredBy(t *testing.T) {
cases := []struct {
host, san string
want bool
}{
{"app.example.com", "app.example.com", true}, // exact SAN covers exact host
{"app.example.com", "*.example.com", true}, // wildcard SAN covers subdomain
{"App.Example.com", "*.EXAMPLE.com", true}, // case-insensitive
{"*.example.com", "*.example.com", true}, // wildcard host needs identical wildcard SAN
{"*.example.com", "app.example.com", false}, // exact SAN can't cover a wildcard host
{"a.b.example.com", "*.example.com", false}, // wildcard SAN is single-label only
{"shop.example.com", "internal.corp.local", false}, // unrelated SAN
{"example.com", "*.example.com", false}, // apex not covered by its wildcard
{"app.example.com", "", false}, // empty SAN
}
for _, c := range cases {
if got := HostCoveredBy(c.host, c.san); got != c.want {
t.Errorf("HostCoveredBy(%q, %q) = %v, want %v", c.host, c.san, got, c.want)
}
}
}

func TestParentWildcard(t *testing.T) {
cases := []struct {
host, want string
}{
{"app.example.com", "*.example.com"},
{"example.com", "*.com"},
{"*.example.com", ""}, // already a wildcard
{"localhost", ""}, // no parent label
{"", ""},
}
for _, c := range cases {
if got := ParentWildcard(c.host); got != c.want {
t.Errorf("ParentWildcard(%q) = %q, want %q", c.host, got, c.want)
}
}
}
Loading
Loading