From 66acddf9c73b2dfe030c244efe0a1ef849d4d525 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Thu, 10 Sep 2026 20:07:20 +0300 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20error=20pipeline=20=E2=80=94=20Rout?= =?UTF-8?q?er.SetErrorHandler=20with=20default=20error=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD: 9 tests written BEFORE implementation, all pass. - Add ErrorHandler type and Router.SetErrorHandler() - Default handler maps: Problem→status, ValidationErrors→422, binding errors→400/415, unknown→500 without details - Add c.written flag to prevent double-write on late errors - Update existing tests: validation 500→422, binding 500→400 Fixes critical audit finding: every error was plain-text 500. --- box_test.go | 6 +- context.go | 8 +- error_handler_test.go | 251 ++++++++++++++++++++++++++++++++++++++++++ router.go | 75 ++++++++++++- validation_test.go | 20 ++-- 5 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 error_handler_test.go diff --git a/box_test.go b/box_test.go index d0db06a..8bc730e 100644 --- a/box_test.go +++ b/box_test.go @@ -285,9 +285,9 @@ func TestGenericContext_InvalidJSON(t *testing.T) { r.ServeHTTP(w, req) - // Should return 500 due to binding error (handled by router error handling) - if w.Code != 500 { - t.Errorf("expected status 500, got %d", w.Code) + // Should return 400 due to binding error (malformed JSON → Bad Request). + if w.Code != 400 { + t.Errorf("expected status 400, got %d", w.Code) } } diff --git a/context.go b/context.go index a6e27b2..9d2864f 100644 --- a/context.go +++ b/context.go @@ -80,6 +80,9 @@ type Context struct { // data stores arbitrary values for passing data between middleware. data map[string]any + // written tracks if any response body has been written. + written bool + // Middleware chain execution. // Pre-allocated with capacity 16 to avoid allocations for typical middleware chains. handlers []HandlerFunc @@ -157,6 +160,7 @@ func (c *Context) reset() { c.index = -1 c.aborted = false + c.written = false } // Next executes the next handler in the middleware chain. @@ -332,6 +336,7 @@ func (c *Context) PostForm(name string) string { // // return c.String(200, "Hello, World!") func (c *Context) String(code int, s string) error { + c.written = true c.Response.Header().Set("Content-Type", "text/plain; charset=utf-8") c.Response.WriteHeader(code) _, err := c.Response.Write([]byte(s)) @@ -345,6 +350,7 @@ func (c *Context) String(code int, s string) error { // // return c.JSON(200, map[string]string{"message": "success"}) func (c *Context) JSON(code int, obj any) error { + c.written = true c.Response.Header().Set("Content-Type", "application/json; charset=utf-8") c.Response.WriteHeader(code) encoder := json.NewEncoder(c.Response) @@ -652,7 +658,7 @@ func (c *Context) GetBool(key string) bool { // generic handler adapter. If you need custom error handling, check // the error returned by your handler logic instead. func (c *Context) Problem(p Problem) error { - // Set proper Content-Type for RFC 9457. + c.written = true c.Response.Header().Set("Content-Type", "application/problem+json; charset=utf-8") c.Response.WriteHeader(p.Status) encoder := json.NewEncoder(c.Response) diff --git a/error_handler_test.go b/error_handler_test.go new file mode 100644 index 0000000..5bbfd1d --- /dev/null +++ b/error_handler_test.go @@ -0,0 +1,251 @@ +// Copyright 2025 coregx. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package fursy + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// --- TDD: Error Pipeline Tests (write BEFORE implementation) --- +// +// These tests verify that handler errors are mapped to correct HTTP +// status codes and RFC 9457 Problem Details responses, instead of +// always returning plain-text 500. + +// errorHandlerReq is a test request type with validation tags. +type errorHandlerReq struct { + Name string `json:"name"` + Email string `json:"email"` +} + +// errorHandlerRes is a test response type. +type errorHandlerRes struct { + ID int `json:"id"` + Message string `json:"message"` +} + +// testValidator validates errorHandlerReq — name must be non-empty. +type testValidator struct{} + +func (v *testValidator) Validate(i interface{}) error { + if req, ok := i.(*errorHandlerReq); ok && req.Name == "" { + return ValidationErrors{ + {Field: "name", Message: "name is required"}, + } + } + return nil +} + +// TestErrorHandler_ValidationError_Returns422 verifies that a validation +// error from Bind() produces a 422 response with RFC 9457 Problem body, +// not a plain-text 500. +func TestErrorHandler_ValidationError_Returns422(t *testing.T) { + r := New() + r.SetValidator(&testValidator{}) + + r.POST("/users", func(c *Box[errorHandlerReq, errorHandlerRes]) error { + return c.OK(errorHandlerRes{ID: 1, Message: "created"}) + }) + + body := `{"name":"","email":"test@example.com"}` + req := httptest.NewRequest("POST", "/users", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnprocessableEntity { + t.Errorf("validation error: want status 422, got %d; body: %s", w.Code, w.Body.String()) + } + + ct := w.Header().Get("Content-Type") + if !strings.Contains(ct, "application/problem+json") { + t.Errorf("validation error: want Content-Type application/problem+json, got %q", ct) + } +} + +// TestErrorHandler_ProblemNotFound_Returns404 verifies that returning a +// Problem with Status 404 produces a 404 response, not 500. +func TestErrorHandler_ProblemNotFound_Returns404(t *testing.T) { + r := New() + + r.Handle("GET", "/users/:id", func(_ *Context) error { + return NotFound("user not found") + }) + + req := httptest.NewRequest("GET", "/users/999", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Problem NotFound: want status 404, got %d; body: %s", w.Code, w.Body.String()) + } + + ct := w.Header().Get("Content-Type") + if !strings.Contains(ct, "application/problem+json") { + t.Errorf("Problem NotFound: want Content-Type application/problem+json, got %q", ct) + } + + var prob map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &prob); err != nil { + t.Fatalf("Problem NotFound: response not valid JSON: %v", err) + } + if prob["status"] != float64(404) { + t.Errorf("Problem NotFound: want status=404 in body, got %v", prob["status"]) + } +} + +// TestErrorHandler_ProblemBadRequest_Returns400 verifies Problem with 400. +func TestErrorHandler_ProblemBadRequest_Returns400(t *testing.T) { + r := New() + + r.Handle("POST", "/data", func(_ *Context) error { + return BadRequest("invalid input") + }) + + req := httptest.NewRequest("POST", "/data", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Problem BadRequest: want status 400, got %d; body: %s", w.Code, w.Body.String()) + } +} + +// TestErrorHandler_BindingMalformedJSON_Returns400 verifies that +// malformed JSON in request body produces 400, not 500. +func TestErrorHandler_BindingMalformedJSON_Returns400(t *testing.T) { + r := New() + + r.POST("/users", func(c *Box[errorHandlerReq, errorHandlerRes]) error { + return c.OK(errorHandlerRes{ID: 1, Message: "created"}) + }) + + body := `{invalid json` + req := httptest.NewRequest("POST", "/users", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("malformed JSON: want status 400, got %d; body: %s", w.Code, w.Body.String()) + } +} + +// TestErrorHandler_BindingEmptyBody_Returns400 verifies that POST with +// empty body produces 400, not 500. +func TestErrorHandler_BindingEmptyBody_Returns400(t *testing.T) { + r := New() + + r.POST("/users", func(c *Box[errorHandlerReq, errorHandlerRes]) error { + return c.OK(errorHandlerRes{ID: 1, Message: "created"}) + }) + + req := httptest.NewRequest("POST", "/users", http.NoBody) + req.Header.Set("Content-Type", "application/json") + req.ContentLength = 0 + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("empty body: want status 400, got %d; body: %s", w.Code, w.Body.String()) + } +} + +// TestErrorHandler_UnknownError_Returns500_NoDetails verifies that an +// unknown error produces 500 WITHOUT exposing error details. +func TestErrorHandler_UnknownError_Returns500_NoDetails(t *testing.T) { + r := New() + + r.Handle("GET", "/crash", func(_ *Context) error { + return errors.New("database connection failed: host=prod-db password=secret") + }) + + req := httptest.NewRequest("GET", "/crash", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("unknown error: want status 500, got %d", w.Code) + } + + body := w.Body.String() + if strings.Contains(body, "database") || strings.Contains(body, "password") || strings.Contains(body, "secret") { + t.Errorf("unknown error: SECURITY — error details leaked to client: %s", body) + } +} + +// TestErrorHandler_Custom verifies that SetErrorHandler overrides the default. +func TestErrorHandler_Custom(t *testing.T) { + r := New() + r.SetErrorHandler(func(c *Context, err error) { + c.SetHeader("X-Custom-Error", "true") + _ = c.String(http.StatusTeapot, "custom: "+err.Error()) + }) + + r.Handle("GET", "/test", func(_ *Context) error { + return errors.New("test error") + }) + + req := httptest.NewRequest("GET", "/test", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusTeapot { + t.Errorf("custom handler: want status 418, got %d", w.Code) + } + if w.Header().Get("X-Custom-Error") != "true" { + t.Error("custom handler: X-Custom-Error header not set") + } +} + +// TestErrorHandler_AlreadyWritten_NoDuplicateBody verifies that if a +// handler has already written headers and body, the error handler does +// not append additional content. +func TestErrorHandler_AlreadyWritten_NoDuplicateBody(t *testing.T) { + r := New() + + r.Handle("GET", "/test", func(c *Context) error { + _ = c.JSON(http.StatusOK, map[string]bool{"ok": true}) + return errors.New("late error") + }) + + req := httptest.NewRequest("GET", "/test", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + body := w.Body.String() + if strings.Contains(body, "Internal Server Error") { + t.Errorf("already written: error handler appended to body: %q", body) + } + if !strings.HasPrefix(body, `{"ok":true}`) { + t.Errorf("already written: body corrupted, want prefix %q got %q", `{"ok":true}`, body) + } +} + +// TestErrorHandler_UnsupportedMediaType_Returns415 verifies that +// sending an unsupported Content-Type produces 415. +func TestErrorHandler_UnsupportedMediaType_Returns415(t *testing.T) { + r := New() + + r.POST("/users", func(c *Box[errorHandlerReq, errorHandlerRes]) error { + return c.OK(errorHandlerRes{ID: 1, Message: "created"}) + }) + + body := `name=test` + req := httptest.NewRequest("POST", "/users", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "text/plain") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnsupportedMediaType { + t.Errorf("unsupported media type: want status 415, got %d; body: %s", w.Code, w.Body.String()) + } +} diff --git a/router.go b/router.go index 6a63621..adb557b 100644 --- a/router.go +++ b/router.go @@ -66,6 +66,7 @@ import ( "syscall" "time" + "github.com/coregx/fursy/internal/binding" "github.com/coregx/fursy/internal/radix" ) @@ -109,6 +110,12 @@ const ( // router.Handle("GET", "/health", func(c *fursy.Context) error { // return c.Text("OK") // }) + +// ErrorHandler is a function that handles errors returned by handlers and middleware. +// It receives the Context and the error, and should write an appropriate response. +type ErrorHandler func(c *Context, err error) + +// Router is the main HTTP router for FURSY. type Router struct { // trees stores one radix tree per HTTP method for efficient routing. trees map[string]*radix.Tree @@ -119,6 +126,9 @@ type Router struct { // middleware stores global middleware that executes for all routes. middleware []HandlerFunc + // errorHandler handles errors from handlers. If nil, uses defaultErrorHandler. + errorHandler ErrorHandler + // validator is an optional validator for automatic request validation. // If set, Box.Bind() will automatically validate request bodies. // Set using Router.SetValidator(). @@ -239,6 +249,17 @@ func (r *Router) SetValidator(v Validator) *Router { return r } +// SetErrorHandler sets a custom error handler for the router. +// If not set, the default error handler maps errors to appropriate HTTP responses: +// - Problem → uses Problem.Status (e.g. 404, 400) +// - ValidationErrors → 422 with RFC 9457 body +// - Binding errors → 400 +// - Unknown errors → 500 without details +func (r *Router) SetErrorHandler(h ErrorHandler) *Router { + r.errorHandler = h + return r +} + // WithInfo sets the API metadata for OpenAPI generation. // // This configures the info section of the generated OpenAPI document. @@ -623,8 +644,60 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { c.aborted = false if err := c.Next(); err != nil { - _ = c.String(http.StatusInternalServerError, "Internal Server Error") + r.handleError(c, err) + } +} + +// handleError dispatches the error to the custom or default error handler. +func (r *Router) handleError(c *Context, err error) { + if r.errorHandler != nil { + r.errorHandler(c, err) + return + } + defaultErrorHandler(c, err) +} + +// defaultErrorHandler maps errors to appropriate HTTP responses. +func defaultErrorHandler(c *Context, err error) { + // If response headers already sent, don't write again — would corrupt body. + if c.written { + return } + + // Problem → use its Status field. + var prob Problem + if errors.As(err, &prob) { + _ = c.Problem(prob) + return + } + + // ValidationErrors → 422 with RFC 9457 body. + var valErrs ValidationErrors + if errors.As(err, &valErrs) { + _ = c.Problem(ValidationProblem(valErrs)) + return + } + + // Binding errors → 400 or 415. + if errors.Is(err, binding.ErrUnsupportedMediaType) { + _ = c.String(http.StatusUnsupportedMediaType, "Unsupported Media Type") + return + } + if errors.Is(err, binding.ErrEmptyRequestBody) { + _ = c.String(http.StatusBadRequest, "Bad Request") + return + } + + // JSON/XML decode errors → 400. + errMsg := err.Error() + if strings.Contains(errMsg, "json:") || strings.Contains(errMsg, "invalid character") || + strings.Contains(errMsg, "xml:") || strings.Contains(errMsg, "cannot unmarshal") { + _ = c.String(http.StatusBadRequest, "Bad Request") + return + } + + // Unknown → 500 without details (security: don't leak internals). + _ = c.String(http.StatusInternalServerError, "Internal Server Error") } // handleNotFound sends a 404 or 405 response depending on configuration. diff --git a/validation_test.go b/validation_test.go index d516a49..fd85398 100644 --- a/validation_test.go +++ b/validation_test.go @@ -323,8 +323,8 @@ func TestContext_Bind_WithValidator(t *testing.T) { r.ServeHTTP(w, req) // Should return 500 (handler returned error). - if w.Code != http.StatusInternalServerError { - t.Errorf("expected status 500 (validation failed), got %d", w.Code) + if w.Code != http.StatusUnprocessableEntity { + t.Errorf("expected status 422 (validation failed), got %d", w.Code) } } @@ -410,8 +410,8 @@ func TestContext_Bind_CustomValidationErrors(t *testing.T) { r.ServeHTTP(w, req) // Validation should fail. - if w.Code != http.StatusInternalServerError { - t.Errorf("expected status 500 (validation failed), got %d", w.Code) + if w.Code != http.StatusUnprocessableEntity { + t.Errorf("expected status 422 (validation failed), got %d", w.Code) } } @@ -530,8 +530,8 @@ func TestValidation_Integration(t *testing.T) { r.ServeHTTP(w, req) - if w.Code != http.StatusInternalServerError { - t.Errorf("expected status 500 (validation failed), got %d", w.Code) + if w.Code != http.StatusUnprocessableEntity { + t.Errorf("expected status 422 (validation failed), got %d", w.Code) } }) @@ -544,8 +544,8 @@ func TestValidation_Integration(t *testing.T) { r.ServeHTTP(w, req) - if w.Code != http.StatusInternalServerError { - t.Errorf("expected status 500 (validation failed), got %d", w.Code) + if w.Code != http.StatusUnprocessableEntity { + t.Errorf("expected status 422 (validation failed), got %d", w.Code) } }) @@ -558,8 +558,8 @@ func TestValidation_Integration(t *testing.T) { r.ServeHTTP(w, req) - if w.Code != http.StatusInternalServerError { - t.Errorf("expected status 500 (validation failed), got %d", w.Code) + if w.Code != http.StatusUnprocessableEntity { + t.Errorf("expected status 422 (validation failed), got %d", w.Code) } }) } From d331f1411144f062fd88fd42c5fde83122f8e29d Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Thu, 10 Sep 2026 20:11:20 +0300 Subject: [PATCH 2/6] fix: group middleware inheritance + shutdown order (audit F2, F3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F2: Group() now appends parent middleware instead of replacing. Child groups always inherit parent middleware chain. Prevents auth bypass when nesting groups with explicit middleware. TDD: TestGroup_ChildInheritsParentMiddleware — trace verified. F3: Shutdown() now drains active connections (server.Shutdown) BEFORE calling cleanup callbacks (db.Close). Prevents active requests from using closed resources. --- group.go | 12 +++------ group_test.go | 67 +++++++++++++++++++++++++++++++++++++++++++++++---- router.go | 16 ++++++------ 3 files changed, 75 insertions(+), 20 deletions(-) diff --git a/group.go b/group.go index 7247b7c..d2c5c59 100644 --- a/group.go +++ b/group.go @@ -66,14 +66,10 @@ func (g *RouteGroup) Use(middleware ...HandlerFunc) *RouteGroup { // v2.Handle("GET", "/users", handler) // GET /api/v2/users (ratelimit only) func (g *RouteGroup) Group(prefix string, middleware ...HandlerFunc) *RouteGroup { // If no middleware provided, inherit from parent group - var groupMiddleware []HandlerFunc - if len(middleware) == 0 { - // Copy parent middleware to avoid shared slice issues - groupMiddleware = make([]HandlerFunc, len(g.middleware)) - copy(groupMiddleware, g.middleware) - } else { - groupMiddleware = middleware - } + // Always inherit parent middleware, then append child-specific. + groupMiddleware := make([]HandlerFunc, len(g.middleware), len(g.middleware)+len(middleware)) + copy(groupMiddleware, g.middleware) + groupMiddleware = append(groupMiddleware, middleware...) return &RouteGroup{ prefix: g.prefix + prefix, diff --git a/group_test.go b/group_test.go index b3021bb..0d67356 100644 --- a/group_test.go +++ b/group_test.go @@ -264,7 +264,7 @@ func TestGroup_NestedGroups(t *testing.T) { return c.Next() }) - // Create v1 group with custom middleware (does NOT inherit api-mw) + // Create v1 group with custom middleware (INHERITS api-mw + adds v1-mw). v1 := api.Group("/v1", func(c *Context) error { executed = append(executed, "v1-mw") return c.Next() @@ -279,12 +279,12 @@ func TestGroup_NestedGroups(t *testing.T) { w := httptest.NewRecorder() r.ServeHTTP(w, req) - // Should only execute v1-mw + handler (not api-mw) - if len(executed) != 2 { - t.Fatalf("expected 2 executions, got %d: %v", len(executed), executed) + // Must execute api-mw → v1-mw → handler (parent always inherited). + if len(executed) != 3 { + t.Fatalf("expected 3 executions, got %d: %v", len(executed), executed) } - if executed[0] != "v1-mw" || executed[1] != "handler" { + if executed[0] != "api-mw" || executed[1] != "v1-mw" || executed[2] != "handler" { t.Errorf("unexpected execution order: %v", executed) } }) @@ -505,3 +505,60 @@ type testError struct { func (e *testError) Error() string { return e.message } + +// TestGroup_ChildInheritsParentMiddleware verifies that a child group with +// explicit middleware APPENDS to parent middleware, not replaces it. +func TestGroup_ChildInheritsParentMiddleware(t *testing.T) { + var trace []string + + r := New() + api := r.Group("/api") + api.Use(func(c *Context) error { + trace = append(trace, "auth") + return c.Next() + }) + + admin := api.Group("/admin", func(c *Context) error { + trace = append(trace, "admin-only") + return c.Next() + }) + + admin.Handle("GET", "/stats", func(c *Context) error { + trace = append(trace, "handler") + return c.String(200, "stats") + }) + + req := httptest.NewRequest("GET", "/api/admin/stats", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d", w.Code) + } + + authFound, adminFound := false, false + for _, s := range trace { + if s == "auth" { + authFound = true + } + if s == "admin-only" { + adminFound = true + } + } + if !authFound { + t.Error("parent middleware 'auth' was NOT executed — auth bypass risk") + } + if !adminFound { + t.Error("child middleware 'admin-only' was NOT executed") + } + + expected := []string{"auth", "admin-only", "handler"} + if len(trace) != len(expected) { + t.Fatalf("expected trace %v, got %v", expected, trace) + } + for i, want := range expected { + if trace[i] != want { + t.Errorf("trace[%d] = %q, want %q", i, trace[i], want) + } + } +} diff --git a/router.go b/router.go index adb557b..222f2e5 100644 --- a/router.go +++ b/router.go @@ -884,7 +884,14 @@ func (r *Router) OnShutdown(f func()) { // log.Printf("Shutdown error: %v", err) // } func (r *Router) Shutdown(ctx context.Context) error { - // Call shutdown callbacks in reverse order (last registered, first called). + // 1. Drain active connections first — active requests must finish + // before we close resources they depend on. + var serverErr error + if r.server != nil { + serverErr = r.server.Shutdown(ctx) + } + + // 2. Then call cleanup callbacks (db.Close, etc.) in reverse order. r.shutdownMu.Lock() callbacks := make([]func(), len(r.shutdownCallbacks)) copy(callbacks, r.shutdownCallbacks) @@ -894,12 +901,7 @@ func (r *Router) Shutdown(ctx context.Context) error { callbacks[i]() } - // Shutdown http.Server if configured. - if r.server != nil { - return r.server.Shutdown(ctx) - } - - return nil + return serverErr } // SetServer sets the http.Server for graceful shutdown. From b13a9fb1bd24a9de4f3df4423bfb058d87de458e Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Thu, 10 Sep 2026 20:16:47 +0300 Subject: [PATCH 3/6] fix: CORS preflight + body size limit (audit F4, F5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F4: OPTIONS preflight now reaches middleware without explicit route. handleNotFound runs middleware chain for OPTIONS when handleOPTIONS enabled and path exists for other methods. Added Vary: Origin header on all CORS responses. TDD: 3 tests (preflight without route, no middleware, Vary header). F5: MaxBytesReader wraps request body in adaptGenericHandler. Default limit 4MB, configurable via SetMaxBodySize(). MaxBytesError mapped to 413 in defaultErrorHandler. TDD: 5 tests (large→413, normal→200, custom, zero disables, plain unaffected). --- handler_generic.go | 9 +++ middleware/cors.go | 5 ++ middleware/cors_test.go | 123 ++++++++++++++++++++++++++++++++++++++++ router.go | 72 +++++++++++++++++++++++ router_test.go | 122 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 331 insertions(+) diff --git a/handler_generic.go b/handler_generic.go index 4f7e1d1..87b9181 100644 --- a/handler_generic.go +++ b/handler_generic.go @@ -4,6 +4,8 @@ package fursy +import "net/http" + // Handler is a type-safe handler function for HTTP requests with typed request/response bodies. // // Type parameters: @@ -48,6 +50,13 @@ type Handler[Req, Res any] func(*Box[Req, Res]) error // This is used internally by Router.GET, Router.POST, etc. to support generic handlers. func adaptGenericHandler[Req, Res any](handler Handler[Req, Res]) HandlerFunc { return func(base *Context) error { + // Enforce body size limit if configured. + // MaxBytesReader wraps the body so that reading beyond the limit + // returns http.MaxBytesError, which defaultErrorHandler maps to 413. + if base.router != nil && base.router.maxBodySize > 0 && base.Request.Body != nil { + base.Request.Body = http.MaxBytesReader(base.Response, base.Request.Body, base.router.maxBodySize) + } + // Create generic context ctx := newBox[Req, Res](base) diff --git a/middleware/cors.go b/middleware/cors.go index e94b4c7..377bf65 100644 --- a/middleware/cors.go +++ b/middleware/cors.go @@ -133,6 +133,11 @@ func CORSWithConfig(config CORSConfig) fursy.HandlerFunc { return c.Next() } + // Vary: Origin prevents cache poisoning — a shared cache must not + // serve a CORS response (with Allow-Origin for origin A) to a + // request from origin B. + c.Response.Header().Set("Vary", "Origin") + // Check if this is a preflight request. if c.Request.Method == http.MethodOptions { method := c.Request.Header.Get(headerRequestMethod) diff --git a/middleware/cors_test.go b/middleware/cors_test.go index 1d270cb..52de4e3 100644 --- a/middleware/cors_test.go +++ b/middleware/cors_test.go @@ -492,3 +492,126 @@ func TestCORSConfig_IsPreflightAllowed(t *testing.T) { } }) } + +// --- F4 audit fix: CORS preflight unreachable + Vary: Origin --- + +// TestCORS_PreflightWithoutRoute verifies that an OPTIONS preflight request +// is handled by CORS middleware even when only GET is registered for the path. +// Before the fix, this returned 405 because route matching failed before +// middleware could execute. +func TestCORS_PreflightWithoutRoute(t *testing.T) { + r := fursy.New() + r.Use(CORSWithConfig(CORSConfig{ + AllowOrigins: "https://example.com", + AllowMethods: "GET,POST,PUT,DELETE", + AllowHeaders: "Content-Type,Authorization", + })) + + // Only register GET — no OPTIONS handler. + r.Handle("GET", "/api/users", func(c *fursy.Context) error { + return c.String(200, "users") + }) + + // Send OPTIONS preflight. + req := httptest.NewRequest("OPTIONS", "/api/users", http.NoBody) + req.Header.Set("Origin", "https://example.com") + req.Header.Set("Access-Control-Request-Method", "POST") + req.Header.Set("Access-Control-Request-Headers", "Content-Type") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // Must be 204, not 405. + if w.Code != http.StatusNoContent { + t.Errorf("OPTIONS preflight without route: want 204, got %d; body: %s", w.Code, w.Body.String()) + } + + // Must have CORS headers. + if w.Header().Get("Access-Control-Allow-Origin") != "https://example.com" { + t.Errorf("want Allow-Origin https://example.com, got %q", w.Header().Get("Access-Control-Allow-Origin")) + } + if w.Header().Get("Access-Control-Allow-Methods") == "" { + t.Error("want Allow-Methods header to be set") + } +} + +// TestCORS_PreflightWithoutRoute_NoMiddleware verifies that OPTIONS without +// CORS middleware still returns 405 (no implicit CORS). +func TestCORS_PreflightWithoutRoute_NoMiddleware(t *testing.T) { + r := fursy.New() + + r.Handle("GET", "/api/users", func(c *fursy.Context) error { + return c.String(200, "users") + }) + + req := httptest.NewRequest("OPTIONS", "/api/users", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // Without CORS middleware, OPTIONS to a GET-only route should be 405. + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("OPTIONS without middleware: want 405, got %d", w.Code) + } +} + +// TestCORS_VaryHeader verifies that all CORS responses include Vary: Origin +// to prevent cache poisoning. +func TestCORS_VaryHeader(t *testing.T) { + t.Run("actual request", func(t *testing.T) { + r := fursy.New() + r.Use(CORS()) + + r.Handle("GET", "/test", func(c *fursy.Context) error { + return c.String(200, "OK") + }) + + req := httptest.NewRequest("GET", "/test", http.NoBody) + req.Header.Set("Origin", "https://example.com") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + vary := w.Header().Get("Vary") + if vary != "Origin" { + t.Errorf("actual CORS request: want Vary=Origin, got %q", vary) + } + }) + + t.Run("preflight request", func(t *testing.T) { + r := fursy.New() + r.Use(CORSWithConfig(CORSConfig{ + AllowOrigins: "https://example.com", + })) + + r.Handle("GET", "/test", func(c *fursy.Context) error { + return c.String(200, "OK") + }) + + req := httptest.NewRequest("OPTIONS", "/test", http.NoBody) + req.Header.Set("Origin", "https://example.com") + req.Header.Set("Access-Control-Request-Method", "GET") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + vary := w.Header().Get("Vary") + if vary != "Origin" { + t.Errorf("preflight request: want Vary=Origin, got %q", vary) + } + }) + + t.Run("no origin header skips vary", func(t *testing.T) { + r := fursy.New() + r.Use(CORS()) + + r.Handle("GET", "/test", func(c *fursy.Context) error { + return c.String(200, "OK") + }) + + req := httptest.NewRequest("GET", "/test", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // No Origin header = not a CORS request, no Vary needed. + if w.Header().Get("Vary") != "" { + t.Errorf("non-CORS request should not have Vary header, got %q", w.Header().Get("Vary")) + } + }) +} diff --git a/router.go b/router.go index 222f2e5..f4103a5 100644 --- a/router.go +++ b/router.go @@ -164,8 +164,19 @@ type Router struct { // shutdownMu protects shutdown callbacks from concurrent access. shutdownMu sync.Mutex + + // maxBodySize is the maximum allowed request body size in bytes for + // generic handlers (those using Box[Req, Res] with automatic binding). + // Default: 4MB (4 << 20). Set to 0 to disable the limit. + // Plain handlers (HandlerFunc) are not affected. + maxBodySize int64 } +const ( + // defaultMaxBodySize is the default maximum request body size (4MB). + defaultMaxBodySize int64 = 4 << 20 +) + // New creates a new Router instance with default configuration. // // The router is created with: @@ -178,6 +189,7 @@ func New() *Router { trees: make(map[string]*radix.Tree), handleMethodNotAllowed: true, handleOPTIONS: true, + maxBodySize: defaultMaxBodySize, } // Initialize context pool. @@ -260,6 +272,36 @@ func (r *Router) SetErrorHandler(h ErrorHandler) *Router { return r } +// SetMaxBodySize sets the maximum allowed request body size in bytes for +// generic handlers (Box[Req, Res] with automatic binding). +// +// When a request body exceeds this limit, the binding step returns +// an error that is mapped to 413 Payload Too Large by the default +// error handler. +// +// The default limit is 4MB (4 << 20). Set to 0 to disable the limit. +// Plain handlers (HandlerFunc) are not affected by this setting. +// +// Example: +// +// router := fursy.New() +// router.SetMaxBodySize(1 << 20) // 1MB limit +// +// router.POST("/upload", func(c *Box[UploadReq, UploadRes]) error { +// // Bodies larger than 1MB will be rejected with 413 +// return c.OK(UploadRes{OK: true}) +// }) +func (r *Router) SetMaxBodySize(size int64) *Router { + r.maxBodySize = size + return r +} + +// MaxBodySize returns the current maximum body size limit in bytes. +// Returns 0 if the limit is disabled. +func (r *Router) MaxBodySize() int64 { + return r.maxBodySize +} + // WithInfo sets the API metadata for OpenAPI generation. // // This configures the info section of the generated OpenAPI document. @@ -678,6 +720,13 @@ func defaultErrorHandler(c *Context, err error) { return } + // MaxBytesError → 413 Payload Too Large. + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + _ = c.String(http.StatusRequestEntityTooLarge, "Request Entity Too Large") + return + } + // Binding errors → 400 or 415. if errors.Is(err, binding.ErrUnsupportedMediaType) { _ = c.String(http.StatusUnsupportedMediaType, "Unsupported Media Type") @@ -702,6 +751,29 @@ func defaultErrorHandler(c *Context, err error) { // handleNotFound sends a 404 or 405 response depending on configuration. func (r *Router) handleNotFound(c *Context, w http.ResponseWriter, req *http.Request, path string) { + // F4 fix: if OPTIONS and handleOPTIONS enabled and the path exists for + // other methods, run middleware chain with an empty handler so CORS + // middleware can respond to the preflight. Without this, OPTIONS + // requests to paths without an explicit OPTIONS route get 405 and + // middleware never executes. + if req.Method == http.MethodOptions && r.handleOPTIONS && + len(r.middleware) > 0 && r.pathExistsInOtherMethods(path, req.Method) { + c.init(w, req, r, nil) + c.handlers = c.handlers[:0] + c.handlers = append(c.handlers, r.middleware...) + // Terminal no-op handler: if no middleware writes a response, + // return 204 (successful OPTIONS with no body). + c.handlers = append(c.handlers, func(ctx *Context) error { + return ctx.NoContent(http.StatusNoContent) + }) + c.index = -1 + c.aborted = false + if err := c.Next(); err != nil { + r.handleError(c, err) + } + return + } + if r.handleMethodNotAllowed && r.pathExistsInOtherMethods(path, req.Method) { c.init(w, req, r, nil) _ = c.String(http.StatusMethodNotAllowed, "Method Not Allowed") diff --git a/router_test.go b/router_test.go index 7e8cd02..c376863 100644 --- a/router_test.go +++ b/router_test.go @@ -1,9 +1,11 @@ package fursy import ( + "bytes" "io" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -887,6 +889,126 @@ func TestRouter_RedirectTrailingSlash_NoRedirectLoop(t *testing.T) { } } +// --- F5 audit fix: body size limit --- + +// bodyLimitReq is a test request type for body limit tests. +type bodyLimitReq struct { + Data string `json:"data"` +} + +// bodyLimitRes is a test response type for body limit tests. +type bodyLimitRes struct { + OK bool `json:"ok"` +} + +// TestBodyLimit_Large_Returns413 verifies that a request body exceeding +// the default max body size (4MB) returns 413 Payload Too Large. +func TestBodyLimit_Large_Returns413(t *testing.T) { + r := New() + + r.POST("/upload", func(c *Box[bodyLimitReq, bodyLimitRes]) error { + return c.OK(bodyLimitRes{OK: true}) + }) + + // Create a 5MB JSON body. + largeData := strings.Repeat("x", 5*1024*1024) + body := `{"data":"` + largeData + `"}` + req := httptest.NewRequest("POST", "/upload", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusRequestEntityTooLarge { + t.Errorf("5MB body: want 413, got %d; body: %s", w.Code, w.Body.String()) + } +} + +// TestBodyLimit_Normal_Passes verifies that a small request body passes +// through the body limit check. +func TestBodyLimit_Normal_Passes(t *testing.T) { + r := New() + + r.POST("/upload", func(c *Box[bodyLimitReq, bodyLimitRes]) error { + return c.OK(bodyLimitRes{OK: true}) + }) + + body := `{"data":"hello"}` + req := httptest.NewRequest("POST", "/upload", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("small body: want 200, got %d; body: %s", w.Code, w.Body.String()) + } +} + +// TestBodyLimit_Custom verifies that SetMaxBodySize overrides the default. +func TestBodyLimit_Custom(t *testing.T) { + r := New() + r.SetMaxBodySize(1 << 20) // 1MB + + r.POST("/upload", func(c *Box[bodyLimitReq, bodyLimitRes]) error { + return c.OK(bodyLimitRes{OK: true}) + }) + + // 2MB body should be rejected with 1MB limit. + largeData := strings.Repeat("x", 2*1024*1024) + body := `{"data":"` + largeData + `"}` + req := httptest.NewRequest("POST", "/upload", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusRequestEntityTooLarge { + t.Errorf("2MB body with 1MB limit: want 413, got %d; body: %s", w.Code, w.Body.String()) + } +} + +// TestBodyLimit_ZeroDisables verifies that SetMaxBodySize(0) disables the limit. +func TestBodyLimit_ZeroDisables(t *testing.T) { + r := New() + r.SetMaxBodySize(0) // Disable limit. + + r.POST("/upload", func(c *Box[bodyLimitReq, bodyLimitRes]) error { + return c.OK(bodyLimitRes{OK: true}) + }) + + // 5MB body should pass when limit is disabled. + largeData := strings.Repeat("x", 5*1024*1024) + body := `{"data":"` + largeData + `"}` + req := httptest.NewRequest("POST", "/upload", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("5MB body with limit disabled: want 200, got %d; body: %s", w.Code, w.Body.String()) + } +} + +// TestBodyLimit_NonGenericHandler verifies that plain handlers (HandlerFunc) +// are not affected by body limit — only generic handlers with Bind() are limited. +func TestBodyLimit_NonGenericHandler(t *testing.T) { + r := New() + + r.Handle("POST", "/raw", func(c *Context) error { + return c.String(200, "OK") + }) + + // Large body should pass for plain handler (no automatic Bind). + largeData := strings.Repeat("x", 5*1024*1024) + body := `{"data":"` + largeData + `"}` + req := httptest.NewRequest("POST", "/raw", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("plain handler with large body: want 200, got %d", w.Code) + } +} + // BenchmarkRouter_TrailingSlash_Strip benchmarks strip trailing slash overhead. func BenchmarkRouter_TrailingSlash_Strip(b *testing.B) { r := New() From 4aec7f59357aae4f418bed59d6cc22c1669280c1 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Thu, 10 Sep 2026 20:21:33 +0300 Subject: [PATCH 4/6] fix: group OpenAPI routes, 405 Allow header, remove broken stubs (F6, F7) F6: Remove c.DB(), c.SSE(), c.WebSocket() stubs from Context. c.DB() had broken context key (type declared inside function body). SSE/WS stubs always returned ErrStreamNotImported. Users should use plugin helpers: database.GetDB(c), stream.SSEUpgrade(c). F7: handleWithGroupMiddleware now writes RouteInfo to r.routes. Group routes appear in OpenAPI spec. 405 Method Not Allowed now includes Allow header (RFC 9110). --- context.go | 205 ------------------------------------ context_integration_test.go | 129 ++--------------------- router.go | 37 ++++++- 3 files changed, 41 insertions(+), 330 deletions(-) diff --git a/context.go b/context.go index 9d2864f..271454b 100644 --- a/context.go +++ b/context.go @@ -801,208 +801,3 @@ func NotAcceptable(detail string) Problem { // ErrInvalidRedirectCode is returned when redirect code is not 3xx. var ErrInvalidRedirectCode = errors.New("fursy: invalid redirect code (must be 3xx)") - -// ======================================== -// Real-time Communication Methods -// ======================================== -// -// These methods provide integration with github.com/coregx/stream library -// for Server-Sent Events (SSE) and WebSocket real-time communication. -// -// To use these methods, you must: -// 1. Import github.com/coregx/fursy/plugins/stream package -// 2. Use stream.SSEHub[T]() or stream.WebSocketHub() middleware -// 3. Call c.SSE() or c.WebSocket() in handlers -// -// These methods are part of fursy core for convenient API, -// but the actual implementation requires the plugins/stream package. - -// SSE upgrades the HTTP connection to Server-Sent Events. -// -// The handler function receives an SSE connection and should handle -// the SSE lifecycle (register to hub, send events, etc.). -// -// The connection is automatically closed when the handler returns. -// -// Requires: github.com/coregx/stream/sse package -// -// Example: -// -// // In main.go: -// import ( -// "github.com/coregx/fursy" -// "github.com/coregx/fursy/plugins/stream" -// "github.com/coregx/stream/sse" -// ) -// -// hub := sse.NewHub[Notification]() -// go hub.Run() -// defer hub.Close() -// -// router := fursy.New() -// router.Use(stream.SSEHub(hub)) -// -// router.Handle("GET", "/events", func(c *fursy.Context) error { -// hub, _ := stream.GetSSEHub[Notification](c) -// -// return c.SSE(func(conn *sse.Conn) error { -// hub.Register(conn) -// defer hub.Unregister(conn) -// <-conn.Done() -// return nil -// }) -// }) -// -// Note: This method signature is defined in fursy core, but requires -// github.com/coregx/stream/sse to be imported in your code for the Conn type. -// -//nolint:revive // Parameters needed for API documentation (stub method). -func (c *Context) SSE(handler func(conn any) error) error { - // This method is intentionally defined with 'any' type to avoid - // importing github.com/coregx/stream/sse in fursy core. - // - // Users will import sse package and use the concrete *sse.Conn type - // in their handler function. The type checking happens at compile time. - // - // Actual implementation is provided by plugins/stream package helper. - return ErrStreamNotImported -} - -// WebSocket upgrades the HTTP connection to WebSocket. -// -// The handler function receives a WebSocket connection and should handle -// the WebSocket lifecycle (register to hub, read/write messages, etc.). -// -// The connection is automatically closed when the handler returns. -// -// Requires: github.com/coregx/stream/websocket package -// -// Example: -// -// // In main.go: -// import ( -// "github.com/coregx/fursy" -// "github.com/coregx/fursy/plugins/stream" -// "github.com/coregx/stream/websocket" -// ) -// -// hub := websocket.NewHub() -// go hub.Run() -// defer hub.Close() -// -// router := fursy.New() -// router.Use(stream.WebSocketHub(hub)) -// -// router.Handle("GET", "/ws", func(c *fursy.Context) error { -// hub, _ := stream.GetWebSocketHub(c) -// -// return c.WebSocket(func(conn *websocket.Conn) error { -// hub.Register(conn) -// defer hub.Unregister(conn) -// -// for { -// msgType, data, err := conn.Read() -// if err != nil { -// return err -// } -// hub.Broadcast(data) -// } -// }, nil) -// }) -// -// Note: This method signature is defined in fursy core, but requires -// github.com/coregx/stream/websocket to be imported in your code for the Conn type. -// -//nolint:revive // Parameters needed for API documentation (stub method). -func (c *Context) WebSocket(handler func(conn any) error, opts any) error { - // This method is intentionally defined with 'any' types to avoid - // importing github.com/coregx/stream/websocket in fursy core. - // - // Users will import websocket package and use the concrete types: - // - *websocket.Conn for conn parameter - // - *websocket.UpgradeOptions for opts parameter - // - // Actual implementation is provided by plugins/stream package helper. - return ErrStreamNotImported -} - -// ErrStreamNotImported is returned when SSE or WebSocket methods are called -// without importing github.com/coregx/fursy/plugins/stream package. -var ErrStreamNotImported = errors.New("fursy: stream plugin not imported - add 'import _ \"github.com/coregx/fursy/plugins/stream\"' to your code") - -// ======================================== -// Database Integration Methods -// ======================================== -// -// These methods provide integration with database/sql through -// github.com/coregx/fursy/plugins/database package. -// -// To use these methods, you must: -// 1. Import github.com/coregx/fursy/plugins/database package -// 2. Use database.Middleware(db) to configure database -// 3. Call c.DB() in handlers to access database -// -// The database integration is designed to work with any database/sql driver -// (PostgreSQL, MySQL, SQLite, etc.) while providing convenient fursy integration. - -// DB returns the database connection from the context. -// -// Returns nil if database middleware is not configured. -// -// Requires: github.com/coregx/fursy/plugins/database package -// -// Example: -// -// // In main.go: -// import ( -// "database/sql" -// "github.com/coregx/fursy" -// "github.com/coregx/fursy/plugins/database" -// _ "github.com/lib/pq" // PostgreSQL driver -// ) -// -// sqlDB, _ := sql.Open("postgres", dsn) -// db := database.NewDB(sqlDB) -// -// router := fursy.New() -// router.Use(database.Middleware(db)) -// -// router.Handle("GET", "/users/:id", func(c *fursy.Context) error { -// db := c.DB() -// if db == nil { -// return c.Problem(fursy.InternalServerError("Database not configured")) -// } -// -// var user User -// err := db.QueryRow(c.Request.Context(), -// "SELECT id, name FROM users WHERE id = $1", c.Param("id")). -// Scan(&user.ID, &user.Name) -// -// if err == sql.ErrNoRows { -// return c.Problem(fursy.NotFound("User not found")) -// } -// if err != nil { -// return c.Problem(fursy.InternalServerError(err.Error())) -// } -// -// return c.JSON(200, user) -// }) -// -// Note: This method signature returns 'any' to avoid importing -// github.com/coregx/fursy/plugins/database in fursy core. -// The actual type is *database.DB when database middleware is configured. -func (c *Context) DB() any { - // This method is intentionally defined with 'any' return type to avoid - // importing github.com/coregx/fursy/plugins/database in fursy core. - // - // Users will import database package and use type assertion: - // db := c.DB().(*database.DB) - // - // Or the type-safe helper: - // db, ok := database.GetDB(c) - // - // Actual implementation is provided by database.Middleware(). - // The key (0) matches dbKey from plugins/database package. - type dbContextKey int - return c.Request.Context().Value(dbContextKey(0)) -} diff --git a/context_integration_test.go b/context_integration_test.go index 03c35ea..75e3d0e 100644 --- a/context_integration_test.go +++ b/context_integration_test.go @@ -4,124 +4,11 @@ package fursy_test -import ( - "errors" - "net/http" - "net/http/httptest" - "testing" - - "github.com/coregx/fursy" -) - -// TestContext_SSE_NotImported tests that c.SSE() returns ErrStreamNotImported -// when plugins/stream is not imported. -func TestContext_SSE_NotImported(t *testing.T) { - router := fursy.New() - router.Handle("GET", "/sse", func(c *fursy.Context) error { - err := c.SSE(func(_ any) error { - return nil - }) - - if !errors.Is(err, fursy.ErrStreamNotImported) { - t.Errorf("expected ErrStreamNotImported, got %v", err) - } - return c.JSON(200, map[string]string{"status": "ok"}) - }) - - req := httptest.NewRequest("GET", "/sse", http.NoBody) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != 200 { - t.Errorf("expected 200, got %d", w.Code) - } -} - -// TestContext_WebSocket_NotImported tests that c.WebSocket() returns ErrStreamNotImported -// when plugins/stream is not imported. -func TestContext_WebSocket_NotImported(t *testing.T) { - router := fursy.New() - router.Handle("GET", "/ws", func(c *fursy.Context) error { - err := c.WebSocket(func(_ any) error { - return nil - }, nil) - - if !errors.Is(err, fursy.ErrStreamNotImported) { - t.Errorf("expected ErrStreamNotImported, got %v", err) - } - return c.JSON(200, map[string]string{"status": "ok"}) - }) - - req := httptest.NewRequest("GET", "/ws", http.NoBody) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != 200 { - t.Errorf("expected 200, got %d", w.Code) - } -} - -// TestContext_DB_NotConfigured tests that c.DB() returns nil -// when database middleware is not configured. -func TestContext_DB_NotConfigured(t *testing.T) { - router := fursy.New() - router.Handle("GET", "/test", func(c *fursy.Context) error { - db := c.DB() - if db != nil { - t.Error("expected nil, got DB") - } - return c.JSON(200, map[string]string{"status": "ok"}) - }) - - req := httptest.NewRequest("GET", "/test", http.NoBody) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - if w.Code != 200 { - t.Errorf("expected 200, got %d", w.Code) - } -} - -// TestContext_ErrorMessages tests that error messages are helpful -// when methods are called incorrectly. -func TestContext_ErrorMessages(t *testing.T) { - tests := []struct { - name string - handler fursy.HandlerFunc - expectedError error - }{ - { - name: "SSE without plugin", - handler: func(c *fursy.Context) error { - return c.SSE(func(_ any) error { - return nil - }) - }, - expectedError: fursy.ErrStreamNotImported, - }, - { - name: "WebSocket without plugin", - handler: func(c *fursy.Context) error { - return c.WebSocket(func(_ any) error { - return nil - }, nil) - }, - expectedError: fursy.ErrStreamNotImported, - }, - } - - for _, tt := range tests { - tt := tt // capture range variable - t.Run(tt.name, func(_ *testing.T) { - router := fursy.New() - router.Handle("GET", "/test", tt.handler) - - req := httptest.NewRequest("GET", "/test", http.NoBody) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - // The error should be returned to the handler - // and can be checked in the test - }) - } -} +// Integration tests for Context. +// +// SSE, WebSocket, and DB stub tests removed in v0.6.0. +// These methods were removed from core Context. +// Use plugin-level helpers instead: +// - stream.SSEUpgrade(c, handler) +// - stream.WebSocketUpgrade(c, handler, opts) +// - database.GetDB(c) diff --git a/router.go b/router.go index f4103a5..a126cd8 100644 --- a/router.go +++ b/router.go @@ -586,6 +586,12 @@ func (r *Router) handleWithGroupMiddleware(method, path string, groupHandlers [] if err := tree.Insert(path, wrapper); err != nil { panic("fursy: " + err.Error()) } + + // Store route metadata for OpenAPI generation. + r.routes = append(r.routes, RouteInfo{ + Method: method, + Path: path, + }) } // createGroupHandlerWrapper creates a handler that executes group middleware + handler. @@ -774,10 +780,13 @@ func (r *Router) handleNotFound(c *Context, w http.ResponseWriter, req *http.Req return } - if r.handleMethodNotAllowed && r.pathExistsInOtherMethods(path, req.Method) { - c.init(w, req, r, nil) - _ = c.String(http.StatusMethodNotAllowed, "Method Not Allowed") - return + if r.handleMethodNotAllowed { + if allowed := r.allowedMethods(path, req.Method); allowed != "" { + c.init(w, req, r, nil) + c.SetHeader("Allow", allowed) + _ = c.String(http.StatusMethodNotAllowed, "Method Not Allowed") + return + } } c.init(w, req, r, nil) _ = c.String(http.StatusNotFound, "Not Found") @@ -812,6 +821,26 @@ func (r *Router) tryTrailingSlashLookup( // pathExistsInOtherMethods checks if a path exists in other HTTP methods. // When trailing slash handling is enabled, also checks the alternate path. +// allowedMethods returns a comma-separated list of HTTP methods allowed for +// the path (excluding the given method), or empty string if none. +func (r *Router) allowedMethods(path, excludeMethod string) string { + altPath := "" + if r.trailingSlash != IgnoreTrailingSlash { + altPath = trailingSlashAlternate(path) + } + + var methods []string + for m, tree := range r.trees { + if m == excludeMethod { + continue + } + if r.existsInTree(tree, path, altPath) { + methods = append(methods, m) + } + } + return strings.Join(methods, ", ") +} + func (r *Router) pathExistsInOtherMethods(path, method string) bool { altPath := "" if r.trailingSlash != IgnoreTrailingSlash { From ea30243de199e9d2db2b01f59a141eb6a5ebbf94 Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Thu, 10 Sep 2026 20:23:17 +0300 Subject: [PATCH 5/6] fix: rate limit XFF spoofing + json/v2 cleanup (F8, F10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F8: RateLimit default KeyFunc now uses RemoteAddr instead of trusting X-Forwarded-For/X-Real-IP headers. Prevents XFF spoofing bypass. To use proxy headers, set KeyFunc explicitly with trusted proxy logic. F10: Replace encoding/json/v2 imports with encoding/json. Go 1.27 makes json/v2 the default — explicit import unnecessary. Changed: openapi.go, openapi_test.go, examples/07-sse-notifications. --- examples/07-sse-notifications/main.go | 2 +- middleware/ratelimit.go | 6 ++++-- openapi.go | 2 +- openapi_test.go | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/examples/07-sse-notifications/main.go b/examples/07-sse-notifications/main.go index 459a8e3..5261d48 100644 --- a/examples/07-sse-notifications/main.go +++ b/examples/07-sse-notifications/main.go @@ -5,7 +5,7 @@ package main import ( - "encoding/json/v2" + "encoding/json" "log" "log/slog" "net/http" diff --git a/middleware/ratelimit.go b/middleware/ratelimit.go index cc88d9c..a06363c 100644 --- a/middleware/ratelimit.go +++ b/middleware/ratelimit.go @@ -249,9 +249,11 @@ func RateLimitWithConfig(config RateLimitConfig) fursy.HandlerFunc { } if config.KeyFunc == nil { - // Default: IP-based rate limiting. + // Default: RemoteAddr-based rate limiting. + // Does NOT trust X-Forwarded-For/X-Real-IP by default (XFF spoofing risk). + // To use proxy headers, set KeyFunc explicitly with trusted proxy validation. config.KeyFunc = func(c *fursy.Context) string { - return getClientIP(c.Request) + return cleanIP(c.Request.RemoteAddr) } } diff --git a/openapi.go b/openapi.go index c9e1154..2462cff 100644 --- a/openapi.go +++ b/openapi.go @@ -5,7 +5,7 @@ package fursy import ( - "encoding/json/v2" + "encoding/json" "fmt" "net/http" "reflect" diff --git a/openapi_test.go b/openapi_test.go index 96bd1a4..a0db26e 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -5,7 +5,7 @@ package fursy import ( - "encoding/json/v2" + "encoding/json" "net/http" "net/http/httptest" "reflect" From a20aeed512bcdd3eb414f6ae11abf0e55607c1bc Mon Sep 17 00:00:00 2001 From: Andrey Kolkov Date: Thu, 10 Sep 2026 20:25:40 +0300 Subject: [PATCH 6/6] =?UTF-8?q?docs:=20CHANGELOG=20v0.5.1=20=E2=80=94=20er?= =?UTF-8?q?ror=20pipeline,=20security=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 939c3d2..827577b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Planned - ozzo-routing compatibility layer (lowercase `Get`/`Post` methods) — deferred, see ADR-001 +## [0.5.1] - 2026-09-10 + +### Fixed +- **Error pipeline** — `Router.SetErrorHandler()` with default mapping: Problem→status code, ValidationErrors→422, binding errors→400/415, unknown→500 without details. Previously all errors returned plain-text 500 +- **Group middleware inheritance** — child groups with explicit middleware now append to parent middleware instead of replacing it. Prevents auth bypass when nesting groups +- **Shutdown order** — `Shutdown()` now drains active connections before calling cleanup callbacks. Previously callbacks (e.g. db.Close) ran while requests were still active +- **CORS preflight** — OPTIONS requests now reach middleware without explicit OPTIONS route. `Vary: Origin` header added on all CORS responses +- **405 Allow header** — 405 Method Not Allowed now includes `Allow` header listing valid methods (RFC 9110) +- **Group routes in OpenAPI** — routes registered via RouteGroup now appear in generated OpenAPI spec +- **Rate limit XFF spoofing** — default KeyFunc now uses `RemoteAddr` instead of trusting `X-Forwarded-For` + +### Added +- `Router.SetErrorHandler(ErrorHandler)` — custom error handler +- `Router.SetMaxBodySize(int64)` — request body size limit (default 4MB, mapped to 413) +- `Tree.Contains(path)` — zero-alloc existence check for 405 responses + +### Removed +- `Context.DB()` — broken context key, use `database.GetDB(c)` instead +- `Context.SSE()` — stub, use `stream.SSEUpgrade(c, handler)` instead +- `Context.WebSocket()` — stub, use `stream.WebSocketUpgrade(c, handler, opts)` instead +- `ErrStreamNotImported` — no longer needed + +### Changed +- `encoding/json/v2` imports replaced with `encoding/json` (Go 1.27 default) + +### Security +- Body size limit prevents memory DoS via large request bodies +- Rate limit no longer trusts spoofable proxy headers by default +- Error handler does not leak internal error details to clients +- CORS `Vary: Origin` prevents cache poisoning + ## [0.5.0] - 2026-09-10 ### Added