1
+package integration
2
+
3
+import (
4
+ "fmt"
5
+ "io"
6
+ "net"
7
+ "net/http"
8
+ "net/http/fcgi"
9
+ "os"
10
+ "runtime"
11
+ "strings"
12
+ "testing"
13
+
14
+ "github.com/caddyserver/caddy/v2/caddytest"
15
+)
16
+
17
+// TestFastCGIChunkedRequestBody drives the case from caddyserver/caddy#7386: a
18
+// client sending a body with Transfer-Encoding: chunked through a fastcgi
19
+// reverse_proxy over a unix socket.
20
+//
21
+// FastCGI cannot express a body of unknown length — CGI/1.1 requires
22
+// CONTENT_LENGTH, and php-fpm hangs when it is absent or wrong and the body is
23
+// non-empty — so such a body is only forwardable once it has been buffered in
24
+// full. request_buffers is therefore what decides the outcome, and the default
25
+// of 4096 for this transport is far below a git push or a file upload.
26
+func TestFastCGIChunkedRequestBody(t *testing.T) {
27
+ if runtime.GOOS == "windows" {
28
+ t.SkipNow()
29
+ }
30
+
31
+ socketName := tempSocketName(t)
32
+
33
+ // Report how many body bytes actually arrived, so a truncated body shows up
34
+ // as a failure instead of passing quietly.
35
+ ln, err := net.Listen("unix", socketName)
36
+ if err != nil {
37
+ t.Fatalf("failed to listen on the socket: %s", err)
38
+ }
39
+ go fcgi.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { //nolint:errcheck
40
+ n, _ := io.Copy(io.Discard, r.Body)
41
+ fmt.Fprintf(w, "received %d", n)
42
+ }))
43
+ t.Cleanup(func() { ln.Close() })
44
+ runtime.Gosched()
45
+
46
+ const requestBuffers = 4096
47
+
48
+ tester := caddytest.NewTester(t)
49
+ tester.InitServer(fmt.Sprintf(`
50
+ {
51
+ "admin": {"listen": "localhost:2999"},
52
+ "apps": {
53
+ "pki": {"certificate_authorities": {"local": {"install_trust": false}}},
54
+ "http": {
55
+ "grace_period": 1,
56
+ "servers": {
57
+ "srv0": {
58
+ "listen": [":18080"],
59
+ "routes": [
60
+ {
61
+ "match": [{"path": ["/limited"]}],
62
+ "handle": [{
63
+ "handler": "reverse_proxy",
64
+ "request_buffers": %d,
65
+ "transport": {"protocol": "fastcgi"},
66
+ "upstreams": [{"dial": "unix/%s"}]
67
+ }]
68
+ },
69
+ {
70
+ "match": [{"path": ["/unlimited"]}],
71
+ "handle": [{
72
+ "handler": "reverse_proxy",
73
+ "request_buffers": -1,
74
+ "transport": {"protocol": "fastcgi"},
75
+ "upstreams": [{"dial": "unix/%s"}]
76
+ }]
77
+ }
78
+ ]
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ `, requestBuffers, socketName, socketName), "json")
85
+
86
+ tests := []struct {
87
+ name string
88
+ path string
89
+ size int
90
+ wantStatus int
91
+ }{
92
+ {name: "small body fits the buffer", path: "/limited", size: 16, wantStatus: http.StatusOK},
93
+ {name: "one below the buffer", path: "/limited", size: requestBuffers - 1, wantStatus: http.StatusOK},
94
+ // The body is buffered up to the limit and no further, so its length
95
+ // stays unknown and FastCGI has nothing to put in CONTENT_LENGTH.
96
+ {name: "exactly the buffer", path: "/limited", size: requestBuffers, wantStatus: http.StatusLengthRequired},
97
+ {name: "above the buffer", path: "/limited", size: requestBuffers * 4, wantStatus: http.StatusLengthRequired},
98
+ // Unlimited buffering is the remedy: the whole body is read, so its
99
+ // length is known and the request goes through at any size.
100
+ {name: "above the buffer, buffering unlimited", path: "/unlimited", size: requestBuffers * 4, wantStatus: http.StatusOK},
101
+ }
102
+
103
+ for _, tc := range tests {
104
+ t.Run(tc.name, func(t *testing.T) {
105
+ body := strings.Repeat("x", tc.size)
106
+ // A strings.Reader would let net/http size the body; wrapping it in
107
+ // a bare io.Reader is what makes the request chunked.
108
+ req, err := http.NewRequest(http.MethodPost, "http://localhost:18080"+tc.path, struct{ io.Reader }{strings.NewReader(body)})
109
+ if err != nil {
110
+ t.Fatalf("building request: %s", err)
111
+ }
112
+
113
+ resp := tester.AssertResponseCode(req, tc.wantStatus)
114
+ defer resp.Body.Close()
115
+ if tc.wantStatus != http.StatusOK {
116
+ return
117
+ }
118
+ got, err := io.ReadAll(resp.Body)
119
+ if err != nil {
120
+ t.Fatalf("reading response: %s", err)
121
+ }
122
+ // Every byte has to reach the upstream, not just a buffer's worth.
123
+ if want := fmt.Sprintf("received %d", tc.size); string(got) != want {
124
+ t.Errorf("upstream got %q, want %q", got, want)
125
+ }
126
+ })
127
+ }
128
+}
129
+
130
+// tempSocketName borrows a name inside a valid path to bind a unix socket on.
131
+func tempSocketName(t *testing.T) string {
132
+ t.Helper()
133
+ f, err := os.CreateTemp("", "*.sock")
134
+ if err != nil {
135
+ t.Fatalf("failed to create TempFile: %s", err)
136
+ }
137
+ name := f.Name()
138
+ f.Close()
139
+ os.Remove(name)
140
+ t.Cleanup(func() { os.Remove(name) })
141
+ return name
142
+}