Repository: traefik/traefik (language: go)

Issue:
Wrong Status code since 2.9.9 with header middleware
### 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?

Since our last upgrade of traefik in 2.10.1, we saw an increase in http 401 response on some of our backends, but not on traefik side.


### What did you see instead?

* Backend process the request and returns : http 401 
* Traefik returns http 200 to the client. 
* Traefik logs only http 100 in accesslog.
* Traefik reports a 401 in metrics

After some investigation : 
* We tried to downgrade traefik, the issue starts occurring in 2.9.9.
* Disabling header middleware also solve the issue. 
* It's not specifically related to 401 response, we also observe this behaviour on others calls which are responding 201/204 for example.

This occurs when all those conditions are met : 
* Client make a POST request. (I didn't check but I assume we would have the same behaviour on a PUT request.)
* Client send a Expect: 100-continue Header
* Middleware header is used. (Not tied to a specific header, I use sts headers, but a random custom header give the same result)
* Http1.1

I'll post below the minimal setup I used to reproduce. (On the backend side I juste used a go backend, 1.20.4, which just store the uploaded file and return a http 201.)

### What version of Traefik are you using?

```bash
Version:      2.9.9
Codename:     banon
Go version:   go1.20.2
Built:        2023-03-21T15:52:28Z
OS/Arch:      darwin/arm64
```

### What is your environment & configuration?

traefik.yaml :

```yaml
api:
  dashboard: true
  insecure: true
entryPoints:
  http:
    address: ':80'
  traefik:
    address: ':8081'
log:
  level: DEBUG
providers:
  file:
    filename: dynamic.yaml
accessLog:
  filePath: "access.log"
  format: json
metrics:
  prometheus: {}
```

dynamic.yaml :

```yaml
http:
  middlewares:
    headers-hsts:
      headers:
        forceSTSHeader: true
        frameDeny: true
        stsIncludeSubdomains: true
        stsSeconds: 15724800
    ratelimit:
      rateLimit:
        average: 30
        burst: 60
  routers:
    local-router:
      rule: Host(`traefik.local`)
      service: upload
      middlewares:
        - "ratelimit"
        - "headers-hsts"
  services:
    upload:
      loadBalancer:
        servers:
          - url: 'http://REDACTED:8082'
 ```
 

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

**Traefik 2.9.9, headers middleware ON**  : 

```bash
curl --http1.1 \                                                                                 
    -H "multipart/form-data" \
    -H "Expect: 100-continue" \
    -vk \
    http://traefik.local/upload \
    -F uploadFile=@testFile
*   Trying REDACTED:80...
* Connected to traefik.local (REDACTED) port 80 (#0)
> POST /upload HTTP/1.1
> Host: traefik.local
> User-Agent: curl/7.87.0
> Accept: */*
> Expect: 100-continue
> Content-Length: 4043747
> Content-Type: multipart/form-data; boundary=------------------------2ecc540804f95d37
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 100 Continue
< Strict-Transport-Security: max-age=15724800; includeSubDomains
< X-Frame-Options: DENY
* We are completely uploaded and fine
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Content-Length: 1
< Content-Type: text/plain; charset=utf-8
< Date: Wed, 31 May 2023 15:21:24 GMT
< X-Content-Type-Options: nosniff
<

* Connection #0 to host traefik.local left intact
```

accesslog : 
```bash
{"ClientAddr":"REDACTED:52691","ClientHost":"REDACTED","ClientPort":"52691","ClientUsername":"-","DownstreamContentSize":1,"DownstreamStatus":100,"Duration":33810000,"OriginContentSize":1,"OriginDuration":33732000,"OriginStatus":100,"Overhead":78000,"RequestAddr":"traefik.local","RequestContentSize":4043747,"RequestCount":1,"RequestHost":"traefik.local","RequestMethod":"POST","RequestPath":"/upload","RequestPort":"-","RequestProtocol":"HTTP/1.1","RequestScheme":"http","RetryAttempts":0,"RouterName":"local-router@file","ServiceAddr":"REDACTED:8082","ServiceName":"upload@file","ServiceURL":{"Scheme":"http","Opaque":"","User":null,"Host":"REDACTED:8082","Path":"","RawPath":"","OmitHost":false,"ForceQuery":false,"RawQuery":"","Fragment":"","RawFragment":""},"StartLocal":"2023-05-31T17:21:23.980574+02:00","StartUTC":"2023-05-31T15:21:23.980574Z","entryPointName":"http","level":"info","msg":"","time":"2023-05-31T17:21:24+02:00"}
```

**Traefik 2.9.9, headers middleware OFF**  :

```bash
curl --http1.1 \                                                                                  
    -H "multipart/form-data" \
    -H "Expect: 100-continue" \
    -vk \
    http://traefik.local/upload \
    -F uploadFile=@testFile
*   Trying REDACTED:80...
* Connected to traefik.local (REDACTED) port 80 (#0)
> POST /upload HTTP/1.1
> Host: traefik.local
> User-Agent: curl/7.87.0
> Accept: */*
> Expect: 100-continue
> Content-Length: 4043747
> Content-Type: multipart/form-data; boundary=------------------------21c93b5e4e11755a
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 100 Continue
* We are completely uploaded and fine
* Mark bundle as not supporting multiuse
< HTTP/1.1 201 Created
< Content-Length: 1
< Content-Type: text/plain; charset=utf-8
< Date: Wed, 31 May 2023 15:26:56 GMT
< X-Content-Type-Options: nosniff
<

* Connection #0 to host traefik.local left intact
```

accesslog : 
```
{"ClientAddr":"REDACTED:52707","ClientHost":"REDACTED","ClientPort":"52707","ClientUsername":"-","DownstreamContentSize":1,"DownstreamStatus":201,"Duration":22132000,"OriginContentSize":1,"OriginDuration":22026000,"OriginStatus":201,"Overhead":106000,"RequestAddr":"traefik.local","RequestContentSize":4043747,"RequestCount":4,"RequestHost":"traefik.local","RequestMethod":"POST","RequestPath":"/upload","RequestPort":"-","RequestProtocol":"HTTP/1.1","RequestScheme":"http","RetryAttempts":0,"RouterName":"local-router@file","ServiceAddr":"REDACTED:8082","ServiceName":"upload@file","ServiceURL":{"Scheme":"http","Opaque":"","User":null,"Host":"REDACTED:8082","Path":"","RawPath":"","OmitHost":false,"ForceQuery":false,"RawQuery":"","Fragment":"","RawFragment":""},"StartLocal":"2023-05-31T17:26:56.175075+02:00","StartUTC":"2023-05-31T15:26:56.175075Z","entryPointName":"http","level":"info","msg":"","time":"2023-05-31T17:26:56+02:00"}
```

**Traefik 2.9.8, headers middleware ON**  :

```bash
curl --http1.1 \                                                                                  
    -H "multipart/form-data" \
    -H "Expect: 100-continue" \
    -vk \
    http://traefik.local/upload \
    -F uploadFile=@testFile
*   Trying REDACTED:80...
* Connected to traefik.local (REDACTED) port 80 (#0)
> POST /upload HTTP/1.1
> Host: traefik.local
> User-Agent: curl/7.87.0
> Accept: */*
> Expect: 100-continue
> Content-Length: 4043747
> Content-Type: multipart/form-data; boundary=------------------------d457858debf8cf0d
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 100 Continue
* We are completely uploaded and fine
* Mark bundle as not supporting multiuse
< HTTP/1.1 201 Created
< Content-Length: 1
< Content-Type: text/plain; charset=utf-8
< Date: Wed, 31 May 2023 15:31:21 GMT
< Strict-Transport-Security: max-age=15724800; includeSubDomains
< X-Content-Type-Options: nosniff
< X-Frame-Options: DENY
<

* Connection #0 to host traefik.local left intact
````

accesslog : 
```
{"ClientAddr":"REDACTED:52729","ClientHost":"REDACTED","ClientPort":"52729","ClientUsername":"-","DownstreamContentSize":1,"DownstreamStatus":201,"Duration":48922000,"OriginContentSize":1,"OriginDuration":48833000,"OriginStatus":201,"Overhead":89000,"RequestAddr":"traefik.local","RequestContentSize":4043747,"RequestCount":1,"RequestHost":"traefik.local","RequestMethod":"POST","RequestPath":"/upload","RequestPort":"-","RequestProtocol":"HTTP/1.1","RequestScheme":"http","RetryAttempts":0,"RouterName":"local-router@file","ServiceAddr":"REDACTED:8082","ServiceName":"upload@file","ServiceURL":{"Scheme":"http","Opaque":"","User":null,"Host":"REDACTED:8082","Path":"","RawPath":"","OmitHost":false,"ForceQuery":false,"RawQuery":"","Fragment":"","RawFragment":""},"StartLocal":"2023-05-31T17:31:21.820605+02:00","StartUTC":"2023-05-31T15:31:21.820605Z","entryPointName":"http","level":"info","msg":"","time":"2023-05-31T17:31:21+02:00"}
```


Discussion / hints:

Fix patch:
diff --git a/go.mod b/go.mod
index d4e045bb3f..27de095ebc 100644
--- a/go.mod
+++ b/go.mod
@@ -35,7 +35,7 @@ require (
 	github.com/influxdata/influxdb-client-go/v2 v2.7.0
 	github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d
 	github.com/instana/go-sensor v1.38.3
-	github.com/klauspost/compress v1.15.0
+	github.com/klauspost/compress v1.16.6
 	github.com/kvtools/consul v1.0.2
 	github.com/kvtools/etcdv3 v1.0.2
 	github.com/kvtools/redis v1.0.2
diff --git a/go.sum b/go.sum
index 1de390a944..c444150a2c 100644
--- a/go.sum
+++ b/go.sum
@@ -1158,8 +1158,8 @@ github.com/kisielk/sqlstruct v0.0.0-20150923205031-648daed35d49/go.mod h1:yyMNCy
 github.com/kisom/goutils v1.1.0/go.mod h1:+UBTfd78habUYWFbNWTJNG+jNG/i/lGURakr4A/yNRw=
 github.com/klauspost/compress v1.11.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
 github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
-github.com/klauspost/compress v1.15.0 h1:xqfchp4whNFxn5A4XFyyYtitiWI8Hy5EW59jEwcyL6U=
-github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
+github.com/klauspost/compress v1.16.6 h1:91SKEy4K37vkp255cJ8QesJhjyRO0hn9i9G0GoUwLsk=
+github.com/klauspost/compress v1.16.6/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
 github.com/kolo/xmlrpc v0.0.0-20200310150728-e0350524596b h1:DzHy0GlWeF0KAglaTMY7Q+khIFoG8toHP+wLFBVBQJc=
 github.com/kolo/xmlrpc v0.0.0-20200310150728-e0350524596b/go.mod h1:o03bZfuBwAXHetKXuInt4S7omeXUu62/A845kiycsSQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
diff --git a/pkg/middlewares/customerrors/custom_errors.go b/pkg/middlewares/customerrors/custom_errors.go
index c764b2cf88..39d4116222 100644
--- a/pkg/middlewares/customerrors/custom_errors.go
+++ b/pkg/middlewares/customerrors/custom_errors.go
@@ -193,11 +193,25 @@ func (cc *codeCatcher) Write(buf []byte) (int, error) {
 	return cc.responseWriter.Write(buf)
 }
 
+// WriteHeader is, in the specific case of 1xx status codes, a direct call to the wrapped ResponseWriter, without marking headers as sent,
+// allowing so further calls.
 func (cc *codeCatcher) WriteHeader(code int) {
 	if cc.headersSent || cc.caughtFilteredCode {
 		return
 	}
 
+	// Handling informational headers.
+	if code >= 100 && code <= 199 {
+		// Multiple informational status codes can be used,
+		// so here the copy is not appending the values to not repeat them.
+		for k, v := range cc.Header() {
+			cc.responseWriter.Header()[k] = v
+		}
+
+		cc.responseWriter.WriteHeader(code)
+		return
+	}
+
 	cc.code = code
 	for _, block := range cc.httpCodeRanges {
 		if cc.code >= block[0] && cc.code <= block[1] {
@@ -208,7 +222,11 @@ func (cc *codeCatcher) WriteHeader(code int) {
 		}
 	}
 
-	utils.CopyHeaders(cc.responseWriter.Header(), cc.Header())
+	// The copy is not appending the values,
+	// to not repeat them in case any informational status code has been written.
+	for k, v := range cc.Header() {
+		cc.responseWriter.Header()[k] = v
+	}
 	cc.responseWriter.WriteHeader(cc.code)
 	cc.headersSent = true
 }
@@ -303,12 +321,28 @@ func (r *codeModifierWithoutCloseNotify) Write(buf []byte) (int, error) {
 
 // WriteHeader sends the headers, with the enforced code (the code in argument is always ignored),
 // if it hasn't already been done.
-func (r *codeModifierWithoutCloseNotify) WriteHeader(_ int) {
+// WriteHeader is, in the specific case of 1xx status codes, a direct call to the wrapped ResponseWriter, without marking headers as sent,
+// allowing so further calls.
+func (r *codeModifierWithoutCloseNotify) WriteHeader(code int) {
 	if r.headerSent {
 		return
 	}
 
-	utils.CopyHeaders(r.responseWriter.Header(), r.Header())
+	// Handling informational headers.
+	if code >= 100 && code <= 199 {
+		// Multiple informational status codes can be used,
+		// so here the copy is not appending the values to not repeat them.
+		for k, v := range r.headerMap {
+			r.responseWriter.Header()[k] = v
+		}
+
+		r.responseWriter.WriteHeader(code)
+		return
+	}
+
+	for k, v := range r.headerMap {
+		r.responseWriter.Header()[k] = v
+	}
 	r.responseWriter.WriteHeader(r.code)
 	r.headerSent = true
 }
diff --git a/pkg/middlewares/headers/responsewriter.go b/pkg/middlewares/headers/responsewriter.go
index 15201b1fd7..c36674a053 100644
--- a/pkg/middlewares/headers/responsewriter.go
+++ b/pkg/middlewares/headers/responsewriter.go
@@ -36,10 +36,19 @@ func newResponseModifier(w http.ResponseWriter, r *http.Request, modifier func(*
 	return rm
 }
 
+// WriteHeader is, in the specific case of 1xx status codes, a direct call to the wrapped ResponseWriter, without marking headers as sent,
+// allowing so further calls.
 func (r *responseModifier) WriteHeader(code int) {
 	if r.headersSent {
 		return
 	}
+
+	// Handling informational headers.
+	if code >= 100 && code <= 199 {
+		r.rw.WriteHeader(code)
+		return
+	}
+
 	defer func() {
 		r.code = code
 		r.headersSent = true
diff --git a/pkg/middlewares/retry/retry.go b/pkg/middlewares/retry/retry.go
index 37dd4abef3..75367ebc76 100644
--- a/pkg/middlewares/retry/retry.go
+++ b/pkg/middlewares/retry/retry.go
@@ -209,7 +209,7 @@ func (r *responseWriterWithoutCloseNotify) WriteHeader(code int) {
 		r.DisableRetries()
 	}
 
-	if r.ShouldRetry() {
+	if r.ShouldRetry() || r.written {
 		return
 	}
 
@@ -223,6 +223,13 @@ func (r *responseWriterWithoutCloseNotify) WriteHeader(code int) {
 	}
 
 	r.responseWriter.WriteHeader(code)
+
+	// Handling informational headers.
+	// This allows to keep writing to r.headers map until a final status code is written.
+	if code >= 100 && code <= 199 {
+		return
+	}
+
 	r.written = true
 }
 


Test patch:
diff --git a/pkg/middlewares/compress/compress_test.go b/pkg/middlewares/compress/compress_test.go
index 8f7e85ae0f..4a1fd21966 100644
--- a/pkg/middlewares/compress/compress_test.go
+++ b/pkg/middlewares/compress/compress_test.go
@@ -5,6 +5,8 @@ import (
 	"io"
 	"net/http"
 	"net/http/httptest"
+	"net/http/httptrace"
+	"net/textproto"
 	"testing"
 
 	"github.com/klauspost/compress/gzhttp"
@@ -356,6 +358,86 @@ func TestMinResponseBodyBytes(t *testing.T) {
 	}
 }
 
+// This test is an adapted version of net/http/httputil.Test1xxResponses test.
+func Test1xxResponses(t *testing.T) {
+	fakeBody := generateBytes(100000)
+
+	next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		h := w.Header()
+		h.Add("Link", "</style.css>; rel=preload; as=style")
+		h.Add("Link", "</script.js>; rel=preload; as=script")
+		w.WriteHeader(http.StatusEarlyHints)
+
+		h.Add("Link", "</foo.js>; rel=preload; as=script")
+		w.WriteHeader(http.StatusProcessing)
+
+		if _, err := w.Write(fakeBody); err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+		}
+	})
+
+	compress, err := New(context.Background(), next, dynamic.Compress{MinResponseBodyBytes: 1024}, "testing")
+	require.NoError(t, err)
+
+	server := httptest.NewServer(compress)
+	t.Cleanup(server.Close)
+	frontendClient := server.Client()
+
+	checkLinkHeaders := func(t *testing.T, expected, got []string) {
+		t.Helper()
+
+		if len(expected) != len(got) {
+			t.Errorf("Expected %d link headers; got %d", len(expected), len(got))
+		}
+
+		for i := range expected {
+			if i >= len(got) {
+				t.Errorf("Expected %q link header; got nothing", expected[i])
+
+				continue
+			}
+
+			if expected[i] != got[i] {
+				t.Errorf("Expected %q link header; got %q", expected[i], got[i])
+			}
+		}
+	}
+
+	var respCounter uint8
+	trace := &httptrace.ClientTrace{
+		Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
+			switch code {
+			case http.StatusEarlyHints:
+				checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script"}, header["Link"])
+			case http.StatusProcessing:
+				checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script", "</foo.js>; rel=preload; as=script"}, header["Link"])
+			default:
+				t.Error("Unexpected 1xx response")
+			}
+
+			respCounter++
+
+			return nil
+		},
+	}
+	req, _ := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, server.URL, nil)
+	req.Header.Add(acceptEncodingHeader, gzipValue)
+
+	res, err := frontendClient.Do(req)
+	assert.Nil(t, err)
+
+	defer res.Body.Close()
+
+	if respCounter != 2 {
+		t.Errorf("Expected 2 1xx responses; got %d", respCounter)
+	}
+	checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script", "</foo.js>; rel=preload; as=script"}, res.Header["Link"])
+
+	assert.Equal(t, gzipValue, res.Header.Get(contentEncodingHeader))
+	body, _ := io.ReadAll(res.Body)
+	assert.NotEqualValues(t, body, fakeBody)
+}
+
 func BenchmarkCompress(b *testing.B) {
 	testCases := []struct {
 		name     string
diff --git a/pkg/middlewares/customerrors/custom_errors_test.go b/pkg/middlewares/customerrors/custom_errors_test.go
index c291b15d52..84e3579b72 100644
--- a/pkg/middlewares/customerrors/custom_errors_test.go
+++ b/pkg/middlewares/customerrors/custom_errors_test.go
@@ -3,8 +3,11 @@ package customerrors
 import (
 	"context"
 	"fmt"
+	"io"
 	"net/http"
 	"net/http/httptest"
+	"net/http/httptrace"
+	"net/textproto"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -181,6 +184,88 @@ func TestHandler(t *testing.T) {
 	}
 }
 
+// This test is an adapted version of net/http/httputil.Test1xxResponses test.
+func Test1xxResponses(t *testing.T) {
+	next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		h := w.Header()
+		h.Add("Link", "</style.css>; rel=preload; as=style")
+		h.Add("Link", "</script.js>; rel=preload; as=script")
+		w.WriteHeader(http.StatusEarlyHints)
+
+		h.Add("Link", "</foo.js>; rel=preload; as=script")
+		w.WriteHeader(http.StatusProcessing)
+
+		h.Add("User-Agent", "foobar")
+		_, _ = w.Write([]byte("Hello"))
+		w.WriteHeader(http.StatusBadGateway)
+	})
+
+	serviceBuilderMock := &mockServiceBuilder{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		_, _ = fmt.Fprintln(w, "My error page.")
+	})}
+
+	config := dynamic.ErrorPage{Service: "error", Query: "/", Status: []string{"200"}}
+
+	errorPageHandler, err := New(context.Background(), next, config, serviceBuilderMock, "test")
+	require.NoError(t, err)
+
+	server := httptest.NewServer(errorPageHandler)
+	t.Cleanup(server.Close)
+	frontendClient := server.Client()
+
+	checkLinkHeaders := func(t *testing.T, expected, got []string) {
+		t.Helper()
+
+		if len(expected) != len(got) {
+			t.Errorf("Expected %d link headers; got %d", len(expected), len(got))
+		}
+
+		for i := range expected {
+			if i >= len(got) {
+				t.Errorf("Expected %q link header; got nothing", expected[i])
+
+				continue
+			}
+
+			if expected[i] != got[i] {
+				t.Errorf("Expected %q link header; got %q", expected[i], got[i])
+			}
+		}
+	}
+
+	var respCounter uint8
+	trace := &httptrace.ClientTrace{
+		Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
+			switch code {
+			case http.StatusEarlyHints:
+				checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script"}, header["Link"])
+			case http.StatusProcessing:
+				checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script", "</foo.js>; rel=preload; as=script"}, header["Link"])
+			default:
+				t.Error("Unexpected 1xx response")
+			}
+
+			respCounter++
+
+			return nil
+		},
+	}
+	req, _ := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, server.URL, nil)
+
+	res, err := frontendClient.Do(req)
+	assert.Nil(t, err)
+
+	defer res.Body.Close()
+
+	if respCounter != 2 {
+		t.Errorf("Expected 2 1xx responses; got %d", respCounter)
+	}
+	checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script", "</foo.js>; rel=preload; as=script"}, res.Header["Link"])
+
+	body, _ := io.ReadAll(res.Body)
+	assert.Equal(t, "My error page.\n", string(body))
+}
+
 type mockServiceBuilder struct {
 	handler http.Handler
 }
diff --git a/pkg/middlewares/headers/headers_test.go b/pkg/middlewares/headers/headers_test.go
index efbcdb0b2b..c345efd605 100644
--- a/pkg/middlewares/headers/headers_test.go
+++ b/pkg/middlewares/headers/headers_test.go
@@ -4,8 +4,11 @@ package headers
 
 import (
 	"context"
+	"io"
 	"net/http"
 	"net/http/httptest"
+	"net/http/httptrace"
+	"net/textproto"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -111,3 +114,87 @@ func Test_headers_getTracingInformation(t *testing.T) {
 	assert.Equal(t, "testing", name)
 	assert.Equal(t, tracing.SpanKindNoneEnum, trace)
 }
+
+// This test is an adapted version of net/http/httputil.Test1xxResponses test.
+func Test1xxResponses(t *testing.T) {
+	next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		h := w.Header()
+		h.Add("Link", "</style.css>; rel=preload; as=style")
+		h.Add("Link", "</script.js>; rel=preload; as=script")
+		w.WriteHeader(http.StatusEarlyHints)
+
+		h.Add("Link", "</foo.js>; rel=preload; as=script")
+		w.WriteHeader(http.StatusProcessing)
+
+		_, _ = w.Write([]byte("Hello"))
+	})
+
+	cfg := dynamic.Headers{
+		CustomResponseHeaders: map[string]string{
+			"X-Custom-Response-Header": "test_response",
+		},
+	}
+
+	mid, err := New(context.Background(), next, cfg, "testing")
+	require.NoError(t, err)
+
+	server := httptest.NewServer(mid)
+	t.Cleanup(server.Close)
+	frontendClient := server.Client()
+
+	checkLinkHeaders := func(t *testing.T, expected, got []string) {
+		t.Helper()
+
+		if len(expected) != len(got) {
+			t.Errorf("Expected %d link headers; got %d", len(expected), len(got))
+		}
+
+		for i := range expected {
+			if i >= len(got) {
+				t.Errorf("Expected %q link header; got nothing", expected[i])
+
+				continue
+			}
+
+			if expected[i] != got[i] {
+				t.Errorf("Expected %q link header; got %q", expected[i], got[i])
+			}
+		}
+	}
+
+	var respCounter uint8
+	trace := &httptrace.ClientTrace{
+		Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
+			switch code {
+			case http.StatusEarlyHints:
+				checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script"}, header["Link"])
+			case http.StatusProcessing:
+				checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script", "</foo.js>; rel=preload; as=script"}, header["Link"])
+			default:
+				t.Error("Unexpected 1xx response")
+			}
+
+			respCounter++
+
+			return nil
+		},
+	}
+	req, _ := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, server.URL, nil)
+
+	res, err := frontendClient.Do(req)
+	assert.Nil(t, err)
+
+	defer res.Body.Close()
+
+	if respCounter != 2 {
+		t.Errorf("Expected 2 1xx responses; got %d", respCounter)
+	}
+	checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script", "</foo.js>; rel=preload; as=script"}, res.Header["Link"])
+
+	body, _ := io.ReadAll(res.Body)
+	if string(body) != "Hello" {
+		t.Errorf("Read body %q; want Hello", body)
+	}
+
+	assert.Equal(t, "test_response", res.Header.Get("X-Custom-Response-Header"))
+}
diff --git a/pkg/middlewares/pipelining/pipelining_test.go b/pkg/middlewares/pipelining/pipelining_test.go
index b26e5fc6b7..181fcb3286 100644
--- a/pkg/middlewares/pipelining/pipelining_test.go
+++ b/pkg/middlewares/pipelining/pipelining_test.go
@@ -2,8 +2,11 @@ package pipelining
 
 import (
 	"context"
+	"io"
 	"net/http"
 	"net/http/httptest"
+	"net/http/httptrace"
+	"net/textproto"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -68,3 +71,80 @@ func TestNew(t *testing.T) {
 		})
 	}
 }
+
+// This test is an adapted version of net/http/httputil.Test1xxResponses test.
+// This test is only here to guarantee that there would not be any regression in the future,
+// because the pipelining middleware is already supporting informational headers.
+func Test1xxResponses(t *testing.T) {
+	next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		h := w.Header()
+		h.Add("Link", "</style.css>; rel=preload; as=style")
+		h.Add("Link", "</script.js>; rel=preload; as=script")
+		w.WriteHeader(http.StatusEarlyHints)
+
+		h.Add("Link", "</foo.js>; rel=preload; as=script")
+		w.WriteHeader(http.StatusProcessing)
+
+		_, _ = w.Write([]byte("Hello"))
+	})
+
+	pipe := New(context.Background(), next, "pipe")
+
+	server := httptest.NewServer(pipe)
+	t.Cleanup(server.Close)
+	frontendClient := server.Client()
+
+	checkLinkHeaders := func(t *testing.T, expected, got []string) {
+		t.Helper()
+
+		if len(expected) != len(got) {
+			t.Errorf("Expected %d link headers; got %d", len(expected), len(got))
+		}
+
+		for i := range expected {
+			if i >= len(got) {
+				t.Errorf("Expected %q link header; got nothing", expected[i])
+
+				continue
+			}
+
+			if expected[i] != got[i] {
+				t.Errorf("Expected %q link header; got %q", expected[i], got[i])
+			}
+		}
+	}
+
+	var respCounter uint8
+	trace := &httptrace.ClientTrace{
+		Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
+			switch code {
+			case http.StatusEarlyHints:
+				checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script"}, header["Link"])
+			case http.StatusProcessing:
+				checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script", "</foo.js>; rel=preload; as=script"}, header["Link"])
+			default:
+				t.Error("Unexpected 1xx response")
+			}
+
+			respCounter++
+
+			return nil
+		},
+	}
+	req, _ := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, server.URL, nil)
+
+	res, err := frontendClient.Do(req)
+	assert.Nil(t, err)
+
+	defer res.Body.Close()
+
+	if respCounter != 2 {
+		t.Errorf("Expected 2 1xx responses; got %d", respCounter)
+	}
+	checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script", "</foo.js>; rel=preload; as=script"}, res.Header["Link"])
+
+	body, _ := io.ReadAll(res.Body)
+	if string(body) != "Hello" {
+		t.Errorf("Read body %q; want Hello", body)
+	}
+}
diff --git a/pkg/middlewares/retry/retry_test.go b/pkg/middlewares/retry/retry_test.go
index 4622093afa..75ef5831b2 100644
--- a/pkg/middlewares/retry/retry_test.go
+++ b/pkg/middlewares/retry/retry_test.go
@@ -7,6 +7,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"net/http/httptrace"
+	"net/textproto"
 	"strings"
 	"testing"
 	"time"
@@ -309,3 +310,82 @@ func TestRetryWebsocket(t *testing.T) {
 		})
 	}
 }
+
+// This test is an adapted version of net/http/httputil.Test1xxResponses test.
+func Test1xxResponses(t *testing.T) {
+	next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		h := w.Header()
+		h.Add("Link", "</style.css>; rel=preload; as=style")
+		h.Add("Link", "</script.js>; rel=preload; as=script")
+		w.WriteHeader(http.StatusEarlyHints)
+
+		h.Add("Link", "</foo.js>; rel=preload; as=script")
+		w.WriteHeader(http.StatusProcessing)
+
+		_, _ = w.Write([]byte("Hello"))
+	})
+
+	retryListener := &countingRetryListener{}
+	retry, err := New(context.Background(), next, dynamic.Retry{Attempts: 1}, retryListener, "traefikTest")
+	require.NoError(t, err)
+
+	server := httptest.NewServer(retry)
+	t.Cleanup(server.Close)
+	frontendClient := server.Client()
+
+	checkLinkHeaders := func(t *testing.T, expected, got []string) {
+		t.Helper()
+
+		if len(expected) != len(got) {
+			t.Errorf("Expected %d link headers; got %d", len(expected), len(got))
+		}
+
+		for i := range expected {
+			if i >= len(got) {
+				t.Errorf("Expected %q link header; got nothing", expected[i])
+
+				continue
+			}
+
+			if expected[i] != got[i] {
+				t.Errorf("Expected %q link header; got %q", expected[i], got[i])
+			}
+		}
+	}
+
+	var respCounter uint8
+	trace := &httptrace.ClientTrace{
+		Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
+			switch code {
+			case http.StatusEarlyHints:
+				checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script"}, header["Link"])
+			case http.StatusProcessing:
+				checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script", "</foo.js>; rel=preload; as=script"}, header["Link"])
+			default:
+				t.Error("Unexpected 1xx response")
+			}
+
+			respCounter++
+
+			return nil
+		},
+	}
+	req, _ := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, server.URL, nil)
+
+	res, err := frontendClient.Do(req)
+	assert.Nil(t, err)
+
+	defer res.Body.Close()
+
+	if respCounter != 2 {
+		t.Errorf("Expected 2 1xx responses; got %d", respCounter)
+	}
+	checkLinkHeaders(t, []string{"</style.css>; rel=preload; as=style", "</script.js>; rel=preload; as=script", "</foo.js>; rel=preload; as=script"}, res.Header["Link"])
+
+	body, _ := io.ReadAll(res.Body)
+	if string(body) != "Hello" {
+		t.Errorf("Read body %q; want Hello", body)
+	}
+
+	assert.Equal(t, 0, retryListener.timesCalled)
+}