@@ -11,6 +11,7 @@ import (
1111 "testing"
1212 "time"
1313
14+ "github.com/gorilla/websocket"
1415 "github.com/stretchr/testify/require"
1516)
1617
@@ -125,13 +126,14 @@ func TestNewHardenedServer_CapsRequestBody(t *testing.T) {
125126
126127 // Status carries the read outcome, so assertions read only the response code —
127128 // no state shared across the server/test goroutines.
128- handler := limitRequestBodyN (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
129+ handler := http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
130+ capRequestBody (w , r , limit )
129131 if _ , err := io .ReadAll (r .Body ); err != nil {
130132 w .WriteHeader (http .StatusRequestEntityTooLarge )
131133 return
132134 }
133135 w .WriteHeader (http .StatusOK )
134- }), limit )
136+ })
135137
136138 srv := httptest .NewServer (handler )
137139 defer srv .Close ()
@@ -148,3 +150,157 @@ func TestNewHardenedServer_CapsRequestBody(t *testing.T) {
148150 _ = resp .Body .Close ()
149151 require .Equal (t , http .StatusOK , resp .StatusCode , "body within the cap must be accepted" )
150152}
153+
154+ // TestApplyBodyReadDeadline_CutsSlowBody: a client that completes the header, promises
155+ // a large body, then stalls is cut at ~BodyReadTimeout instead of pinning the connection.
156+ func TestApplyBodyReadDeadline_CutsSlowBody (t * testing.T ) {
157+ t .Parallel ()
158+
159+ const deadline = 300 * time .Millisecond
160+
161+ readDone := make (chan error , 1 )
162+ handler := http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
163+ applyBodyReadDeadline (w , r , deadline )
164+ _ , err := io .ReadAll (r .Body )
165+ readDone <- err
166+ })
167+
168+ srv := httptest .NewServer (handler )
169+ defer srv .Close ()
170+
171+ conn , err := net .Dial ("tcp" , srv .Listener .Addr ().String ())
172+ require .Nil (t , err )
173+ defer func () { _ = conn .Close () }()
174+
175+ // Promise 1 MiB but send only a few bytes, then stall — the body read blocks.
176+ _ , err = fmt .Fprint (conn , "POST / HTTP/1.1\r \n Host: x\r \n Content-Length: 1048576\r \n \r \n partial" )
177+ require .Nil (t , err )
178+
179+ start := time .Now ()
180+ select {
181+ case err := <- readDone :
182+ require .Error (t , err , "a stalled body read must be cut by the deadline" )
183+ require .Less (t , time .Since (start ), 2 * time .Second , "body must be cut promptly (~BodyReadTimeout)" )
184+ case <- time .After (2 * time .Second ):
185+ t .Fatal ("stalled body read was not cut by the deadline" )
186+ }
187+ }
188+
189+ // TestApplyBodyReadDeadline_DoesNotCancelSlowHandler: a handler that reads the body then
190+ // works past the deadline still returns 200 — the deadline bounds the body read, not
191+ // post-read CPU work (e.g. a VM query) or the response write. (clear() has no observable
192+ // effect to assert in standard net/http, so this checks the property that matters.)
193+ func TestApplyBodyReadDeadline_DoesNotCancelSlowHandler (t * testing.T ) {
194+ t .Parallel ()
195+
196+ const deadline = 200 * time .Millisecond
197+
198+ handler := http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
199+ applyBodyReadDeadline (w , r , deadline )
200+ if _ , err := io .ReadAll (r .Body ); err != nil {
201+ w .WriteHeader (http .StatusInternalServerError )
202+ return
203+ }
204+ time .Sleep (3 * deadline ) // work well past the deadline
205+ w .WriteHeader (http .StatusOK )
206+ })
207+
208+ srv := httptest .NewServer (handler )
209+ defer srv .Close ()
210+
211+ resp , err := http .Post (srv .URL , "text/plain" , bytes .NewReader ([]byte ("hello" )))
212+ require .Nil (t , err )
213+ _ = resp .Body .Close ()
214+ require .Equal (t , http .StatusOK , resp .StatusCode ,
215+ "the body deadline must not cancel a slow post-read handler" )
216+ }
217+
218+ // TestRequestHasBody covers the predicate that gates the body deadline: bodiless requests
219+ // (including the bodiless-GET websocket handshakes) are skipped.
220+ func TestRequestHasBody (t * testing.T ) {
221+ t .Parallel ()
222+
223+ require .False (t , requestHasBody (& http.Request {ContentLength : 0 }), "no body (incl. websocket handshake)" )
224+ require .True (t , requestHasBody (& http.Request {ContentLength : 5 }), "known body" )
225+ require .True (t , requestHasBody (& http.Request {ContentLength : - 1 }), "chunked body" )
226+ }
227+
228+ // TestApplyBodyReadDeadline_SkipsBodilessUpgrade: a bodiless GET with Upgrade: websocket
229+ // is not wrapped (no deadline armed), so the /log and /subscribe streams are never severed.
230+ func TestApplyBodyReadDeadline_SkipsBodilessUpgrade (t * testing.T ) {
231+ t .Parallel ()
232+
233+ handler := http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
234+ before := r .Body
235+ applyBodyReadDeadline (w , r , BodyReadTimeout )
236+ // r.Body must be unchanged — a wrapped body would mean a deadline was armed.
237+ if r .Body != before {
238+ w .WriteHeader (http .StatusInternalServerError )
239+ return
240+ }
241+ w .WriteHeader (http .StatusOK )
242+ })
243+
244+ srv := httptest .NewServer (handler )
245+ defer srv .Close ()
246+
247+ req , err := http .NewRequest (http .MethodGet , srv .URL , nil ) // bodiless GET, like a handshake
248+ require .Nil (t , err )
249+ req .Header .Set ("Connection" , "Upgrade" )
250+ req .Header .Set ("Upgrade" , "websocket" )
251+
252+ resp , err := http .DefaultClient .Do (req )
253+ require .Nil (t , err )
254+ _ = resp .Body .Close ()
255+ require .Equal (t , http .StatusOK , resp .StatusCode , "bodiless websocket handshake must not be wrapped" )
256+ }
257+
258+ // TestNewHardenedServer_WebSocketStreamWorks: a real websocket upgrade survives the
259+ // hardened server — frames round-trip across idle gaps without the stream being severed.
260+ func TestNewHardenedServer_WebSocketStreamWorks (t * testing.T ) {
261+ t .Parallel ()
262+
263+ upgrader := websocket.Upgrader {CheckOrigin : func (* http.Request ) bool { return true }}
264+ mux := http .NewServeMux ()
265+ mux .HandleFunc ("/ws" , func (w http.ResponseWriter , r * http.Request ) {
266+ conn , err := upgrader .Upgrade (w , r , nil )
267+ if err != nil {
268+ return
269+ }
270+ defer func () { _ = conn .Close () }()
271+ for { // echo until the client closes
272+ mt , msg , err := conn .ReadMessage ()
273+ if err != nil {
274+ return
275+ }
276+ if err := conn .WriteMessage (mt , msg ); err != nil {
277+ return
278+ }
279+ }
280+ })
281+
282+ ln , err := net .Listen ("tcp" , "127.0.0.1:0" )
283+ require .Nil (t , err )
284+ srv := NewHardenedServer (ln .Addr ().String (), mux )
285+ go func () { _ = srv .Serve (ln ) }()
286+ defer func () { _ = srv .Close () }()
287+
288+ dialer := websocket.Dialer {}
289+ wsURL := "ws://" + ln .Addr ().String () + "/ws"
290+ conn , resp , err := dialer .Dial (wsURL , nil )
291+ require .Nil (t , err , "websocket upgrade must succeed through the hardened server" )
292+ require .Equal (t , http .StatusSwitchingProtocols , resp .StatusCode )
293+ defer func () { _ = conn .Close () }()
294+
295+ // Exchange frames with an idle gap between them to show the stream stays open and is
296+ // not cut by ReadHeaderTimeout/IdleTimeout/body deadline once upgraded.
297+ for i , gap := range []time.Duration {0 , 250 * time .Millisecond , 250 * time .Millisecond } {
298+ time .Sleep (gap )
299+ want := fmt .Sprintf ("frame-%d" , i )
300+ require .Nil (t , conn .WriteMessage (websocket .TextMessage , []byte (want )))
301+ _ = conn .SetReadDeadline (time .Now ().Add (2 * time .Second ))
302+ _ , got , err := conn .ReadMessage ()
303+ require .Nil (t , err , "frame %d must round-trip" , i )
304+ require .Equal (t , want , string (got ))
305+ }
306+ }
0 commit comments