diff --git a/hscontrol/handlers.go b/hscontrol/handlers.go index f0f1501c..358b8abd 100644 --- a/hscontrol/handlers.go +++ b/hscontrol/handlers.go @@ -80,13 +80,19 @@ func parseCapabilityVersion(req *http.Request) (tailcfg.CapabilityVersion, error return tailcfg.CapabilityVersion(clientCapabilityVersion), nil } +// verifyBodyLimit caps the request body for /verify. The DERP verify +// protocol payload (tailcfg.DERPAdmitClientRequest) is a few hundred +// bytes; 4 KiB is generous and prevents an unauthenticated client from +// OOMing the public router with arbitrarily large POSTs. +const verifyBodyLimit int64 = 4 * 1024 + func (h *Headscale) handleVerifyRequest( req *http.Request, writer io.Writer, ) error { body, err := io.ReadAll(req.Body) if err != nil { - return fmt.Errorf("reading request body: %w", err) + return NewHTTPError(http.StatusRequestEntityTooLarge, "request body too large", fmt.Errorf("reading request body: %w", err)) } var derpAdmitClientRequest tailcfg.DERPAdmitClientRequest @@ -124,6 +130,8 @@ func (h *Headscale) VerifyHandler( return } + req.Body = http.MaxBytesReader(writer, req.Body, verifyBodyLimit) + err := h.handleVerifyRequest(req, writer) if err != nil { httpError(writer, err) diff --git a/hscontrol/handlers_test.go b/hscontrol/handlers_test.go new file mode 100644 index 00000000..1058681e --- /dev/null +++ b/hscontrol/handlers_test.go @@ -0,0 +1,57 @@ +package hscontrol + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestHandleVerifyRequest_OversizedBodyRejected verifies that the +// /verify handler refuses POST bodies larger than verifyBodyLimit. +// The MaxBytesReader is applied in VerifyHandler, so we simulate +// the same wrapping here. +func TestHandleVerifyRequest_OversizedBodyRejected(t *testing.T) { + t.Parallel() + + body := strings.Repeat("x", int(verifyBodyLimit)+128) + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext( + context.Background(), + http.MethodPost, + "/verify", + bytes.NewReader([]byte(body)), + ) + req.Body = http.MaxBytesReader(rec, req.Body, verifyBodyLimit) + + h := &Headscale{} + + err := h.handleVerifyRequest(req, &bytes.Buffer{}) + if err == nil { + t.Fatal("oversized verify body must be rejected") + } + + httpErr, ok := errorAsHTTPError(err) + if !ok { + t.Fatalf("error must be an HTTPError, got: %T (%v)", err, err) + } + + assert.Equal(t, http.StatusRequestEntityTooLarge, httpErr.Code, + "oversized body must surface 413") +} + +// errorAsHTTPError is a small local helper that unwraps an HTTPError +// from an error chain. +func errorAsHTTPError(err error) (HTTPError, bool) { + var h HTTPError + if errors.As(err, &h) { + return h, true + } + + return HTTPError{}, false +}