Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 11, 01:00 UTC
ci: test and release with Go 1.27 (#8210)
* caddyhttp: add HTTP/1 body reuse regression tests * ci: test and release with Go 1.27 --------- Co-authored-by: steadytao <mail@steadytao.com>
Krish Singh Hura committed
Nov 3, 2026 at 10:11 UTC
053be179d8602b65d93db6980397c176ecba2f89
4 files changed
+179
-9
.github/workflows/ci.yml
+4
-4
@@ -31,13 +31,13 @@ jobs:
31
- mac
32
- windows
33
go:
34
- - '1.26'
34
+ - '1.27'
35
36
include:
37
# Set the minimum Go patch version for the given Go minor
38
# Usable via ${{ matrix.GO_SEMVER }}
39
- - go: '1.26'
40
- GO_SEMVER: '~1.26.0'
39
+ - go: '1.27'
40
+ GO_SEMVER: '~1.27.0'
41
42
# Set some variables per OS, usable via ${{ matrix.VAR }}
43
# OS_LABEL: the VM label from GitHub Actions (see https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories)
@@ -237,7 +237,7 @@ jobs:
237
- name: Install Go
238
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
239
with:
240
- go-version: "~1.26"
240
+ go-version: "~1.27"
241
check-latest: true
242
- name: Install xcaddy
243
run: |
.github/workflows/lint.yml
+2
-2
@@ -52,7 +52,7 @@ jobs:
52
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
53
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
54
with:
55
- go-version: '~1.26'
55
+ go-version: '~1.27'
56
check-latest: true
57
58
- name: golangci-lint
@@ -80,7 +80,7 @@ jobs:
80
- name: govulncheck
81
uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4
82
with:
83
- go-version-input: '~1.26.0'
83
+ go-version-input: '~1.27.0'
84
check-latest: true
85
86
dependency-review:
.github/workflows/release.yml
+3
-3
@@ -334,13 +334,13 @@ jobs:
334
os:
335
- ubuntu-latest
336
go:
337
- - '1.26'
337
+ - '1.27'
338
339
include:
340
# Set the minimum Go patch version for the given Go minor
341
# Usable via ${{ matrix.GO_SEMVER }}
342
- - go: '1.26'
343
- GO_SEMVER: '~1.26.0'
342
+ - go: '1.27'
343
+ GO_SEMVER: '~1.27.0'
344
345
runs-on: ${{ matrix.os }}
346
# https://github.com/sigstore/cosign/issues/1258#issuecomment-1002251233
modules/caddyhttp/server_test.go
+170
@@ -1773,3 +1773,173 @@ func TestServerLogLevel(t *testing.T) {
1773
})
1774
}
1775
}
1776
+
1777
+// TestServer_ClosedRequestBodyDoesNotDesyncHTTP1 verifies that an early
1778
+// response after a handler closes its request body cannot leave the unread
1779
+// body bytes to be parsed as the next request. This models a reverse proxy
1780
+// transport which receives an early upstream response and then closes the
1781
+// downstream request body.
1782
+func TestServer_ClosedRequestBodyDoesNotDesyncHTTP1(t *testing.T) {
1783
+ const firstChunkSize = 1024
1784
+
1785
+ for _, tc := range []struct {
1786
+ name string
1787
+ chunked bool
1788
+ bodyLen int
1789
+ }{
1790
+ {name: "fixed length above drain limit", bodyLen: 1 << 20},
1791
+ {name: "chunked above drain limit", chunked: true, bodyLen: 1 << 20},
1792
+ } {
1793
+ t.Run(tc.name, func(t *testing.T) {
1794
+ secondRequest := make(chan struct{}, 1)
1795
+ server := &Server{
1796
+ ReadIdleTimeout: caddy.Duration(time.Minute),
1797
+ logger: zap.NewNop(),
1798
+ errorLogger: zap.NewNop(),
1799
+ primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1800
+ switch r.URL.Path {
1801
+ case "/early":
1802
+ buf := make([]byte, firstChunkSize)
1803
+ if _, err := io.ReadFull(r.Body, buf); err != nil {
1804
+ return fmt.Errorf("read first request chunk: %w", err)
1805
+ }
1806
+ if err := r.Body.Close(); err != nil {
1807
+ return fmt.Errorf("close first request body: %w", err)
1808
+ }
1809
+ w.Header().Set("Content-Length", "0")
1810
+ w.WriteHeader(http.StatusLocked)
1811
+ case "/second":
1812
+ secondRequest <- struct{}{}
1813
+ w.Header().Set("Content-Length", "0")
1814
+ w.WriteHeader(http.StatusNoContent)
1815
+ default:
1816
+ return Error(http.StatusNotFound, nil)
1817
+ }
1818
+ return nil
1819
+ }),
1820
+ }
1821
+ ts := httptest.NewServer(http.HandlerFunc(server.ServeHTTP))
1822
+ t.Cleanup(ts.Close)
1823
+
1824
+ conn, err := net.DialTimeout("tcp", ts.Listener.Addr().String(), time.Second)
1825
+ require.NoError(t, err)
1826
+ t.Cleanup(func() { _ = conn.Close() })
1827
+ require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
1828
+
1829
+ reader := bufio.NewReader(conn)
1830
+ if tc.chunked {
1831
+ _, err = fmt.Fprintf(conn, "PUT /early HTTP/1.1\r\nHost: %s\r\nTransfer-Encoding: chunked\r\n\r\n%x\r\n%s\r\n", ts.Listener.Addr(), firstChunkSize, bytes.Repeat([]byte{'a'}, firstChunkSize))
1832
+ } else {
1833
+ _, err = fmt.Fprintf(conn, "PUT /early HTTP/1.1\r\nHost: %s\r\nContent-Length: %d\r\n\r\n%s", ts.Listener.Addr(), tc.bodyLen, bytes.Repeat([]byte{'a'}, firstChunkSize))
1834
+ }
1835
+ require.NoError(t, err)
1836
+
1837
+ writerDone := make(chan struct{})
1838
+ if tc.chunked {
1839
+ go func() {
1840
+ defer close(writerDone)
1841
+ for sent := firstChunkSize; sent < tc.bodyLen; {
1842
+ n := min(8<<10, tc.bodyLen-sent)
1843
+ if _, err := fmt.Fprintf(conn, "%x\r\n%s\r\n", n, bytes.Repeat([]byte{'b'}, n)); err != nil {
1844
+ return
1845
+ }
1846
+ sent += n
1847
+ time.Sleep(time.Millisecond)
1848
+ }
1849
+ _, _ = io.WriteString(conn, "0\r\n\r\n")
1850
+ }()
1851
+ }
1852
+
1853
+ first, err := http.ReadResponse(reader, &http.Request{Method: http.MethodPut})
1854
+ require.NoError(t, err)
1855
+ require.Equal(t, http.StatusLocked, first.StatusCode)
1856
+ require.NoError(t, first.Body.Close())
1857
+
1858
+ if tc.chunked {
1859
+ <-writerDone
1860
+ } else {
1861
+ _, _ = conn.Write(bytes.Repeat([]byte{'b'}, tc.bodyLen-firstChunkSize))
1862
+ }
1863
+ _, _ = fmt.Fprintf(conn, "GET /second HTTP/1.1\r\nHost: %s\r\nConnection: keep-alive\r\n\r\n", ts.Listener.Addr())
1864
+
1865
+ second, err := http.ReadResponse(reader, &http.Request{Method: http.MethodGet})
1866
+ if err != nil {
1867
+ if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, net.ErrClosed) {
1868
+ return // The server safely closed the connection after the early response.
1869
+ }
1870
+ var netErr net.Error
1871
+ if errors.As(err, &netErr) && !netErr.Timeout() {
1872
+ return // The peer reset the connection after the early response.
1873
+ }
1874
+ t.Fatalf("read second response: %v", err)
1875
+ }
1876
+ defer second.Body.Close()
1877
+ if second.StatusCode != http.StatusNoContent {
1878
+ t.Fatalf("second response status = %d, want %d or a closed connection", second.StatusCode, http.StatusNoContent)
1879
+ }
1880
+ select {
1881
+ case <-secondRequest:
1882
+ case <-time.After(time.Second):
1883
+ t.Fatal("second request response was not produced by the handler")
1884
+ }
1885
+ })
1886
+ }
1887
+}
1888
+
1889
+func TestServer_FullyReadRequestBodyPreservesHTTP1Connection(t *testing.T) {
1890
+ for _, tc := range []struct {
1891
+ name string
1892
+ chunked bool
1893
+ }{
1894
+ {name: "fixed length"},
1895
+ {name: "chunked", chunked: true},
1896
+ } {
1897
+ t.Run(tc.name, func(t *testing.T) {
1898
+ remoteAddrs := make(chan string, 2)
1899
+ server := &Server{
1900
+ ReadIdleTimeout: caddy.Duration(time.Minute),
1901
+ logger: zap.NewNop(),
1902
+ errorLogger: zap.NewNop(),
1903
+ primaryHandlerChain: HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
1904
+ remoteAddrs <- r.RemoteAddr
1905
+ if r.URL.Path == "/first" {
1906
+ if _, err := io.Copy(io.Discard, r.Body); err != nil {
1907
+ return err
1908
+ }
1909
+ }
1910
+ w.Header().Set("Content-Length", "0")
1911
+ w.WriteHeader(http.StatusNoContent)
1912
+ return nil
1913
+ }),
1914
+ }
1915
+ ts := httptest.NewServer(http.HandlerFunc(server.ServeHTTP))
1916
+ t.Cleanup(ts.Close)
1917
+
1918
+ conn, err := net.DialTimeout("tcp", ts.Listener.Addr().String(), time.Second)
1919
+ require.NoError(t, err)
1920
+ t.Cleanup(func() { _ = conn.Close() })
1921
+ require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
1922
+
1923
+ if tc.chunked {
1924
+ _, err = fmt.Fprintf(conn, "POST /first HTTP/1.1\r\nHost: %s\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\n\r\n", ts.Listener.Addr())
1925
+ } else {
1926
+ _, err = fmt.Fprintf(conn, "POST /first HTTP/1.1\r\nHost: %s\r\nContent-Length: 4\r\n\r\nbody", ts.Listener.Addr())
1927
+ }
1928
+ require.NoError(t, err)
1929
+ reader := bufio.NewReader(conn)
1930
+ first, err := http.ReadResponse(reader, &http.Request{Method: http.MethodPost})
1931
+ require.NoError(t, err)
1932
+ require.Equal(t, http.StatusNoContent, first.StatusCode)
1933
+ require.NoError(t, first.Body.Close())
1934
+
1935
+ _, err = fmt.Fprintf(conn, "GET /second HTTP/1.1\r\nHost: %s\r\n\r\n", ts.Listener.Addr())
1936
+ require.NoError(t, err)
1937
+ second, err := http.ReadResponse(reader, &http.Request{Method: http.MethodGet})
1938
+ require.NoError(t, err)
1939
+ require.Equal(t, http.StatusNoContent, second.StatusCode)
1940
+ require.NoError(t, second.Body.Close())
1941
+
1942
+ assert.Equal(t, <-remoteAddrs, <-remoteAddrs)
1943
+ })
1944
+ }
1945
+}