TLS configuration issue
Description
The commit addresses TLS options resolution for entry-point based TLS configuration. Previously, a router that relies on entry-point TLS could publish a configuration where TLS options were not resolved, potentially allowing an attacker to negotiate a TLS version or configuration that did not reflect the operator's intended security posture. The patch ensures TLS options are resolved and applied even when the router derives its TLS config from the entry point, and includes a regression test (TestWithEntryPointTLSConfig) and a test asserting resolution in published configurations (TestEntryPointTLSResolvedOptions). This constitutes a TLS configuration correctness fix with security implications (misconfiguration could weaken TLS).
Proof of Concept
Proof-of-concept (exploit) steps to demonstrate pre-fix weakness:
Prerequisites:
- Traefik build prior to this commit (3.7.x) with a router whose TLS config is inherited from an entry point (no per-router TLS block), and a tls.options entry that limits maxVersion to TLS1.2 (VersionTLS12).
- A test/fixture setup similar to integration/fixtures/https/https_entrypoint_tls.toml, exposing two entry points: one using direct TLS config and another using entryPoints.websecure-options that references a named TLS options object (foo) with maxVersion = VersionTLS12.
- Certificates configured for both hosts (snittest.com and snittest.org).
Attack workflow (PoC):
1) Start Traefik with the pre-fix configuration where a router relies on the entry-point TLS configuration and TLS options resolution is not applied to routers derived from entry points.
2) From a client capable of TLS1.3, connect to the router port that uses the entry-point options (e.g., https://127.0.0.1:4444/) using a TLS1.3 handshake:
openssl s_client -connect 127.0.0.1:4444 -servername snittest.org -tls1_3
(or a Go/Go TLS client with MinVersion/TLS1_3 support).
3) Observe a successful TLS handshake despite the operator-specified maxVersion = VersionTLS12, indicating the TLS options were not resolved for the router derived from the entry point.
4) Demonstrate the intended secure behavior after the fix by repeating the handshake; it should fail or negotiate no stronger than TLS1.2 (depending on the exact server behavior) when the TLS options are properly resolved. In the pre-fix state this step would instead succeed, illustrating the flaw.
Plausible exploit payload (illustrative):
- Client negotiates TLS1.3 with the origin server behind Traefik, bypassing the operator’s restriction to TLS1.2 for the affected router.
- Risk: downgrade resistance and cipher suite/profile alignment may be violated if future extensions rely on the same misconfiguration pattern.
Note: The repository's test TestWithEntryPointTLSConfig and the regression test TestEntryPointTLSResolvedOptions model this PoC scenario. The fix ensures TLS options from the entry point are resolved and enforced for routers that rely on entry-point TLS configuration.
Commit Details
Author: romain
Date: 2026-06-04 12:06 UTC
Message:
Merge branch v3.6 into v3.7
Triage Assessment
Vulnerability Type: TLS configuration issue
Confidence: MEDIUM
Reasoning:
The patch addresses TLS options resolution for entry-point based TLS config, ensuring TLS options are correctly resolved and applied even when a router relies on entry-point TLS configuration. A regression test enforces this behavior, implying prior misconfiguration could lead to insecure TLS settings.
Verification Assessment
Vulnerability Type: TLS configuration issue
Confidence: MEDIUM
Affected Versions: 3.7.0-ea.3 (and earlier in the 3.7 development stream)
Code Diff
diff --git a/go.mod b/go.mod
index f8e2d281d9..de812f1bd1 100644
--- a/go.mod
+++ b/go.mod
@@ -22,7 +22,7 @@ require (
github.com/docker/cli v29.4.0+incompatible
github.com/docker/go-connections v0.6.0
github.com/fatih/structs v1.1.0
- github.com/fsnotify/fsnotify v1.9.0
+ github.com/fsnotify/fsnotify v1.10.1
github.com/go-acme/lego/v4 v4.35.2
github.com/go-kit/kit v0.13.0
github.com/go-kit/log v0.2.1
diff --git a/integration/fixtures/https/https_entrypoint_tls.toml b/integration/fixtures/https/https_entrypoint_tls.toml
new file mode 100644
index 0000000000..26a85c8128
--- /dev/null
+++ b/integration/fixtures/https/https_entrypoint_tls.toml
@@ -0,0 +1,53 @@
+[global]
+ checkNewVersion = false
+ sendAnonymousUsage = false
+
+[log]
+ level = "DEBUG"
+
+[entryPoints]
+ [entryPoints.websecure]
+ address = ":4443"
+ [entryPoints.websecure.http.tls]
+
+ [entryPoints.websecure-options]
+ address = ":4444"
+ [entryPoints.websecure-options.http.tls]
+ options = "foo"
+
+[api]
+ insecure = true
+
+[providers.file]
+ filename = "{{ .SelfFilename }}"
+
+## dynamic configuration ##
+
+[http.routers]
+ [http.routers.router1]
+ entryPoints = ["websecure"]
+ service = "service1"
+ rule = "Host(`snitest.com`)"
+
+ [http.routers.router2]
+ entryPoints = ["websecure-options"]
+ service = "service1"
+ rule = "Host(`snitest.org`)"
+
+[http.services]
+ [http.services.service1]
+ [http.services.service1.loadBalancer]
+ [[http.services.service1.loadBalancer.servers]]
+ url = "http://127.0.0.1:9010"
+
+[[tls.certificates]]
+ certFile = "fixtures/https/snitest.com.cert"
+ keyFile = "fixtures/https/snitest.com.key"
+
+[[tls.certificates]]
+ certFile = "fixtures/https/snitest.org.cert"
+ keyFile = "fixtures/https/snitest.org.key"
+
+[tls.options]
+ [tls.options.foo]
+ maxVersion = "VersionTLS12"
diff --git a/integration/https_test.go b/integration/https_test.go
index ab3e190615..e962337e67 100644
--- a/integration/https_test.go
+++ b/integration/https_test.go
@@ -150,7 +150,69 @@ func (s *HTTPSSuite) TestWithSNIConfigRoute() {
require.NoError(s.T(), err)
}
-// TestWithTLSOptions verifies that traefik routes the requests with the associated tls options.
+// TestWithEntryPointTLSConfig verifies that a router relying on the entry point
+// TLS configuration (without an explicit router TLS section) is served over HTTPS,
+// including when the entry point references user-defined TLS options.
+// Regression test for https://github.com/traefik/traefik/issues/13289.
+func (s *HTTPSSuite) TestWithEntryPointTLSConfig() {
+ file := s.adaptFile("fixtures/https/https_entrypoint_tls.toml", struct{}{})
+ s.traefikCmd(withConfigFile(file))
+
+ // wait for Traefik
+ err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("Host(`snitest.com`)"))
+ require.NoError(s.T(), err)
+
+ backend := startTestServer("9010", http.StatusNoContent, "")
+ defer backend.Close()
+
+ err = try.GetRequest(backend.URL, 1*time.Second, try.StatusCodeIs(http.StatusNoContent))
+ require.NoError(s.T(), err)
+
+ tr := &http.Transport{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true,
+ ServerName: "snitest.com",
+ },
+ }
+
+ req, err := http.NewRequest(http.MethodGet, "https://127.0.0.1:4443/", nil)
+ require.NoError(s.T(), err)
+ req.Host = tr.TLSClientConfig.ServerName
+ req.Header.Set("Host", tr.TLSClientConfig.ServerName)
+ req.Header.Set("Accept", "*/*")
+
+ err = try.RequestWithTransport(req, 30*time.Second, tr, try.HasCn(tr.TLSClientConfig.ServerName), try.StatusCodeIs(http.StatusNoContent))
+ require.NoError(s.T(), err)
+
+ // The websecure-options entry point references the user-defined "foo" TLS options (maxVersion VersionTLS12).
+ // A request with no router-level TLS must still have these options resolved and applied.
+ trOptions := &http.Transport{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true,
+ ServerName: "snitest.org",
+ },
+ }
+
+ req, err = http.NewRequest(http.MethodGet, "https://127.0.0.1:4444/", nil)
+ require.NoError(s.T(), err)
+ req.Host = trOptions.TLSClientConfig.ServerName
+ req.Header.Set("Host", trOptions.TLSClientConfig.ServerName)
+ req.Header.Set("Accept", "*/*")
+
+ err = try.RequestWithTransport(req, 30*time.Second, trOptions, try.HasCn(trOptions.TLSClientConfig.ServerName), try.StatusCodeIs(http.StatusNoContent))
+ require.NoError(s.T(), err)
+
+ // A TLS 1.3-only client must fail the handshake, proving the "foo" options
+ // (resolved from the entry point) are effectively enforced.
+ _, err = tls.Dial("tcp", "127.0.0.1:4444", &tls.Config{
+ InsecureSkipVerify: true,
+ ServerName: "snitest.org",
+ MinVersion: tls.VersionTLS13,
+ })
+ assert.Error(s.T(), err)
+}
+
+// TestWithTLSOptions verifies that traefik routes the requests with the associated tls options.
func (s *HTTPSSuite) TestWithTLSOptions() {
file := s.adaptFile("fixtures/https/https_tls_options.toml", struct{}{})
s.traefikCmd(withConfigFile(file))
diff --git a/pkg/server/aggregator.go b/pkg/server/aggregator.go
index 652db41352..583fa15157 100644
--- a/pkg/server/aggregator.go
+++ b/pkg/server/aggregator.go
@@ -170,7 +170,7 @@ func mergeConfiguration(configurations dynamic.Configurations, defaultEntryPoint
delete(conf.TLS.Options, traefiktls.DefaultTLSConfigName)
}
- return resolveHTTPTLSOptions(conf)
+ return conf
}
func resolveHTTPTLSOptions(cfg dynamic.Configuration) dynamic.Configuration {
diff --git a/pkg/server/configurationwatcher.go b/pkg/server/configurationwatcher.go
index 747f476f65..919c75d0bf 100644
--- a/pkg/server/configurationwatcher.go
+++ b/pkg/server/configurationwatcher.go
@@ -176,6 +176,7 @@ func (c *ConfigurationWatcher) applyConfigurations(ctx context.Context) {
conf := mergeConfiguration(newConfigs.DeepCopy(), c.defaultEntryPoints)
conf = applyModel(conf)
+ conf = resolveHTTPTLSOptions(conf)
for _, listener := range c.configurationListeners {
listener(conf)
diff --git a/pkg/server/configurationwatcher_test.go b/pkg/server/configurationwatcher_test.go
index 40df7b5aad..a9f5920948 100644
--- a/pkg/server/configurationwatcher_test.go
+++ b/pkg/server/configurationwatcher_test.go
@@ -992,3 +992,52 @@ func TestConfigurationWatcher_MultipleTransformers(t *testing.T) {
assert.Equal(t, 1, callCount1)
assert.Equal(t, 1, callCount2)
}
+
+// TestEntryPointTLSResolvedOptions is a regression test for
+// https://github.com/traefik/traefik/issues/13289: a router whose TLS
+// configuration comes from the entry point (and not from an explicit router TLS
+// section) must still have its TLS options resolved in the published configuration.
+func TestEntryPointTLSResolvedOptions(t *testing.T) {
+ routinesPool := safe.NewPool(t.Context())
+ t.Cleanup(routinesPool.Stop)
+
+ pvd := &mockProvider{
+ messages: []dynamic.Message{{
+ ProviderName: "internal",
+ Configuration: &dynamic.Configuration{
+ HTTP: &dynamic.HTTPConfiguration{
+ Routers: map[string]*dynamic.Router{
+ "foo": {
+ EntryPoints: []string{"websecure"},
+ Rule: "Host(`foo.example.com`)",
+ Service: "service",
+ },
+ },
+ Models: map[string]*dynamic.Model{
+ "websecure": {
+ TLS: &dynamic.RouterTLSConfig{},
+ },
+ },
+ },
+ },
+ }},
+ }
+
+ watcher := NewConfigurationWatcher(routinesPool, pvd, []string{}, "")
+
+ run := make(chan struct{})
+ watcher.AddListener(func(conf dynamic.Configuration) {
+ router := conf.HTTP.Routers["foo@internal"]
+ if router == nil || router.TLS == nil {
+ return
+ }
+
+ assert.Equal(t, "default", router.TLS.ResolvedOptions)
+ close(run)
+ })
+
+ watcher.Start()
+ t.Cleanup(watcher.Stop)
+
+ <-run
+}