mirror of
https://github.com/traefik/traefik.git
synced 2026-06-08 16:22:52 -04:00
Merge branch v2.11 into v3.6
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Build and Publish Documentation / Doc Process (push) Waiting to run
Build experimental image on branch / build-webui (push) Waiting to run
Build experimental image on branch / Build experimental image on branch (push) Waiting to run
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Build and Publish Documentation / Doc Process (push) Waiting to run
Build experimental image on branch / build-webui (push) Waiting to run
Build experimental image on branch / Build experimental image on branch (push) Waiting to run
This commit is contained in:
commit
b1b520b186
15 changed files with 227 additions and 113 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -1,3 +1,14 @@
|
|||
## [v2.11.41](https://github.com/traefik/traefik/tree/v2.11.41) (2026-03-18)
|
||||
[All Commits](https://github.com/traefik/traefik/compare/v2.11.40...v2.11.41)
|
||||
|
||||
**Bug fixes:**
|
||||
- **[http]** Add maxResponseBodySize configuration on HTTP provider ([#12788](https://github.com/traefik/traefik/pull/12788) @gndz07)
|
||||
- **[tls]** Support fragmented TLS client hello ([#12787](https://github.com/traefik/traefik/pull/12787) @rtribotte)
|
||||
- **[middleware, authentication]** Make basic auth check timing constant ([#12803](https://github.com/traefik/traefik/pull/12803) @rtribotte)
|
||||
|
||||
**Documentation:**
|
||||
- Bump mkdocs-traefiklabs to use consent mode ([#12804](https://github.com/traefik/traefik/pull/12804) @darkweaver87)
|
||||
|
||||
## [v3.6.10](https://github.com/traefik/traefik/tree/v3.6.10) (2026-03-06)
|
||||
[All Commits](https://github.com/traefik/traefik/compare/v3.6.9...v3.6.10)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ Adding Basic Authentication
|
|||
|
||||
The BasicAuth middleware grants access to services to authorized users only.
|
||||
|
||||
!!! warning "Timing attacks"
|
||||
|
||||
The BasicAuth middleware is vulnerable to timing attacks when the configured users' password hashes do not use the same algorithm and cost.
|
||||
However, when the configured user's password hashes are of the same algorithm and cost, the middleware guarantees the same comparison time between existing and non-existing users.
|
||||
This prevents an attacker from leveraging the time difference to determine whether a user exists.
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
```yaml tab="Docker & Swarm"
|
||||
|
|
|
|||
|
|
@ -200,3 +200,25 @@ providers:
|
|||
```bash tab="CLI"
|
||||
--providers.http.tls.insecureSkipVerify=true
|
||||
```
|
||||
|
||||
### `maxResponseBodySize`
|
||||
|
||||
_Optional, Default=-1_
|
||||
|
||||
Defines the maximum size of the response body in bytes.
|
||||
If left unset (or set to -1), the response body size is unrestricted which can have performance implications.
|
||||
|
||||
```yaml tab="File (YAML)"
|
||||
providers:
|
||||
http:
|
||||
maxResponseBodySize: -1
|
||||
```
|
||||
|
||||
```toml tab="File (TOML)"
|
||||
[providers.http]
|
||||
maxResponseBodySize = -1
|
||||
```
|
||||
|
||||
```bash tab="CLI"
|
||||
--providers.http.maxResponseBodySize=-1
|
||||
```
|
||||
|
|
|
|||
|
|
@ -321,6 +321,7 @@ THIS FILE MUST NOT BE EDITED BY HAND
|
|||
| <a id="opt-providers-http" href="#opt-providers-http" title="#opt-providers-http">providers.http</a> | Enables HTTP provider. | false |
|
||||
| <a id="opt-providers-http-endpoint" href="#opt-providers-http-endpoint" title="#opt-providers-http-endpoint">providers.http.endpoint</a> | Load configuration from this endpoint. | |
|
||||
| <a id="opt-providers-http-headers-name" href="#opt-providers-http-headers-name" title="#opt-providers-http-headers-name">providers.http.headers._name_</a> | Define custom headers to be sent to the endpoint. | |
|
||||
| <a id="opt-providers-http-maxresponsebodysize" href="#opt-providers-http-maxresponsebodysize" title="#opt-providers-http-maxresponsebodysize">providers.http.maxresponsebodysize</a> | Defines the maximum size of the response body in bytes. | -1 |
|
||||
| <a id="opt-providers-http-pollinterval" href="#opt-providers-http-pollinterval" title="#opt-providers-http-pollinterval">providers.http.pollinterval</a> | Polling interval for endpoint. | 5 |
|
||||
| <a id="opt-providers-http-polltimeout" href="#opt-providers-http-polltimeout" title="#opt-providers-http-polltimeout">providers.http.polltimeout</a> | Polling timeout for endpoint. | 5 |
|
||||
| <a id="opt-providers-http-tls-ca" href="#opt-providers-http-tls-ca" title="#opt-providers-http-tls-ca">providers.http.tls.ca</a> | TLS CA | |
|
||||
|
|
|
|||
|
|
@ -933,6 +933,9 @@ Load configuration from this endpoint.
|
|||
`--providers.http.headers.<name>`:
|
||||
Define custom headers to be sent to the endpoint.
|
||||
|
||||
`--providers.http.maxresponsebodysize`:
|
||||
Defines the maximum size of the response body in bytes. (Default: ```-1```)
|
||||
|
||||
`--providers.http.pollinterval`:
|
||||
Polling interval for endpoint. (Default: ```5```)
|
||||
|
||||
|
|
|
|||
|
|
@ -933,6 +933,9 @@ Load configuration from this endpoint.
|
|||
`TRAEFIK_PROVIDERS_HTTP_HEADERS_<NAME>`:
|
||||
Define custom headers to be sent to the endpoint.
|
||||
|
||||
`TRAEFIK_PROVIDERS_HTTP_MAXRESPONSEBODYSIZE`:
|
||||
Defines the maximum size of the response body in bytes. (Default: ```-1```)
|
||||
|
||||
`TRAEFIK_PROVIDERS_HTTP_POLLINTERVAL`:
|
||||
Polling interval for endpoint. (Default: ```5```)
|
||||
|
||||
|
|
|
|||
|
|
@ -310,6 +310,7 @@
|
|||
[providers.http.headers]
|
||||
name0 = "foobar"
|
||||
name1 = "foobar"
|
||||
maxResponseBodySize = 42
|
||||
[providers.http.tls]
|
||||
ca = "foobar"
|
||||
cert = "foobar"
|
||||
|
|
|
|||
|
|
@ -354,6 +354,7 @@ providers:
|
|||
cert: foobar
|
||||
key: foobar
|
||||
insecureSkipVerify: true
|
||||
maxResponseBodySize: 42
|
||||
plugin:
|
||||
PluginConf0:
|
||||
name0: foobar
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
mkdocs==1.4.3
|
||||
mkdocs-include-markdown-plugin==7.2.0
|
||||
mkdocs-exclude==1.0.2
|
||||
mkdocs-traefiklabs>=100.0.7
|
||||
mkdocs-traefiklabs>=100.1.0
|
||||
mkdocs-redirects==1.2.2
|
||||
|
||||
click==8.1.7
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ package auth
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
goauth "github.com/abbot/go-http-auth"
|
||||
|
|
@ -27,6 +29,7 @@ type basicAuth struct {
|
|||
removeHeader bool
|
||||
name string
|
||||
|
||||
notFoundSecret string
|
||||
checkSecret func(password, secret string) bool
|
||||
singleflightGroup *singleflight.Group
|
||||
}
|
||||
|
|
@ -40,12 +43,18 @@ func NewBasic(ctx context.Context, next http.Handler, authConfig dynamic.BasicAu
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// To prevent timing attacks, we need to compute a hash even if the user is not found.
|
||||
// We assume it to be safe only when the users hashes are all from the same algorithm,
|
||||
// so we can pick the first one as a random hash to compute.
|
||||
notFoundSecret := users[slices.Collect(maps.Values(users))[0]]
|
||||
|
||||
ba := &basicAuth{
|
||||
next: next,
|
||||
users: users,
|
||||
headerField: authConfig.HeaderField,
|
||||
removeHeader: authConfig.RemoveHeader,
|
||||
name: name,
|
||||
notFoundSecret: notFoundSecret,
|
||||
checkSecret: goauth.CheckSecret,
|
||||
singleflightGroup: new(singleflight.Group),
|
||||
}
|
||||
|
|
@ -68,8 +77,9 @@ func (b *basicAuth) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||
logger := middlewares.GetLogger(req.Context(), b.name, typeNameBasic)
|
||||
|
||||
user, password, ok := req.BasicAuth()
|
||||
var authenticated bool
|
||||
if ok {
|
||||
ok = b.checkPassword(user, password)
|
||||
authenticated = b.checkPassword(user, password)
|
||||
}
|
||||
|
||||
logData := accesslog.GetLogData(req)
|
||||
|
|
@ -77,7 +87,7 @@ func (b *basicAuth) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||
logData.Core[accesslog.ClientUsername] = user
|
||||
}
|
||||
|
||||
if !ok {
|
||||
if !authenticated {
|
||||
logger.Debug().Msg("Authentication failed")
|
||||
observability.SetStatusErrorf(req.Context(), "Authentication failed")
|
||||
|
||||
|
|
@ -101,19 +111,21 @@ func (b *basicAuth) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||
|
||||
func (b *basicAuth) checkPassword(user, password string) bool {
|
||||
secret := b.auth.Secrets(user, b.auth.Realm)
|
||||
if secret == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
key := password + secret
|
||||
match, _, _ := b.singleflightGroup.Do(key, func() (any, error) {
|
||||
if secret == "" {
|
||||
_ = b.checkSecret(password, b.notFoundSecret)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return b.checkSecret(password, secret), nil
|
||||
})
|
||||
|
||||
return match.(bool)
|
||||
}
|
||||
|
||||
func (b *basicAuth) secretBasic(user, realm string) string {
|
||||
func (b *basicAuth) secretBasic(user, _ string) string {
|
||||
if secret, ok := b.users[user]; ok {
|
||||
return secret
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import (
|
|||
|
||||
var _ provider.Provider = (*Provider)(nil)
|
||||
|
||||
const defaultMaxResponseBodySize = -1
|
||||
|
||||
// Provider is a provider.Provider implementation that queries an HTTP(s) endpoint for a configuration.
|
||||
type Provider struct {
|
||||
Endpoint string `description:"Load configuration from this endpoint." json:"endpoint" toml:"endpoint" yaml:"endpoint"`
|
||||
|
|
@ -35,12 +37,14 @@ type Provider struct {
|
|||
|
||||
httpClient *http.Client
|
||||
lastConfigurationHash uint64
|
||||
MaxResponseBodySize int64 `description:"Defines the maximum size of the response body in bytes." json:"maxResponseBodySize,omitempty" toml:"maxResponseBodySize,omitempty" yaml:"maxResponseBodySize,omitempty" export:"true"`
|
||||
}
|
||||
|
||||
// SetDefaults sets the default values.
|
||||
func (p *Provider) SetDefaults() {
|
||||
p.PollInterval = ptypes.Duration(5 * time.Second)
|
||||
p.PollTimeout = ptypes.Duration(5 * time.Second)
|
||||
p.MaxResponseBodySize = defaultMaxResponseBodySize
|
||||
}
|
||||
|
||||
// Init the provider.
|
||||
|
|
@ -168,7 +172,19 @@ func (p *Provider) fetchConfigurationData() ([]byte, error) {
|
|||
return nil, fmt.Errorf("received non-ok response code: %d", res.StatusCode)
|
||||
}
|
||||
|
||||
return io.ReadAll(res.Body)
|
||||
if p.MaxResponseBodySize < 0 {
|
||||
return io.ReadAll(res.Body)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(res.Body, p.MaxResponseBodySize+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
if int64(len(data)) > p.MaxResponseBodySize {
|
||||
return nil, errors.New("response body too large")
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// decodeConfiguration decodes and returns the dynamic configuration from the given data.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/traefik/traefik/v3/pkg/config/dynamic"
|
||||
"github.com/traefik/traefik/v3/pkg/safe"
|
||||
"github.com/traefik/traefik/v3/pkg/tls"
|
||||
"k8s.io/utils/ptr"
|
||||
)
|
||||
|
||||
func TestProvider_Init(t *testing.T) {
|
||||
|
|
@ -65,15 +66,17 @@ func TestProvider_SetDefaults(t *testing.T) {
|
|||
|
||||
assert.Equal(t, provider.PollInterval, ptypes.Duration(5*time.Second))
|
||||
assert.Equal(t, provider.PollTimeout, ptypes.Duration(5*time.Second))
|
||||
assert.Equal(t, int64(-1), provider.MaxResponseBodySize)
|
||||
}
|
||||
|
||||
func TestProvider_fetchConfigurationData(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
statusCode int
|
||||
headers map[string]string
|
||||
expData []byte
|
||||
expErr require.ErrorAssertionFunc
|
||||
desc string
|
||||
statusCode int
|
||||
headers map[string]string
|
||||
expData []byte
|
||||
expErr require.ErrorAssertionFunc
|
||||
maxResponseBodySize *int64
|
||||
}{
|
||||
{
|
||||
desc: "should return the fetched configuration data",
|
||||
|
|
@ -97,6 +100,25 @@ func TestProvider_fetchConfigurationData(t *testing.T) {
|
|||
statusCode: http.StatusInternalServerError,
|
||||
expErr: require.Error,
|
||||
},
|
||||
{
|
||||
desc: "should return an error response body is too long when maxResponseBodySize is 0",
|
||||
statusCode: http.StatusOK,
|
||||
maxResponseBodySize: ptr.To(int64(0)),
|
||||
expErr: require.Error,
|
||||
},
|
||||
{
|
||||
desc: "should return an error response body is too long when response is longer than maxResponseBodySize",
|
||||
statusCode: http.StatusOK,
|
||||
maxResponseBodySize: ptr.To(int64(1)),
|
||||
expErr: require.Error,
|
||||
},
|
||||
{
|
||||
desc: "should return the fetched configuration data when response is the same length with maxResponseBodySize",
|
||||
statusCode: http.StatusOK,
|
||||
maxResponseBodySize: ptr.To(int64(2)),
|
||||
expData: []byte("{}"),
|
||||
expErr: require.NoError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
|
@ -118,11 +140,15 @@ func TestProvider_fetchConfigurationData(t *testing.T) {
|
|||
}))
|
||||
defer srv.Close()
|
||||
|
||||
provider := Provider{
|
||||
Endpoint: srv.URL,
|
||||
Headers: test.headers,
|
||||
PollInterval: ptypes.Duration(1 * time.Second),
|
||||
PollTimeout: ptypes.Duration(1 * time.Second),
|
||||
var provider Provider
|
||||
provider.SetDefaults()
|
||||
|
||||
provider.Headers = test.headers
|
||||
provider.Endpoint = srv.URL
|
||||
provider.PollTimeout = ptypes.Duration(1 * time.Second)
|
||||
provider.PollInterval = ptypes.Duration(1 * time.Second)
|
||||
if test.maxResponseBodySize != nil {
|
||||
provider.MaxResponseBodySize = *test.maxResponseBodySize
|
||||
}
|
||||
|
||||
err := provider.Init()
|
||||
|
|
@ -201,11 +227,12 @@ func TestProvider_Provide(t *testing.T) {
|
|||
server := httptest.NewServer(http.HandlerFunc(handler))
|
||||
defer server.Close()
|
||||
|
||||
provider := Provider{
|
||||
Endpoint: server.URL,
|
||||
PollTimeout: ptypes.Duration(1 * time.Second),
|
||||
PollInterval: ptypes.Duration(100 * time.Millisecond),
|
||||
}
|
||||
var provider Provider
|
||||
provider.SetDefaults()
|
||||
|
||||
provider.Endpoint = server.URL
|
||||
provider.PollTimeout = ptypes.Duration(1 * time.Second)
|
||||
provider.PollInterval = ptypes.Duration(100 * time.Millisecond)
|
||||
|
||||
err := provider.Init()
|
||||
require.NoError(t, err)
|
||||
|
|
@ -257,11 +284,12 @@ func TestProvider_ProvideConfigurationOnlyOnceIfUnchanged(t *testing.T) {
|
|||
server := httptest.NewServer(http.HandlerFunc(handler))
|
||||
defer server.Close()
|
||||
|
||||
provider := Provider{
|
||||
Endpoint: server.URL + "/endpoint",
|
||||
PollTimeout: ptypes.Duration(1 * time.Second),
|
||||
PollInterval: ptypes.Duration(100 * time.Millisecond),
|
||||
}
|
||||
var provider Provider
|
||||
provider.SetDefaults()
|
||||
|
||||
provider.Endpoint = server.URL + "/endpoint"
|
||||
provider.PollTimeout = ptypes.Duration(1 * time.Second)
|
||||
provider.PollInterval = ptypes.Duration(100 * time.Millisecond)
|
||||
|
||||
err := provider.Init()
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
|
|
@ -51,7 +51,8 @@ func (r *Router) servePostgres(conn tcp.WriteCloser) {
|
|||
return
|
||||
}
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
var peeked bytes.Buffer
|
||||
br := bufio.NewReader(io.TeeReader(conn, &peeked))
|
||||
|
||||
b := make([]byte, len(PostgresStartTLSMsg))
|
||||
_, err = br.Read(b)
|
||||
|
|
@ -93,7 +94,7 @@ func (r *Router) servePostgres(conn tcp.WriteCloser) {
|
|||
}
|
||||
|
||||
// We are in TLS mode and if the handler is not TLSHandler, we are in passthrough.
|
||||
proxiedConn := r.GetConn(conn, hello.peeked)
|
||||
proxiedConn := r.GetConn(conn, peeked.String())
|
||||
if _, ok := handlerTCPTLS.(*tcp.TLSHandler); !ok {
|
||||
proxiedConn = &postgresConn{WriteCloser: proxiedConn}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,15 +19,8 @@ import (
|
|||
"github.com/traefik/traefik/v3/pkg/tcp"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBufSize = 4096
|
||||
// Per RFC 8446 Section 5.1, the maximum TLS record payload length is 2^14 (16384) bytes.
|
||||
// A ClientHello is always a plaintext record, so any value exceeding this limit is invalid
|
||||
// and likely indicates an attack attempting to force oversized per-connection buffer allocations.
|
||||
// However, in practice the go server handshake can read up to 16384 + 2048 bytes,
|
||||
// so we need to allow for some extra bytes to avoid rejecting valid handshakes.
|
||||
maxTLSRecordLen = 16384 + 2048
|
||||
)
|
||||
// errClientHelloRead is used as a sentinel error to break the TLS handshake once we have read the ClientHello.
|
||||
var errClientHelloRead = errors.New("client hello successfully read")
|
||||
|
||||
// Router is a TCP router.
|
||||
type Router struct {
|
||||
|
|
@ -127,7 +120,9 @@ func (r *Router) ServeTCP(conn tcp.WriteCloser) {
|
|||
}
|
||||
|
||||
// TODO -- Check if ProxyProtocol changes the first bytes of the request
|
||||
br := bufio.NewReader(conn)
|
||||
var peeked bytes.Buffer
|
||||
br := bufio.NewReader(io.TeeReader(conn, &peeked))
|
||||
|
||||
postgres, err := isPostgres(br)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
|
|
@ -135,7 +130,7 @@ func (r *Router) ServeTCP(conn tcp.WriteCloser) {
|
|||
}
|
||||
|
||||
if postgres {
|
||||
r.servePostgres(r.GetConn(conn, getPeeked(br)))
|
||||
r.servePostgres(r.GetConn(conn, peeked.String()))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -168,9 +163,9 @@ func (r *Router) ServeTCP(conn tcp.WriteCloser) {
|
|||
handler, _ := r.muxerTCP.Match(connData)
|
||||
switch {
|
||||
case handler != nil:
|
||||
handler.ServeTCP(r.GetConn(conn, hello.peeked))
|
||||
handler.ServeTCP(r.GetConn(conn, peeked.String()))
|
||||
case r.httpForwarder != nil:
|
||||
r.httpForwarder.ServeTCP(r.GetConn(conn, hello.peeked))
|
||||
r.httpForwarder.ServeTCP(r.GetConn(conn, peeked.String()))
|
||||
default:
|
||||
conn.Close()
|
||||
}
|
||||
|
|
@ -179,7 +174,7 @@ func (r *Router) ServeTCP(conn tcp.WriteCloser) {
|
|||
|
||||
// Handling ACME-TLS/1 challenges.
|
||||
if !r.acmeTLSPassthrough && slices.Contains(hello.protos, tlsalpn01.ACMETLS1Protocol) {
|
||||
r.acmeTLSALPNHandler().ServeTCP(r.GetConn(conn, hello.peeked))
|
||||
r.acmeTLSALPNHandler().ServeTCP(r.GetConn(conn, peeked.String()))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -193,14 +188,14 @@ func (r *Router) ServeTCP(conn tcp.WriteCloser) {
|
|||
// In order not to depart from the behavior in 2.6,
|
||||
// we only allow an HTTPS router to take precedence over a TCP-TLS router if it is _not_ an HostSNI(*) router
|
||||
// (so basically any router that has a specific HostSNI based rule).
|
||||
handlerHTTPS.ServeTCP(r.GetConn(conn, hello.peeked))
|
||||
handlerHTTPS.ServeTCP(r.GetConn(conn, peeked.String()))
|
||||
return
|
||||
}
|
||||
|
||||
// Contains also TCP TLS passthrough routes.
|
||||
handlerTCPTLS, catchAllTCPTLS := r.muxerTCPTLS.Match(connData)
|
||||
if handlerTCPTLS != nil && !catchAllTCPTLS {
|
||||
handlerTCPTLS.ServeTCP(r.GetConn(conn, hello.peeked))
|
||||
handlerTCPTLS.ServeTCP(r.GetConn(conn, peeked.String()))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -208,19 +203,19 @@ func (r *Router) ServeTCP(conn tcp.WriteCloser) {
|
|||
// We end up here for e.g. an HTTPS router that only has a PathPrefix rule,
|
||||
// which under the scenes is counted as an HostSNI(*) rule.
|
||||
if handlerHTTPS != nil {
|
||||
handlerHTTPS.ServeTCP(r.GetConn(conn, hello.peeked))
|
||||
handlerHTTPS.ServeTCP(r.GetConn(conn, peeked.String()))
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback on TCP TLS catchAll.
|
||||
if handlerTCPTLS != nil {
|
||||
handlerTCPTLS.ServeTCP(r.GetConn(conn, hello.peeked))
|
||||
handlerTCPTLS.ServeTCP(r.GetConn(conn, peeked.String()))
|
||||
return
|
||||
}
|
||||
|
||||
// To handle 404s for HTTPS.
|
||||
if r.httpsForwarder != nil {
|
||||
r.httpsForwarder.ServeTCP(r.GetConn(conn, hello.peeked))
|
||||
r.httpsForwarder.ServeTCP(r.GetConn(conn, peeked.String()))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -375,7 +370,6 @@ type clientHello struct {
|
|||
serverName string // SNI server name
|
||||
protos []string // ALPN protocols list
|
||||
isTLS bool // whether we are a TLS handshake
|
||||
peeked string // the bytes peeked from the hello while getting the info
|
||||
}
|
||||
|
||||
// clientHelloInfo returns various data from the clientHello handshake,
|
||||
|
|
@ -396,74 +390,46 @@ func clientHelloInfo(br *bufio.Reader) (*clientHello, error) {
|
|||
if hdr[0] == recordTypeSSLv2 {
|
||||
// we consider SSLv2 as TLS, and it will be refused by real TLS handshake.
|
||||
return &clientHello{
|
||||
isTLS: true,
|
||||
peeked: getPeeked(br),
|
||||
isTLS: true,
|
||||
}, nil
|
||||
}
|
||||
return &clientHello{
|
||||
peeked: getPeeked(br),
|
||||
}, nil // Not TLS.
|
||||
return &clientHello{}, nil // Not TLS.
|
||||
}
|
||||
|
||||
const recordHeaderLen = 5
|
||||
hdr, err = br.Peek(recordHeaderLen)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("peeking client hello headers: %w", err)
|
||||
}
|
||||
|
||||
recLen := int(hdr[3])<<8 | int(hdr[4]) // ignoring version in hdr[1:3]
|
||||
|
||||
if recLen > maxTLSRecordLen {
|
||||
return nil, fmt.Errorf("peeking client hello bytes, oversized record: %d", recLen)
|
||||
}
|
||||
|
||||
if recordHeaderLen+recLen > defaultBufSize {
|
||||
br = bufio.NewReaderSize(br, recordHeaderLen+recLen)
|
||||
}
|
||||
|
||||
helloBytes, err := br.Peek(recordHeaderLen + recLen)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("peeking client hello bytes: %w", err)
|
||||
}
|
||||
|
||||
sni := ""
|
||||
var protos []string
|
||||
server := tls.Server(helloSniffConn{r: bytes.NewReader(helloBytes)}, &tls.Config{
|
||||
var (
|
||||
sni string
|
||||
protos []string
|
||||
)
|
||||
server := tls.Server(readOnlyConn{r: br}, &tls.Config{
|
||||
GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
sni = hello.ServerName
|
||||
protos = hello.SupportedProtos
|
||||
return nil, nil
|
||||
// This error prevents unnecessary additional steps in the TLS ClientHello message processing.
|
||||
return nil, errClientHelloRead
|
||||
},
|
||||
})
|
||||
_ = server.Handshake()
|
||||
|
||||
if handshakeErr := server.Handshake(); !errors.Is(handshakeErr, errClientHelloRead) {
|
||||
return nil, fmt.Errorf("reading client hello: %w", handshakeErr)
|
||||
}
|
||||
|
||||
return &clientHello{
|
||||
serverName: sni,
|
||||
isTLS: true,
|
||||
peeked: getPeeked(br),
|
||||
protos: protos,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getPeeked(br *bufio.Reader) string {
|
||||
peeked, err := br.Peek(br.Buffered())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error while peeking bytes")
|
||||
return ""
|
||||
}
|
||||
return string(peeked)
|
||||
}
|
||||
|
||||
// helloSniffConn is a net.Conn that reads from r, fails on Writes,
|
||||
// readOnlyConn is a net.Conn that reads from r, fails on Writes,
|
||||
// and crashes otherwise.
|
||||
type helloSniffConn struct {
|
||||
type readOnlyConn struct {
|
||||
net.Conn // nil; crash on any unexpected use
|
||||
|
||||
r io.Reader
|
||||
}
|
||||
|
||||
// Read reads from the underlying reader.
|
||||
func (c helloSniffConn) Read(p []byte) (int, error) { return c.r.Read(p) }
|
||||
func (c readOnlyConn) Read(p []byte) (int, error) { return c.r.Read(p) }
|
||||
|
||||
// Write crashes all the time.
|
||||
func (helloSniffConn) Write(p []byte) (int, error) { return 0, io.EOF }
|
||||
func (readOnlyConn) Write(_ []byte) (int, error) { return 0, io.EOF }
|
||||
|
|
|
|||
|
|
@ -1151,12 +1151,34 @@ func Test_clientHelloInfo_oversizedRecordLength(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// Test_clientHelloInfo_validRecordLength verifies that clientHelloInfo
|
||||
// still works correctly with legitimate TLS record sizes.
|
||||
func Test_clientHelloInfo_validRecordLength(t *testing.T) {
|
||||
// Test_clientHelloInfo_tlsRecordFragmentation documents a known limitation:
|
||||
// clientHelloInfo only reads a single TLS record. When a ClientHello handshake
|
||||
// message is split across multiple TLS records (RFC 5246 §6.2.1), the SNI cannot
|
||||
// be extracted, leaving serverName empty and allowing SNI-based routing to be bypassed.
|
||||
func Test_clientHelloInfo_tlsRecordFragmentation(t *testing.T) {
|
||||
serverName := "foo.example.com"
|
||||
record := buildClientHelloRecord(t, serverName)
|
||||
|
||||
const hdrLen = 5
|
||||
payload := record[hdrLen:]
|
||||
|
||||
ver1, ver2 := record[1], record[2]
|
||||
|
||||
var recordsData bytes.Buffer
|
||||
for _, part := range [][]byte{payload[:len(serverName)/2], payload[len(serverName)/2:]} {
|
||||
recordsData.WriteByte(0x16)
|
||||
recordsData.WriteByte(ver1)
|
||||
recordsData.WriteByte(ver2)
|
||||
recordsData.WriteByte(byte(len(part) >> 8))
|
||||
recordsData.WriteByte(byte(len(part)))
|
||||
recordsData.Write(part)
|
||||
}
|
||||
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer serverConn.Close()
|
||||
defer clientConn.Close()
|
||||
t.Cleanup(func() {
|
||||
_ = serverConn.Close()
|
||||
_ = clientConn.Close()
|
||||
})
|
||||
|
||||
type result struct {
|
||||
hello *clientHello
|
||||
|
|
@ -1170,30 +1192,51 @@ func Test_clientHelloInfo_validRecordLength(t *testing.T) {
|
|||
resultCh <- result{hello, err}
|
||||
}()
|
||||
|
||||
// Build a TLS record header with a small (valid) record length.
|
||||
recLen := 100
|
||||
hdr := []byte{
|
||||
0x16, // Content Type: Handshake
|
||||
0x03, 0x03, // Version: TLS 1.2
|
||||
byte(recLen >> 8), // Length high byte
|
||||
byte(recLen & 0xFF), // Length low byte
|
||||
}
|
||||
payload := make([]byte, recLen)
|
||||
|
||||
_, err := clientConn.Write(append(hdr, payload...))
|
||||
_, err := clientConn.Write(recordsData.Bytes())
|
||||
require.NoError(t, err)
|
||||
clientConn.Close()
|
||||
_ = clientConn.Close()
|
||||
|
||||
select {
|
||||
case r := <-resultCh:
|
||||
require.NoError(t, r.err)
|
||||
require.NotNil(t, r.hello)
|
||||
assert.True(t, r.hello.isTLS)
|
||||
assert.Equal(t, serverName, r.hello.serverName)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("clientHelloInfo blocked on valid TLS record")
|
||||
t.Fatal("clientHelloInfo blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// buildClientHelloRecord captures a real TLS ClientHello record from Go's TLS stack
|
||||
// for the given serverName.
|
||||
// It returns the raw record bytes and the byte offset of the SNI value within those bytes.
|
||||
func buildClientHelloRecord(t *testing.T, serverName string) []byte {
|
||||
t.Helper()
|
||||
|
||||
serverConn, clientConn := net.Pipe()
|
||||
|
||||
recordCh := make(chan []byte, 1)
|
||||
go func() {
|
||||
buf := make([]byte, 65536)
|
||||
n, _ := serverConn.Read(buf)
|
||||
_ = serverConn.Close()
|
||||
recordCh <- buf[:n]
|
||||
}()
|
||||
|
||||
go func() {
|
||||
tlsConn := tls.Client(clientConn, &tls.Config{
|
||||
ServerName: serverName,
|
||||
InsecureSkipVerify: true, //nolint:gosec
|
||||
})
|
||||
_ = tlsConn.Handshake()
|
||||
_ = clientConn.Close()
|
||||
}()
|
||||
|
||||
record := <-recordCh
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
func TestPostgres(t *testing.T) {
|
||||
router, err := NewRouter()
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
Loading…
Reference in a new issue