Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,23 @@
# that modifies code they own.

# Default owner for everything in the repo
# TODO: Update with actual maintainer GitHub username(s)
* @kolkov

# Core routing engine - Critical performance and correctness
/internal/radix/ @kolkov
/internal/pool/ @kolkov
/internal/binding/ @kolkov
/internal/negotiate/ @kolkov

# Public API files - Breaking changes require careful review
/router.go @kolkov
/context.go @kolkov
/handler.go @kolkov
/context_base.go @kolkov
/context_generic.go @kolkov
/handler_generic.go @kolkov
/group.go @kolkov
/error.go @kolkov
/problem.go @kolkov
/openapi.go @kolkov

# Built-in middleware - Standard middleware implementations
/middleware/ @kolkov
Expand All @@ -38,7 +42,6 @@

# Code quality and linting configuration
/.golangci.yml @kolkov
/Makefile @kolkov

# Tests - Ensure coverage and reliability
*_test.go @kolkov
Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- ozzo-routing compatibility layer (lowercase `Get`/`Post` methods) — deferred, see ADR-001
- Radix tree edge cases (root path + param routes) — tracked by differential fuzz

## [0.6.0] - 2026-09-11

### Security
- **CORS preflight echo bypass** — preflight responses now return only filtered allowed headers, not raw `Access-Control-Request-Headers` value
- **Example JWT alg confusion** — production boilerplate validates `*jwt.SigningMethodHMAC` before returning key
- **PanicHandler re-panics `http.ErrAbortHandler`** — matches Recovery() behavior, Go net/http expects propagation

### Added
- **`NewRateLimiter()`** — returns `*RateLimiter` with exported `Handler()` and `Stop()` methods for lifecycle management
- **`RateLimitConfig.NoHeaders`** — opt-out from `X-RateLimit-*` response headers (previously impossible to disable)

### Changed
- **RFC 9457 Problem Details is now the default error response** for all auto-generated errors:
- Router: 404, 405, 413, 415, 400 (binding/decode), 500
- Middleware: JWT 401, BasicAuth 401, RateLimit 429, CircuitBreaker 503, Recovery 500
- Content-Type changed from `text/plain` to `application/problem+json`
- **Form binding type errors** (e.g., `age=abc`) now return 400 Bad Request (was 500 Internal Server Error)
- **`Problem.WithExtension`/`WithExtensions`** — deep-copy map to prevent aliasing between chained calls
- **Shutdown godoc** — fixed order description (drain connections first, then callbacks)

### Fixed
- **CODEOWNERS** — removed references to non-existent `/internal/pool/`, `/handler.go`, `/Makefile`
- **SECURITY.md** — removed non-existent APIs (CSRF, Timeout, BodyLimit, HTTPSRedirect), fixed RateLimit signature, updated supported versions to 0.5.x
- **llms.md** — corrected `encoding/json/v2` → `encoding/json`, fixed stale API examples
- **Plugin READMEs** — replaced `router.Run()` with `http.ListenAndServe()`
- **README.md** — updated performance numbers (256 ns → 53 ns, 0 alloc)

### Dependencies
- plugins/opentelemetry: OTel v1.38.0 → v1.46.0
- plugins/validator: go-playground/validator v10.24.0 → v10.30.4
- plugins/database: modernc.org/sqlite v1.40.1 → v1.58.0
- plugins: fursy v0.5.3 → v0.5.4, stream v0.1.4 → v0.1.5

## [0.5.4] - 2026-09-10

### Changed
Expand Down
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,8 @@ spec := r.OpenAPI(fursy.OpenAPIConfig{

### Production-Ready Performance

- **256 ns/op** static routes, **326 ns/op** parametric routes
- **1 allocation/op** (routing hot path)
- **~10M req/s** throughput (simple routes)
- **~53 ns/op** static routes, **~75 ns/op** parametric routes
- **0 allocations/op** (zero-allocation routing)
- Zero-allocation radix tree routing
- Efficient context pooling

Expand All @@ -141,7 +140,7 @@ go get github.com/coregx/fursy

## 🚀 Features

- ✅ **High Performance Routing** - 256-326 ns/op, 1 alloc/op
- ✅ **High Performance Routing** - ~53 ns/op static, ~75 ns/op parametric, 0 alloc/op
- ✅ **Type-Safe Generic Methods** - `router.POST()` with Box[Req, Res] and Go 1.27 type inference
- ✅ **Automatic Validation** - Set once, validate everywhere with 100+ tags
- ✅ **Content Negotiation** - RFC 9110 compliant, AI agent support
Expand Down Expand Up @@ -1171,7 +1170,7 @@ Your fursy application will automatically send traces to Jaeger. No configuratio

**Coverage**: 94.6% test coverage (total), 94.6% core, 97.7% binding, 95.6% middleware

**Performance**: 256 ns/op (static), 326 ns/op (parametric), 1 alloc/op
**Performance**: ~53 ns/op (static), ~75 ns/op (parametric), 0 alloc/op

**Roadmap**:

Expand Down
100 changes: 51 additions & 49 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ FURSY HTTP Router is currently in active development (0.x versions). We provide

| Version | Supported |
| ------- | ------------------ |
| 0.2.x | :white_check_mark: |
| 0.1.x | :white_check_mark: |
| < 0.1.0 | :x: |
| 0.5.x | :white_check_mark: |
| < 0.5.0 | :x: |

Future stable releases (v1.0+) will follow semantic versioning with LTS support.

Expand Down Expand Up @@ -105,23 +104,22 @@ router.GET("/files/:path", func(c *fursy.Context) error {

**Mitigation**:
- ✅ Efficient radix tree routing (O(log n) lookups)
- ✅ Zero-allocation routing (1 alloc/op)
- ✅ Zero-allocation routing (0 alloc/op)
- ✅ Context pooling (prevents memory leaks)
- ✅ Graceful shutdown (prevents resource leaks)
- ✅ Circuit breaker middleware (prevents cascade failures)
- ✅ Rate limiting middleware (prevents abuse)

**User Recommendations**:
```go
// ✅ Use built-in middleware for protection
router.Use(fursy.RateLimit(100, time.Minute)) // 100 req/min
router.Use(fursy.CircuitBreaker(0.5, 100)) // 50% error rate threshold
import "github.com/coregx/fursy/middleware"

// ✅ Set reasonable timeouts
router.Use(fursy.Timeout(30 * time.Second))
// ✅ Use built-in middleware for protection
router.Use(middleware.RateLimit(100, 200)) // 100 req/s, burst 200
router.Use(middleware.CircuitBreaker()) // Default: 5 consecutive failures, 60s timeout

// ✅ Limit request body size
router.Use(fursy.BodyLimit(10 * 1024 * 1024)) // 10MB max
router.SetMaxBodySize(10 * 1024 * 1024) // 10MB max
```

### 3. Injection Attacks
Expand All @@ -137,7 +135,7 @@ router.Use(fursy.BodyLimit(10 * 1024 * 1024)) // 10MB max

**Mitigation**:
- ✅ Parameter extraction is safe (no SQL/command execution)
- ✅ JSON parsing uses `encoding/json/v2` (safe unmarshaling)
- ✅ JSON parsing uses `encoding/json` (safe unmarshaling)
- ✅ Header handling through stdlib (validated)
- 🔄 **User Responsibility**: Sanitize data before database/external use

Expand Down Expand Up @@ -176,22 +174,22 @@ router.POST("/search", func(c *fursy.Context) error {

**User Best Practices**:
```go
import "github.com/coregx/fursy/middleware"

// ✅ Use JWT middleware for authentication
jwtMiddleware := fursy.JWT(fursy.JWTConfig{
Secret: os.Getenv("JWT_SECRET"),
Expiration: 24 * time.Hour,
})
jwtMiddleware := middleware.JWT([]byte(os.Getenv("JWT_SECRET")))

// Protected routes
protected := router.Group("/api", jwtMiddleware)
protected.GET("/users", getUsersHandler)
protected.POST("/users", createUserHandler)
protected := router.Group("/api")
protected.Use(jwtMiddleware)
protected.Handle("GET", "/users", getUsersHandler)
protected.Handle("POST", "/users", createUserHandler)

// ✅ Implement authorization in handlers
func getUsersHandler(c *fursy.Context) error {
user := c.Get("user").(User)
if !user.IsAdmin() {
return c.Error(403, fursy.Forbidden("Admin required"))
return c.Problem(fursy.Forbidden("Admin required"))
}
// ...
}
Expand All @@ -207,19 +205,20 @@ func getUsersHandler(c *fursy.Context) error {
- DOM-based XSS (client-side rendering)

**Mitigation**:
- ✅ JSON responses automatically escaped (`encoding/json/v2`)
- ✅ JSON responses automatically escaped (`encoding/json`)
- ✅ Content-Type headers set correctly
- ✅ Security headers middleware (CSP, X-XSS-Protection)
- 🔄 **User Responsibility**: Sanitize HTML/JS output

**User Best Practices**:
```go
import "github.com/coregx/fursy/middleware"

// ✅ Use security headers middleware
router.Use(fursy.SecurityHeaders(fursy.SecurityConfig{
router.Use(middleware.SecureWithConfig(middleware.SecureConfig{
ContentSecurityPolicy: "default-src 'self'",
XFrameOptions: "DENY",
XContentTypeOptions: "nosniff",
XSSProtection: "1; mode=block",
ContentTypeNosniff: "nosniff",
}))

// ✅ Return JSON (auto-escaped)
Expand All @@ -241,24 +240,25 @@ router.GET("/profile", func(c *fursy.Context) error {
**Risk**: Forged requests from malicious sites.

**Mitigation**:
- ✅ CSRF token middleware available
- ✅ SameSite cookie support
- ✅ Origin header validation
- 🔄 **User Responsibility**: Enable CSRF protection
- ✅ Origin header validation via CORS middleware
- 🔄 **User Responsibility**: Implement CSRF protection

**Note**: CSRF middleware is not yet implemented -- planned for a future release. In the meantime, use SameSite cookies and CORS origin validation to mitigate CSRF risks.

**User Best Practices**:
```go
// ✅ Enable CSRF protection for state-changing operations
csrfMiddleware := fursy.CSRF(fursy.CSRFConfig{
TokenLength: 32,
CookieName: "_csrf",
HeaderName: "X-CSRF-Token",
})
import "github.com/coregx/fursy/middleware"

router.Use(csrfMiddleware)
// ✅ Use CORS with strict origin validation
router.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: "https://example.com",
AllowMethods: "GET,POST,PUT,DELETE",
AllowCredentials: true,
}))

// Safe methods (GET, HEAD, OPTIONS) are exempt
// POST, PUT, DELETE require CSRF token
// ✅ Use SameSite cookies for session management
// Set SameSite=Strict or SameSite=Lax on session cookies
```

## Security Best Practices for Users
Expand Down Expand Up @@ -295,14 +295,15 @@ router.POST("/users", func(c *fursy.Context) error {
Protect against abuse with rate limiting:

```go
// Global rate limit
router.Use(fursy.RateLimit(1000, time.Hour))

// Per-route rate limits
router.POST("/login",
fursy.RateLimit(5, time.Minute), // 5 attempts per minute
loginHandler,
)
import "github.com/coregx/fursy/middleware"

// Global rate limit (rate per second, burst)
router.Use(middleware.RateLimit(100, 200))

// Per-group rate limits
loginGroup := router.Group("/login")
loginGroup.Use(middleware.RateLimit(5, 10)) // 5 req/s, burst 10
loginGroup.Handle("POST", "", loginHandler)
```

### Error Handling
Expand Down Expand Up @@ -333,17 +334,18 @@ router.GET("/users/:id", func(c *fursy.Context) error {
Always use HTTPS in production:

```go
// ✅ Redirect HTTP to HTTPS
router.Use(fursy.HTTPSRedirect())
import "github.com/coregx/fursy/middleware"

// ✅ Set secure headers
router.Use(fursy.SecurityHeaders(fursy.SecurityConfig{
// ✅ Set HSTS headers to enforce HTTPS
router.Use(middleware.SecureWithConfig(middleware.SecureConfig{
HSTSMaxAge: 31536000, // 1 year
HSTSIncludeSubdomains: true,
HSTSPreload: true,
HSTSExcludeSubdomains: false,
HSTSPreloadEnabled: true,
}))
```

**Note**: HTTPS redirect middleware is not yet implemented. Use a reverse proxy (nginx, Caddy) or cloud load balancer for HTTP-to-HTTPS redirection.

## Known Security Considerations

### 1. Route Parameter Injection
Expand Down
6 changes: 3 additions & 3 deletions context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -783,9 +783,9 @@ func TestRouter_ContextErrorHandling(t *testing.T) {
t.Errorf("Status code = %d, want 500", w.Code)
}

body := w.Body.String()
if body != "Internal Server Error" {
t.Errorf("Body = %q, want %q", body, "Internal Server Error")
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, "application/problem+json") {
t.Errorf("Content-Type = %q, want application/problem+json", ct)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ func (s *JWTService) GenerateToken(userID, role string) (string, error) {
// ValidateToken validates JWT token and returns claims.
func (s *JWTService) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return s.secret, nil
})

Expand Down
4 changes: 2 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ go run main.go
**1. Basic Router Setup**
```go
router := fursy.New()
router.GET("/", handler)
router.Run(":8080")
router.Handle("GET", "/", handler)
log.Fatal(http.ListenAndServe(":8080", router))
```

**2. With Database**
Expand Down
4 changes: 2 additions & 2 deletions examples/validation/06-production/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,8 +346,8 @@ Press `Ctrl+C` and observe graceful shutdown:
- [ ] Change `JWT_SECRET` to strong random value
- [ ] Use HTTPS in production
- [ ] Implement password hashing (bcrypt)
- [ ] Add rate limiting (see `fursy.RateLimit()`)
- [ ] Add CORS middleware (see `fursy.CORS()`)
- [ ] Add rate limiting (see `middleware.RateLimit()`)
- [ ] Add CORS middleware (see `middleware.CORS()`)
- [ ] Add request ID tracking
- [ ] Implement proper session management
- [ ] Add audit logging
Expand Down
2 changes: 1 addition & 1 deletion internal/binding/binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ func mapForm(ptr any, form map[string][]string) error {

// Set field value
if err := setField(field, values[0]); err != nil {
return fmt.Errorf("set field %s error: %w", structField.Name, err)
return &DecodeError{Err: fmt.Errorf("field %s: %w", structField.Name, err)}
}
}

Expand Down
17 changes: 17 additions & 0 deletions internal/binding/binding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,23 @@ func TestMapForm_MissingFormValues(t *testing.T) {
}
}

// TestMapForm_TypeMismatch tests that form type errors return DecodeError.
func TestMapForm_TypeMismatch(t *testing.T) {
form := url.Values{
"age": {"not-a-number"},
}
var result BindTestStruct
err := mapForm(&result, form)
if err == nil {
t.Fatal("expected error for type mismatch, got nil")
}

var decodeErr *DecodeError
if !errors.As(err, &decodeErr) {
t.Errorf("expected DecodeError, got %T: %v", err, err)
}
}

// TestMapForm_UnexportedFields tests that unexported fields are skipped.
func TestMapForm_UnexportedFields(t *testing.T) {
type withUnexported struct {
Expand Down
Loading
Loading