package tcp
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"io"
"math/big"
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/traefik/traefik/v3/pkg/config/dynamic"
traefiktls "github.com/traefik/traefik/v3/pkg/tls"
"github.com/traefik/traefik/v3/pkg/types"
)
func TestPoCQualifiedTCPServersTransportPresentsFileMTLSIdentity(t *testing.T) {
pki := newPoCPKI(t)
dialerManager := NewDialerManager(nil)
dialerManager.Update(map[string]*dynamic.TCPServersTransport{
"foo@file": {
TLS: &dynamic.TLSClientConfig{
ServerName: "example.com",
RootCAs: []types.FileOrContent{types.FileOrContent(pki.caCertPEM)},
Certificates: traefiktls.Certificates{
traefiktls.Certificate{
CertFile: types.FileOrContent(pki.clientCertPEM),
KeyFile: types.FileOrContent(pki.clientKeyPEM),
},
},
},
},
})
backendAddr, peerCN, done, closeBackend := newPoCMTLSBackend(t, pki)
defer closeBackend()
dialer, err := dialerManager.Build(&dynamic.TCPServersLoadBalancer{ServersTransport: "foo@file"}, true)
require.NoError(t, err)
conn, err := dialer.Dial("tcp", backendAddr, nil)
require.NoError(t, err)
defer conn.Close()
_, err = conn.Write([]byte("ping"))
require.NoError(t, err)
buf := make([]byte, 4)
_, err = io.ReadFull(conn, buf)
require.NoError(t, err)
require.Equal(t, "PONG", string(buf))
var cn string
select {
case cn = <-peerCN:
case <-time.After(time.Second):
t.Fatal("timed out waiting for backend peer certificate")
}
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(time.Second):
t.Fatal("timed out waiting for backend completion")
}
t.Logf("POC_MTLS_RESULT=backend_accepted_transport_identity serversTransport=%q peer_cn=%q response=%q",
"foo@file", cn, string(buf))
}
func newPoCMTLSBackend(t *testing.T, pki poCPKI) (string, <-chan string, <-chan error, func()) {
t.Helper()
serverCert, err := tls.X509KeyPair(pki.serverCertPEM, pki.serverKeyPEM)
require.NoError(t, err)
clientPool := x509.NewCertPool()
require.True(t, clientPool.AppendCertsFromPEM(pki.caCertPEM))
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
tlsListener := tls.NewListener(listener, &tls.Config{
Certificates: []tls.Certificate{serverCert},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: clientPool,
})
peerCN := make(chan string, 1)
done := make(chan error, 1)
go func() {
conn, err := tlsListener.Accept()
if err != nil {
done <- err
return
}
defer conn.Close()
tlsConn, ok := conn.(*tls.Conn)
if !ok {
done <- fmt.Errorf("unexpected connection type %T", conn)
return
}
if err := tlsConn.Handshake(); err != nil {
done <- err
return
}
state := tlsConn.ConnectionState()
if len(state.PeerCertificates) == 0 {
done <- fmt.Errorf("missing peer certificate")
return
}
peerCN <- state.PeerCertificates[0].Subject.CommonName
buf := make([]byte, 4)
if _, err := io.ReadFull(tlsConn, buf); err != nil {
done <- err
return
}
if string(buf) != "ping" {
done <- fmt.Errorf("unexpected backend payload %q", string(buf))
return
}
_, err = tlsConn.Write([]byte("PONG"))
done <- err
}()
return listener.Addr().String(), peerCN, done, func() {
_ = tlsListener.Close()
}
}
type poCPKI struct {
caCertPEM []byte
serverCertPEM []byte
serverKeyPEM []byte
clientCertPEM []byte
clientKeyPEM []byte
}
func newPoCPKI(t *testing.T) poCPKI {
t.Helper()
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "poc-ca"},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
require.NoError(t, err)
serverCertPEM, serverKeyPEM := newPoCLeafCert(t, caTemplate, caKey, "poc-server", []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth})
clientCertPEM, clientKeyPEM := newPoCLeafCert(t, caTemplate, caKey, "example.com", []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth})
return poCPKI{
caCertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}),
serverCertPEM: serverCertPEM,
serverKeyPEM: serverKeyPEM,
clientCertPEM: clientCertPEM,
clientKeyPEM: clientKeyPEM,
}
}
func newPoCLeafCert(t *testing.T, caTemplate *x509.Certificate, caKey *rsa.PrivateKey, cn string, eku []x509.ExtKeyUsage) ([]byte, []byte) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
require.NoError(t, err)
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: cn},
DNSNames: []string{"example.com"},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: eku,
}
certDER, err := x509.CreateCertificate(rand.Reader, template, caTemplate, &key.PublicKey, caKey)
require.NoError(t, err)
keyDER := x509.MarshalPKCS1PrivateKey(key)
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}),
pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER})
}
Summary
There is a medium-severity cross-provider reference vulnerability in Traefik's Kubernetes CRD provider. The
crossProviderNamespacesallowlist is enforced for HTTPserversTransportreferences but was not enforced forIngressRouteTCPserviceserversTransportreferences. A low-privileged Kubernetes user in a namespace that is not listed incrossProviderNamespacescould setserversTransport: foo@fileon anIngressRouteTCPservice, causing Traefik to accept the forbidden cross-provider reference and use the file-providerTCPServersTransport— including privileged backend mTLS client certificates, SPIFFE identity, or PROXY-protocol settings. The fix applies thecrossProviderNamespacesallowlist to TCPserversTransportreferences.Patches
For more information
If you have any questions or comments about this advisory, please open an issue.
Original Description
Summary
Traefik's Kubernetes CRD provider enforces
crossProviderNamespacesfor severalcross-provider references, but
IngressRouteTCPserviceserversTransportreferences skip that allowlist. A low-privileged Kubernetesuser in a namespace that is not listed in
crossProviderNamespacescan stillset
serversTransport: foo@fileon anIngressRouteTCPservice. Traefikaccepts the forbidden cross-provider reference and later uses the referenced
TCPServersTransport, including privileged backend mTLS client certificates,SPIFFE identity, or PROXY protocol settings.
Description
crossProviderNamespacesis documented and implemented as an allowlist fornamespaces that may declare cross-provider references from Kubernetes CRD
objects. HTTP
serversTransportreferences enforce that allowlist. TCPserversTransportreferences do not.An attacker with low Kubernetes privileges in namespace
defaultcan create anIngressRouteTCPservice with:Even when the provider is configured with:
Traefik still emits a TCP dynamic service whose load balancer points to
foo@file. At runtime,DialerManager.Build()uses the exact referencedtransport name and applies that transport's TLS client certificates and related
backend-connection settings.
Impact
The PoC demonstrates two positive facts:
crossProviderNamespacescan cause Traefik to accept andstore
LoadBalancer.ServersTransport = "foo@file"from anIngressRouteTCPservice.foo@fileTCPServersTransportwith a client certificate isactually consumed by the TCP dialer and presented to an mTLS backend.
This proves a backend identity relay primitive: a lower-privileged CRD author
can make Traefik connect to a backend using an operator-defined cross-provider
transport identity that the namespace should not be allowed to reference.
Proof Of Concept
Files
run.sh: portable runner.poc_crd_test.go: positive CRD provider proof.with_servers_transport_cross_provider_poc.yml: minimalIngressRouteTCPfixture.poc_tcp_mtls_test.go: positive runtime mTLS identity-use proof.run.sh
poc_crd_test.go
poc_tcp_mtls_test.go
with_servers_transport_cross_provider_poc.yml
Requirements
gitv3.7.5usesgo 1.25.0.https://github.com/traefik/traefik.gitand downloadGo modules on first run.
No local Traefik checkout is required by default.
Run
Optional target override:
Optional local-source override for faster local validation:
Expected Result
The run should end with:
Root Cause
Line numbers below are from:
1. The provider option is meant to cover IngressRouteTCP
pkg/provider/kubernetes/crd/kubernetes.go:60This establishes the security invariant:
IngressRouteTCPcross-providerreferences should be gated by
crossProviderNamespaces.2. TCP service creation forwards the attacker-controlled transport name
pkg/provider/kubernetes/crd/kubernetes_tcp.go:183-185The attacker-controlled
serversTransportfield is passed into the key builder.3. TCP key builder returns cross-provider names without the allowlist check
pkg/provider/kubernetes/crd/kubernetes_tcp.go:321-322This accepts
foo@filedirectly. There is no call toisCrossProviderNamespaceAllowed(...)on this TCP path.4. HTTP sibling contains the missing authorization gate
pkg/provider/kubernetes/crd/kubernetes_http.go:507-508The HTTP path proves the intended policy: cross-provider
serversTransportreferences should be rejected when the route namespace is not in the allowlist.
5. Runtime TCP dialer consumes the exact referenced transport
pkg/tcp/dialer.go:135-141pkg/tcp/dialer.go:183-188The accepted
foo@filereference is not a harmless string. It selects thecross-provider transport and applies its client TLS identity during backend
connections.
References