Repository navigation
Expand file tree
/
Copy pathrequestbodytimeout_test.go
More file actions
264 lines (211 loc) · 8.31 KB
/
Copy pathrequestbodytimeout_test.go
File metadata and controls
264 lines (211 loc) · 8.31 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
package frankenphp_test
import (
"fmt"
"io"
"net"
"net/http"
"os"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/dunglas/frankenphp"
"github.com/stretchr/testify/require"
)
// newH2CServer starts a cleartext HTTP/2 (h2c) server for handler and returns
// its address and a matching client. h2c drives the SetReadDeadline path that
// only HTTP/2 exercises. The listener and server are closed via t.Cleanup.
func newH2CServer(t *testing.T, handler http.HandlerFunc) (addr string, client *http.Client) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
protocols := new(http.Protocols)
protocols.SetUnencryptedHTTP2(true)
srv := &http.Server{Handler: handler, Protocols: protocols}
go func() { _ = srv.Serve(ln) }()
t.Cleanup(func() {
_ = srv.Close()
_ = ln.Close()
})
client = &http.Client{Transport: &http.Transport{Protocols: protocols}}
return ln.Addr().String(), client
}
// TestRequestBodyTimeout proves that WithRequestBodyTimeout bounds a slow-POST
// client: it announces a large Content-Length, then stalls without sending the
// body. Without the option the PHP thread would block in Body.Read until the
// connection closed; with it, the read is cut off and the request completes.
func TestRequestBodyTimeout(t *testing.T) {
require.NoError(t, frankenphp.Init())
defer frankenphp.Shutdown()
cwd, _ := os.Getwd()
handler := func(w http.ResponseWriter, r *http.Request) {
req, err := frankenphp.NewRequestWithContext(r,
frankenphp.WithRequestDocumentRoot(cwd+"/testdata/", false),
frankenphp.WithRequestBodyTimeout(300*time.Millisecond),
)
require.NoError(t, err)
require.NoError(t, frankenphp.ServeHTTP(w, req))
}
ts := newRawServer(t, handler)
defer ts.Close()
conn, err := net.Dial("tcp", ts.Addr())
require.NoError(t, err)
defer func() { _ = conn.Close() }()
// Announce a 1 MiB body, then send nothing: a classic slow POST.
_, err = fmt.Fprintf(conn,
"POST /read-input.php HTTP/1.1\r\nHost: %s\r\nContent-Type: application/octet-stream\r\nContent-Length: 1048576\r\nConnection: close\r\n\r\n",
ts.Addr(),
)
require.NoError(t, err)
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
start := time.Now()
resp, err := io.ReadAll(conn)
elapsed := time.Since(start)
require.NoError(t, err)
// The 300ms idle timeout (applied at most twice by PHP's read loop) must
// release the thread well before the client's 5s read deadline.
require.Less(t, elapsed, 4*time.Second, "slow body must be bounded by the timeout")
require.Contains(t, string(resp), "200 OK")
// PHP saw an empty body: php://input read zero bytes.
require.Contains(t, string(resp), "read=0")
}
// TestRequestBodyTimeoutHTTP2 is the HTTP/2 counterpart of the test above: over
// HTTP/2 the deadline lands on the stream (via x/net) rather than the net.Conn,
// so this exercises the SetReadDeadline path that HTTP/1 never touches. A slow
// POST that trips the idle timeout must bound the read and return cleanly.
// The nil-dereference crash of php/frankenphp#2535 needs the writer to be used
// after its stream is finalized; see TestSetReadDeadlineRecoversFromPanic.
func TestRequestBodyTimeoutHTTP2(t *testing.T) {
require.NoError(t, frankenphp.Init())
defer frankenphp.Shutdown()
cwd, _ := os.Getwd()
handler := func(w http.ResponseWriter, r *http.Request) {
req, err := frankenphp.NewRequestWithContext(r,
frankenphp.WithRequestDocumentRoot(cwd+"/testdata/", false),
frankenphp.WithRequestBodyTimeout(300*time.Millisecond),
)
require.NoError(t, err)
require.NoError(t, frankenphp.ServeHTTP(w, req))
}
addr, client := newH2CServer(t, handler)
// A body that never sends data: the server blocks in Body.Read until the
// idle timeout fires. Close the writer once the request returns.
pr, pw := io.Pipe()
defer func() { _ = pw.Close() }()
req, err := http.NewRequest(http.MethodPost, "http://"+addr+"/read-input.php", pr)
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
start := time.Now()
resp, err := client.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
elapsed := time.Since(start)
require.Less(t, elapsed, 4*time.Second, "slow body must be bounded by the timeout")
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Contains(t, string(body), "read=0")
}
// TestFinishRequestThenReadBodyHTTP2 reproduces php/frankenphp#2535: a script
// that calls frankenphp_finish_request() and then reads php://input triggers
// go_read_post after the HTTP/2 responseWriter has been finalized. Setting a
// read deadline on that dead writer would dereference a nil pointer and crash
// the whole process. enable_post_data_reading=Off defers the body read until
// the explicit php://input access, i.e. after the request is finished.
func TestFinishRequestThenReadBodyHTTP2(t *testing.T) {
iniDir := t.TempDir()
require.NoError(t, os.WriteFile(iniDir+"/php.ini", []byte("enable_post_data_reading=Off\n"), 0o600))
t.Setenv("PHPRC", iniDir+"/php.ini")
require.NoError(t, frankenphp.Init())
defer frankenphp.Shutdown()
cwd, _ := os.Getwd()
handler := func(w http.ResponseWriter, r *http.Request) {
req, err := frankenphp.NewRequestWithContext(r,
frankenphp.WithRequestDocumentRoot(cwd+"/testdata/", false),
frankenphp.WithRequestBodyTimeout(300*time.Millisecond),
)
require.NoError(t, err)
require.NoError(t, frankenphp.ServeHTTP(w, req))
}
addr, client := newH2CServer(t, handler)
req, err := http.NewRequest(http.MethodPost, "http://"+addr+"/finish-then-read-input.php", strings.NewReader("hello world"))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := client.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
_, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
}
// lateReadBody mimics Caddy's IdleTimeoutReader and counts reads made after the handler returned
type lateReadBody struct {
io.ReadCloser
rc *http.ResponseController
returned *atomic.Bool
lateReads *atomic.Int32
}
func (b *lateReadBody) Read(p []byte) (int, error) {
if b.returned.Load() {
b.lateReads.Add(1)
return 0, io.EOF
}
_ = b.rc.SetReadDeadline(time.Now().Add(time.Minute))
return b.ReadCloser.Read(p)
}
// https://github.com/php/frankenphp/issues/2694
func TestFinishRequestThenShutdownDoesNotReadBodyHTTP2(t *testing.T) {
require.NoError(t, frankenphp.Init())
defer frankenphp.Shutdown()
var returned atomic.Bool
var lateReads atomic.Int32
cwd, _ := os.Getwd()
handler := func(w http.ResponseWriter, r *http.Request) {
defer returned.Store(true)
r.Body = &lateReadBody{
ReadCloser: r.Body,
rc: http.NewResponseController(w),
returned: &returned,
lateReads: &lateReads,
}
req, err := frankenphp.NewRequestWithContext(r, frankenphp.WithRequestDocumentRoot(cwd+"/testdata/", false))
require.NoError(t, err)
require.NoError(t, frankenphp.ServeHTTP(w, req))
}
addr, client := newH2CServer(t, handler)
// PUT bodies are not read at startup, so PHP drains them at shutdown.
req, err := http.NewRequest(http.MethodPut, "http://"+addr+"/finish-without-reading-input.php", strings.NewReader("hello world"))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := client.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
_, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// waits for PHP's request shutdown, which drains the body
frankenphp.Shutdown()
require.True(t, returned.Load())
require.Zero(t, lateReads.Load(), "request body read after the handler returned")
}
// rawServer is a minimal HTTP server exposing its listener address so a test
// can drive it with a raw TCP connection (needed to simulate a stalled body).
type rawServer struct {
ln net.Listener
srv *http.Server
}
func newRawServer(t *testing.T, handler http.HandlerFunc) *rawServer {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
srv := &http.Server{Handler: handler}
go func() {
_ = srv.Serve(ln)
}()
return &rawServer{ln: ln, srv: srv}
}
func (s *rawServer) Addr() string { return s.ln.Addr().String() }
func (s *rawServer) Close() {
_ = s.srv.Close()
_ = s.ln.Close()
}