Repository: traefik/traefik (language: go)

Issue:
[fastproxy] after update from 3.3.2 to 3.3.3 some pages do not work
### Welcome!

- [x] Yes, I've searched similar issues on [GitHub](https://github.com/traefik/traefik/issues) and didn't find any.
- [x] Yes, I've searched similar issues on the [Traefik community forum](https://community.traefik.io) and didn't find any.

### What did you do?

First, Traefik works as expected while the experimental fastProxy feature is disabled.

But when I have
```
experimental:
  fastProxy:
    debug: true
```
configured in the static configuration file, I get blank pages with some services. For example, Gatus and Grafana won't work. The Traefik dashboard works as expected and shows no errors or anomalies. The malfunctioning services are listed as running with no errors.

The error occurs only with Traefik 3.3.3. It worked with fastProxy enabled with version 3.3.2.


### What did you see instead?

The browser shows a blank page/timeout

![Image](https://github.com/user-attachments/assets/7c07ce68-f2c3-4476-87c8-bb44be4a8dcd)

### What version of Traefik are you using?

```
Version:      3.3.3
Codename:     saintnectaire
Go version:   go1.23.5
Built:        2025-01-31T14:55:01Z
OS/Arch:      linux/amd64
```


### What is your environment & configuration?

```yaml
log:
#  level: DEBUG
  level: INFO
#accessLog:
#  filePath: /logs/access.log
#  format: json
#  fields:
#    headers:
#      defaultMode: keep
providers:
  docker:
    exposedByDefault: false
#    endpoint: 'unix:///var/run/docker.sock' # für Betrieb ohne Docker Socket Proxy
    endpoint: 'tcp://dockerproxy:2375'
    network: traefik_proxy
  file:
    directory: /etc/traefik/dynamic
    watch: true
api:
  dashboard: true
  insecure: true
ping:
  entryPoint: web
entryPoints:
  web:
    address: ':80'
    http:
      redirections:
        entryPoint:
          to: ':443'
          scheme: https
  web-secure:
    address: ':443'
    http3: false
    http:
      encodeQuerySemicolons: true
      middlewares:
#        - autodetectContenttype@file
        - default-inflightreq@file
    transport:
      respondingTimeouts:
        readTimeout: 15m
        writeTimeout: 15m
        idleTimeout: 15m
certificatesResolvers:
  default:
    acme:
      email: *******
      storage: /etc/traefik/ACME/acme.json
      tlsChallenge: {}
  acmedns:
    acme:
      email: *******
      storage: /etc/traefik/ACME/acmedns.json
#       caServer: 'https://acme-staging-v02.api.letsencrypt.org/directory' # Staging, zum Testen Kommentar entfernen
      dnsChallenge:
        provider: *******
        delayBeforeCheck: 1s
        resolvers:
          - "1.1.1.1:53"
          - "8.8.8.8:53"
experimental:
  fastProxy:
    debug: true
metrics:
  prometheus:
    manualRouting: true
    buckets:
      - 0.1
      - 0.3
      - 1.2
      - 5
```


### If applicable, please paste the log output in DEBUG level

(no relevant log output even in DEBUG mode)


Discussion / hints:
Hello @wollomatic and thanks for raising this issue,

If you or any community member can help us find verified steps to reproduce the issue, we would love the help.
While I can't reproduce the issue with a small traefik/whoami image, I examined the requests to our `twinproduction/gatus` container with burp suite.

Small HTML pages are no problem, but I run into the issue while requesting js files: Without fastProxy everything looks great:

![Image](https://github.com/user-attachments/assets/2e2c4b2b-e9a3-4069-ad88-bfdee3c4a543)

But with fastProxy enabled the content seems corrupted/compressed/whatever:

![Image](https://github.com/user-attachments/assets/4be1b2d2-0cf8-4299-960f-ac5bf9688c16)

Also, the response needed about 15 seconds.

The docker-compose.yaml ist quite boring:
```
services:
  gatus:
    image: ghcr.io/twin/gatus:v5.15.0
    restart: always
    init: true
    depends_on:
      - postgres
    read_only: true
    user: 65534:65534
    mem_limit: 8GB
    environment:
      - ... # skipped
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.gatus.entrypoints=web-secure"
      - "traefik.http.routers.gatus.rule=Host(`*****.de`)"
      - "traefik.http.routers.gatus.tls=true"
      - "traefik.http.routers.gatus.tls.certresolver=default"
      - "traefik.http.routers.gatus.middlewares=default-ratelimit@file,secHeaders@file"
      - "traefik.http.routers.gatus.service=gatus"
      - "traefik.http.services.gatus.loadbalancer.server.port=8080"
      # block metrics endpoint for external access
      - "traefik.http.routers.gatus-metrics.rule=Host(`*****.de`)&&PathPrefix(`/metrics`)"
      - "traefik.http.routers.gatus-metrics.tls=true"
      - "traefik.http.routers.gatus-metrics.tls.certresolver=default"
      - "traefik.http.routers.gatus-metrics.middlewares=default-ratelimit@file,secHeaders@file"
      - "traefik.http.routers.gatus-metrics.service=noop@internal" # 418 I'm a teapot
    volumes:
     - ./config:/config:ro
    networks:
      - traefik_proxy
      - postgres
# [...]
```
I can reproduce it on my homelab environment. File size doesn't seem to be the problem. My static hugo blog works, my comentario instance not.
I think this issue related to https://github.com/traefik/traefik/pull/11458, maybe we should check chunked response before no content-length header response; Chuncked response also without content-length.

Fix patch:
diff --git a/pkg/proxy/fast/connpool.go b/pkg/proxy/fast/connpool.go
index f6357c59df..7ef17e4598 100644
--- a/pkg/proxy/fast/connpool.go
+++ b/pkg/proxy/fast/connpool.go
@@ -215,34 +215,20 @@ func (c *conn) handleResponse(r rwWithUpgrade) error {
 		return nil
 	}
 
-	hasContentLength := len(res.Header.Peek("Content-Length")) > 0
-
-	if hasContentLength && res.Header.ContentLength() == 0 {
-		return nil
-	}
-
 	// When a body is not allowed for a given status code the body is ignored.
 	// The connection will be marked as broken by the next Peek in the readloop.
 	if !isBodyAllowedForStatus(res.StatusCode()) {
 		return nil
 	}
 
-	if !hasContentLength {
-		b := c.bufferPool.Get()
-		if b == nil {
-			b = make([]byte, bufferSize)
-		}
-		defer c.bufferPool.Put(b)
-
-		if _, err := io.CopyBuffer(r.RW, c.br, b); err != nil {
-			return err
-		}
+	contentLength := res.Header.ContentLength()
 
+	if contentLength == 0 {
 		return nil
 	}
 
 	// Chunked response, Content-Length is set to -1 by FastProxy when "Transfer-Encoding: chunked" header is received.
-	if res.Header.ContentLength() == -1 {
+	if contentLength == -1 {
 		cbr := httputil.NewChunkedReader(c.br)
 
 		b := c.bufferPool.Get()
@@ -282,6 +268,23 @@ func (c *conn) handleResponse(r rwWithUpgrade) error {
 		return nil
 	}
 
+	// Response without Content-Length header.
+	// The message body length is determined by the number of bytes received prior to the server closing the connection.
+	if contentLength == -2 {
+		b := c.bufferPool.Get()
+		if b == nil {
+			b = make([]byte, bufferSize)
+		}
+		defer c.bufferPool.Put(b)
+
+		if _, err := io.CopyBuffer(r.RW, c.br, b); err != nil {
+			return err
+		}
+
+		return nil
+	}
+
+	// Response with a valid Content-Length header.
 	brl := c.limitedReaderPool.Get()
 	if brl == nil {
 		brl = &io.LimitedReader{}


Test patch:
diff --git a/pkg/proxy/fast/proxy_test.go b/pkg/proxy/fast/proxy_test.go
index 4c4c158b17..143039cc65 100644
--- a/pkg/proxy/fast/proxy_test.go
+++ b/pkg/proxy/fast/proxy_test.go
@@ -306,7 +306,7 @@ func TestHeadRequest(t *testing.T) {
 	assert.Equal(t, http.StatusOK, res.Code)
 }
 
-func TestNoContentLengthResponse(t *testing.T) {
+func TestNoContentLength(t *testing.T) {
 	backendListener, err := net.Listen("tcp", ":0")
 	require.NoError(t, err)
 
@@ -346,6 +346,45 @@ func TestNoContentLengthResponse(t *testing.T) {
 	assert.Equal(t, "foo", res.Body.String())
 }
 
+func TestTransferEncodingChunked(t *testing.T) {
+	backendServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+		flusher, ok := rw.(http.Flusher)
+		require.True(t, ok)
+
+		for i := range 3 {
+			_, err := rw.Write([]byte(fmt.Sprintf("chunk %d\n", i)))
+			require.NoError(t, err)
+
+			flusher.Flush()
+		}
+	}))
+	t.Cleanup(backendServer.Close)
+
+	builder := NewProxyBuilder(&transportManagerMock{}, static.FastProxyConfig{})
+
+	proxyHandler, err := builder.Build("", testhelpers.MustParseURL(backendServer.URL), true, true)
+	require.NoError(t, err)
+
+	proxyServer := httptest.NewServer(proxyHandler)
+	t.Cleanup(proxyServer.Close)
+
+	req, err := http.NewRequest(http.MethodGet, proxyServer.URL, http.NoBody)
+	require.NoError(t, err)
+
+	res, err := http.DefaultClient.Do(req)
+	require.NoError(t, err)
+
+	t.Cleanup(func() { _ = res.Body.Close() })
+
+	assert.Equal(t, http.StatusOK, res.StatusCode)
+	assert.Equal(t, []string{"chunked"}, res.TransferEncoding)
+
+	body, err := io.ReadAll(res.Body)
+	require.NoError(t, err)
+
+	assert.Equal(t, "chunk 0\nchunk 1\nchunk 2\n", string(body))
+}
+
 func newCertificate(t *testing.T, domain string) *tls.Certificate {
 	t.Helper()