Skip to content

Commit e3206fb

Browse files
authored
Merge pull request #255 from roadrunner-server/feature/400-when-needed
feature: do not raise 500's on bad request
2 parents 0bac2ba + ea54514 commit e3206fb

7 files changed

Lines changed: 263 additions & 21 deletions

File tree

handler/handle_error_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package handler
2+
3+
import (
4+
"errors"
5+
"io"
6+
"log/slog"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"testing"
11+
)
12+
13+
func TestHandleError_WritesBodyAndHeaders(t *testing.T) {
14+
cases := []struct {
15+
name string
16+
debugMode bool
17+
wantBody string // substring match (http.Error appends a trailing newline)
18+
}{
19+
{name: "non-debug uses StatusText", debugMode: false, wantBody: http.StatusText(http.StatusInternalServerError)},
20+
{name: "debug exposes error message", debugMode: true, wantBody: "boom"},
21+
}
22+
23+
for _, tc := range cases {
24+
t.Run(tc.name, func(t *testing.T) {
25+
h := &Handler{
26+
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
27+
internalHTTPCode: http.StatusInternalServerError,
28+
debugMode: tc.debugMode,
29+
}
30+
rec := httptest.NewRecorder()
31+
32+
h.handleError(rec, errors.New("boom"))
33+
34+
resp := rec.Result()
35+
defer func() { _ = resp.Body.Close() }()
36+
37+
if resp.StatusCode != http.StatusInternalServerError {
38+
t.Errorf("status: got %d, want %d", resp.StatusCode, http.StatusInternalServerError)
39+
}
40+
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
41+
t.Errorf("Content-Type: got %q, want text/plain prefix", ct)
42+
}
43+
if nosniff := resp.Header.Get("X-Content-Type-Options"); nosniff != "nosniff" {
44+
t.Errorf("X-Content-Type-Options: got %q, want nosniff", nosniff)
45+
}
46+
body, _ := io.ReadAll(resp.Body)
47+
if !strings.Contains(string(body), tc.wantBody) {
48+
t.Errorf("body: got %q, want substring %q", body, tc.wantBody)
49+
}
50+
})
51+
}
52+
}

handler/handler.go

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import (
44
"encoding/json"
55
stderr "errors"
66
"fmt"
7-
"html/template"
8-
"io"
97
"log/slog"
108
"net/http"
119
"strings"
@@ -122,7 +120,15 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
122120
// marshal req.Uploads AFTER, not before, to capture the final state.
123121
ups.Open(h.log, h.uploads.dir, h.uploads.forbid, h.uploads.allow)
124122
if req.Uploads, err = json.Marshal(ups); err != nil {
125-
h.handleRequestErr(w, r, ups, err, start)
123+
// Marshaling our own Uploads struct is a server-side bug, not
124+
// client input — keep the 5xx semantics rather than letting it
125+
// fall through handleRequestErr's 4xx default.
126+
clearUploads(h.log, r, ups)
127+
h.handleError(w, err)
128+
h.log.Error("marshal uploads",
129+
"elapsed", time.Since(start).Milliseconds(),
130+
"error", err,
131+
)
126132
return
127133
}
128134
}
@@ -247,6 +253,13 @@ func handleProtoTrailers(h map[string]*httpV2.HttpHeaderValue) {
247253
delete(h, Trailer)
248254
}
249255

256+
// handleRequestErr writes the response for an error produced while parsing the
257+
// client's request. Such errors are 4xx by construction — populateBody only
258+
// touches client-supplied bytes (multipart form, body, query). The default is
259+
// http.StatusBadRequest; call sites that know a more specific code (e.g. 413
260+
// for size limits) wrap the error with withStatus, which wins here via
261+
// errors.As. Server-internal failures must not flow through this path — see
262+
// handleError instead.
250263
func (h *Handler) handleRequestErr(w http.ResponseWriter, r *http.Request, ups *Uploads, err error, start time.Time) {
251264
clearUploads(h.log, r, ups)
252265

@@ -258,14 +271,9 @@ func (h *Handler) handleRequestErr(w http.ResponseWriter, r *http.Request, ups *
258271
return
259272
}
260273

261-
status := http.StatusInternalServerError
262-
switch {
263-
case isMaxBytesError(err):
264-
status = http.StatusRequestEntityTooLarge
265-
case stderr.Is(err, io.EOF), stderr.Is(err, io.ErrUnexpectedEOF):
266-
status = http.StatusBadRequest
267-
case stderr.Is(err, ErrMaxLevelExceeded):
268-
status = http.StatusBadRequest
274+
status := http.StatusBadRequest
275+
if sErr, ok := stderr.AsType[*statusError](err); ok {
276+
status = sErr.Status()
269277
}
270278

271279
http.Error(w, err.Error(), status)
@@ -286,15 +294,15 @@ func (h *Handler) handleSubmitErr(w http.ResponseWriter, err error) {
286294
func (h *Handler) handleError(w http.ResponseWriter, err error) {
287295
// internalHTTPCode is a config-provided HTTP status, defaulted to 500 and
288296
// always within [100, 599] in practice. The cast is safe.
289-
w.WriteHeader(int(h.internalHTTPCode)) //nolint:gosec // G115: bounded HTTP status code
297+
status := int(h.internalHTTPCode) //nolint:gosec // G115: bounded HTTP status code
298+
msg := http.StatusText(status)
290299
if h.debugMode {
291-
template.HTMLEscape(w, []byte(err.Error()))
300+
msg = err.Error()
292301
}
293-
}
294-
295-
func isMaxBytesError(err error) bool {
296-
_, ok := stderr.AsType[*http.MaxBytesError](err)
297-
return ok
302+
// http.Error sets Content-Type and X-Content-Type-Options: nosniff and
303+
// writes msg as the body — keeps the response well-formed even when the
304+
// configured internalHTTPCode is a 5xx and we're not in debug mode.
305+
http.Error(w, msg, status)
298306
}
299307

300308
func clearUploads(log *slog.Logger, r *http.Request, ups *Uploads) {

handler/request.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ func populateBody(r *http.Request, req *httpV2.HttpHandlerRequest, uid, gid int)
110110
// plugin level (plugin.applyBundledMiddleware), so gosec's
111111
// "unbounded form parsing" warning is a false positive here.
112112
if err := r.ParseMultipartForm(defaultMaxMemory); err != nil { //nolint:gosec // G120: bounded upstream
113-
return nil, err
113+
return nil, classifyParseErr(err)
114114
}
115115
ups, err := parseUploads(r, uid, gid)
116116
if err != nil {
@@ -129,8 +129,12 @@ func populateBody(r *http.Request, req *httpV2.HttpHandlerRequest, uid, gid int)
129129
return ups, nil
130130

131131
default:
132+
// r.Body is wrapped by middleware/maxRequest.go's MaxBytesReader, so
133+
// ReadAll can fail with *http.MaxBytesError on payload overflow —
134+
// classifyParseErr promotes that to 413 (otherwise it would fall to
135+
// handleRequestErr's 400 default).
132136
var err error
133137
req.Body, err = io.ReadAll(r.Body)
134-
return nil, err
138+
return nil, classifyParseErr(err)
135139
}
136140
}

handler/status_error.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package handler
2+
3+
import (
4+
"errors"
5+
"mime/multipart"
6+
"net/http"
7+
)
8+
9+
// statusError carries an explicit HTTP status code through an error chain.
10+
// Call sites that know the correct response code wrap with withStatus;
11+
// handleRequestErr unwraps via errors.As so the wrapped status wins over
12+
// the default 4xx classification.
13+
type statusError struct {
14+
status int
15+
err error
16+
}
17+
18+
func (e *statusError) Error() string { return e.err.Error() }
19+
func (e *statusError) Unwrap() error { return e.err }
20+
func (e *statusError) Status() int { return e.status }
21+
22+
func withStatus(status int, err error) error {
23+
if err == nil {
24+
return nil
25+
}
26+
return &statusError{status: status, err: err}
27+
}
28+
29+
// classifyParseErr promotes payload-size errors (*http.MaxBytesError and
30+
// multipart.ErrMessageTooLarge) to 413 by wrapping with withStatus. Other
31+
// errors pass through unchanged so they hit handleRequestErr's 400 default —
32+
// every error reaching this helper originates from parsing client input.
33+
func classifyParseErr(err error) error {
34+
if err == nil {
35+
return nil
36+
}
37+
if _, ok := errors.AsType[*http.MaxBytesError](err); ok || errors.Is(err, multipart.ErrMessageTooLarge) {
38+
return withStatus(http.StatusRequestEntityTooLarge, err)
39+
}
40+
return err
41+
}

handler/status_error_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package handler
2+
3+
import (
4+
"errors"
5+
"mime/multipart"
6+
"net/http"
7+
"testing"
8+
)
9+
10+
func TestStatusError_Wrapping(t *testing.T) {
11+
base := errors.New("boom")
12+
wrapped := withStatus(http.StatusTeapot, base)
13+
14+
if wrapped.Error() != "boom" {
15+
t.Fatalf("Error(): got %q, want %q", wrapped.Error(), "boom")
16+
}
17+
18+
sErr, ok := errors.AsType[*statusError](wrapped)
19+
if !ok {
20+
t.Fatal("errors.AsType[*statusError] failed")
21+
}
22+
if sErr.Status() != http.StatusTeapot {
23+
t.Errorf("Status(): got %d, want %d", sErr.Status(), http.StatusTeapot)
24+
}
25+
if !errors.Is(wrapped, base) {
26+
t.Error("errors.Is should unwrap to the base error")
27+
}
28+
}
29+
30+
func TestStatusError_WithStatusNil(t *testing.T) {
31+
if got := withStatus(http.StatusBadRequest, nil); got != nil {
32+
t.Errorf("withStatus(_, nil): got %v, want nil", got)
33+
}
34+
}
35+
36+
func TestClassifyParseErr(t *testing.T) {
37+
t.Run("nil passthrough", func(t *testing.T) {
38+
if got := classifyParseErr(nil); got != nil {
39+
t.Errorf("got %v, want nil", got)
40+
}
41+
})
42+
43+
t.Run("MaxBytesError promotes to 413", func(t *testing.T) {
44+
err := classifyParseErr(&http.MaxBytesError{Limit: 1024})
45+
sErr, ok := errors.AsType[*statusError](err)
46+
if !ok || sErr.Status() != http.StatusRequestEntityTooLarge {
47+
t.Errorf("got %v, want 413 wrapper", err)
48+
}
49+
})
50+
51+
t.Run("ErrMessageTooLarge promotes to 413", func(t *testing.T) {
52+
err := classifyParseErr(multipart.ErrMessageTooLarge)
53+
sErr, ok := errors.AsType[*statusError](err)
54+
if !ok || sErr.Status() != http.StatusRequestEntityTooLarge {
55+
t.Errorf("got %v, want 413 wrapper", err)
56+
}
57+
})
58+
59+
t.Run("unknown error passes through unwrapped", func(t *testing.T) {
60+
// "invalid semicolon separator in query" is a plain errors.New from
61+
// url.ParseQuery — by passing through (no statusError wrapper), it
62+
// lands on handleRequestErr's 400 default. Protects issue #2353.
63+
base := errors.New("invalid semicolon separator in query")
64+
got := classifyParseErr(base)
65+
if !errors.Is(got, base) {
66+
t.Errorf("expected base error preserved in chain; got %v", got)
67+
}
68+
if _, ok := errors.AsType[*statusError](got); ok {
69+
t.Error("plain errors must not be wrapped with a status")
70+
}
71+
})
72+
}

tests/handler_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
httpV2 "github.com/roadrunner-server/api-go/v6/http/v2"
1818
"github.com/roadrunner-server/http/v6/config"
1919
"github.com/roadrunner-server/http/v6/handler"
20+
httpMw "github.com/roadrunner-server/http/v6/middleware"
2021
"github.com/roadrunner-server/http/v6/proxy"
2122
"github.com/stretchr/testify/assert"
2223
"github.com/stretchr/testify/require"
@@ -469,6 +470,70 @@ func TestHandler_Multipart_PATCH(t *testing.T) {
469470
assertStandardFormTree(t, res, "value")
470471
}
471472

473+
// TestHandler_NonMultipart_OversizeBody guards against the regression where a
474+
// non-multipart body exceeding MaxRequestSize returned 400 instead of 413,
475+
// because the original handleRequestErr's explicit MaxBytesError case had
476+
// been collapsed into the 400 default. classifyParseErr now promotes the
477+
// MaxBytesError on the io.ReadAll path back to 413.
478+
func TestHandler_NonMultipart_OversizeBody(t *testing.T) {
479+
const maxBytes = 64
480+
cfg := defaultCfg()
481+
q := proxy.NewQueue(cfg.Proxy.InboxSize)
482+
stop := helpers.StartFakeWorker(t.Context(), q, multipartEchoResponder)
483+
t.Cleanup(stop)
484+
485+
h := handler.NewHandler(cfg, q, testLog.SlogLogger())
486+
// Wrap with the same MaxRequestSize middleware the plugin applies in
487+
// production (init.go) — without it ReadAll never sees MaxBytesError.
488+
hs := &http.Server{
489+
Addr: "127.0.0.1:8190",
490+
Handler: httpMw.MaxRequestSize(h, maxBytes),
491+
ReadHeaderTimeout: time.Minute,
492+
}
493+
go func() {
494+
if err := hs.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
495+
t.Errorf("listen: %v", err)
496+
}
497+
}()
498+
t.Cleanup(func() { _ = hs.Shutdown(context.Background()) })
499+
time.Sleep(10 * time.Millisecond)
500+
501+
body := strings.Repeat("x", int(maxBytes)*4)
502+
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "http://127.0.0.1:8190/", strings.NewReader(body))
503+
require.NoError(t, err)
504+
req.Header.Set("Content-Type", "application/json")
505+
506+
r, err := http.DefaultClient.Do(req)
507+
require.NoError(t, err)
508+
defer func() { _ = r.Body.Close() }()
509+
510+
assert.Equal(t, http.StatusRequestEntityTooLarge, r.StatusCode)
511+
}
512+
513+
// TestHandler_Multipart_SemicolonInQuery covers issue #2353: a malformed
514+
// query string causes ParseMultipartForm (which internally parses the URL
515+
// query) to fail with "invalid semicolon separator in query". The response
516+
// must be 400 Bad Request, not the historical 500.
517+
func TestHandler_Multipart_SemicolonInQuery(t *testing.T) {
518+
env := newHandlerEnv(t, "127.0.0.1:8189", defaultCfg(), multipartEchoResponder)
519+
defer env.close(t)
520+
521+
var mb bytes.Buffer
522+
w := multipart.NewWriter(&mb)
523+
require.NoError(t, w.WriteField("key", "value"))
524+
require.NoError(t, w.Close())
525+
526+
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "http://127.0.0.1:8189/?a=b;c", &mb)
527+
require.NoError(t, err)
528+
req.Header.Set("Content-Type", w.FormDataContentType())
529+
530+
r, err := http.DefaultClient.Do(req)
531+
require.NoError(t, err)
532+
defer func() { _ = r.Body.Close() }()
533+
534+
assert.Equal(t, http.StatusBadRequest, r.StatusCode)
535+
}
536+
472537
func doMultipartFormPost(t *testing.T, method, urlStr string) map[string]any {
473538
t.Helper()
474539
var mb bytes.Buffer

tests/http_plugin_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1714,7 +1714,7 @@ func TestHTTPBigRequestSize(t *testing.T) {
17141714
b, err := io.ReadAll(r.Body)
17151715
assert.NoError(t, err)
17161716
assert.Equal(t, http.StatusRequestEntityTooLarge, r.StatusCode)
1717-
assert.Equal(t, "serve_http: http: request body too large\n", string(b))
1717+
assert.Equal(t, "http: request body too large\n", string(b))
17181718

17191719
err = r.Body.Close()
17201720
assert.NoError(t, err)

0 commit comments

Comments
 (0)