Security
Kruda provides security hardening features that can be enabled with a single option. Use WithSecureHeaders() to enable all default security headers.
For vulnerability reporting, see SECURITY.md.
Security Headers
When enabled via WithSecureHeaders(), every response includes these headers:
| Header | Default Value | Purpose |
|---|---|---|
X-Content-Type-Options | nosniff | Prevents MIME type sniffing |
X-Frame-Options | DENY | Prevents clickjacking |
X-XSS-Protection | 0 | Disabled per modern best practice (CSP preferred) |
Referrer-Policy | strict-origin-when-cross-origin | Controls referrer information |
The Server: Kruda header is sent by default (no version number is leaked).
// Explicitly enable security headers
app := kruda.New(kruda.WithSecureHeaders())
// Restore Phase 1-4 defaults for backward compatibility
app := kruda.New(kruda.WithLegacySecurityHeaders())
// X-Frame-Options: SAMEORIGIN, X-XSS-Protection: 1; mode=block, Referrer-Policy: no-referrerThreat Model
Kruda assumes:
- The application is exposed to untrusted network traffic (the internet).
- Request paths, headers, query parameters, and body content are attacker-controlled.
- The framework provides layered protections. Core protections (path normalization, header injection prevention, body size limits) are always active. Security response headers are opt-in via
WithSecureHeaders().
| Threat | Severity | Status |
|---|---|---|
| Path Traversal | High | Mitigated |
| Header Injection (CRLF) | High | Mitigated |
| Denial of Service (DoS) | Medium | Mitigated |
| CORS Bypass | Medium | Mitigated |
| Cross-Site Scripting (XSS) | Medium | Mitigated (headers) |
| Cross-Site Request Forgery (CSRF) | Medium | Mitigated (middleware) |
Path Traversal Prevention
Source: router.go -- cleanPath()
All request paths are normalized before route matching:
- Decodes percent-encoded sequences (
%2e%2e%2f->../) - Normalizes via
path.Clean()to resolve.and..segments - Ensures the result starts with
/ - Rejects any path that still contains
..after cleaning
GET /../etc/passwd -> 400 Bad Request
GET /%2e%2e/etc/passwd -> 400 Bad Request
GET /a/b/../c -> normalized to /a/c, routed normallyEnable via WithPathTraversal() or WithSecurity(). The Static() file server has its own built-in traversal check independent of this setting.
Header Injection Prevention
Source: context.go -- sanitizeHeaderValue(), isValidHeaderKey()
HTTP header injection (CRLF injection) is prevented on all response header methods:
sanitizeHeaderValue()strips\rand\nfrom header values (fast path when no CRLF present)isValidHeaderKey()validates keys contain only token characters per RFC 7230
// CRLF characters are automatically stripped
c.SetHeader("X-Custom", "value\r\nInjected: header")
// Result: "X-Custom: valueInjected: header"
// Invalid header keys are silently skipped with a warning log
c.SetHeader("Invalid Key!", "value")Applies to SetHeader, AddHeader, and SetCookie.
DoS Protection
Source: config.go, transport/nethttp.go, transport/wing/transport.go
| Setting | Default | Option |
|---|---|---|
| Max body size | 4 MB | WithBodyLimit(bytes) / WithMaxBodySize(bytes) |
| Read timeout | 30s | WithReadTimeout(d) |
| Write timeout | 30s | WithWriteTimeout(d) |
| Idle timeout | 120s | WithIdleTimeout(d) |
| Max header size | 8 KB | config field |
Bodies exceeding the limit return HTTP 413. Timeouts are enforced at the transport level.
app := kruda.New(
kruda.WithMaxBodySize(1024 * 1024), // 1MB
kruda.WithReadTimeout(10 * time.Second),
)CORS Configuration
Source: middleware/cors.go
app.Use(middleware.CORS(middleware.CORSConfig{
AllowOrigins: []string{"https://app.example.com", "https://admin.example.com"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowHeaders: []string{"Content-Type", "Authorization"},
AllowCredentials: true,
ExposeHeaders: []string{"X-Request-ID"},
MaxAge: 3600,
}))Key behaviors:
- Exact origin matching via O(1) lookup set. Non-matching origins get no
Access-Control-Allow-Origin. - Credentials + wildcard rejection:
AllowCredentials: truewithAllowOrigins: ["*"]panics at startup (CORS spec violation). - Preflight handling:
OPTIONSrequests return HTTP 204 with appropriateAccess-Control-Allow-*headers. - Vary header:
Vary: Originis set automatically for non-wildcard configurations. - Startup validation: Invalid origin formats panic at init so misconfigurations are caught early.
CSRF Protection
Source: middleware/csrf.go
Built-in CSRF middleware using the double-submit cookie pattern.
app.Use(middleware.CSRF())How it works:
- Safe methods (GET, HEAD, OPTIONS, TRACE): Generates a random 32-byte token (crypto/rand), sets a
_csrfcookie, and stores the token inc.Set("csrf_token", token)for template rendering. - Unsafe methods (POST, PUT, DELETE, PATCH): Validates the
X-CSRF-Tokenheader against the_csrfcookie usingcrypto/subtle.ConstantTimeCompare. Rejects with 403 on mismatch. - After validation, a new token is generated for the next request (one-time use).
SPA/AJAX pattern: Read the _csrf cookie with JavaScript and send it as the X-CSRF-Token header on every mutation request.
// Frontend: read cookie and send as header
const csrfToken = document.cookie.match(/(?:^|;\s*)_csrf=([^;]*)/)?.[1];
fetch('/api/data', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
body: JSON.stringify(data),
});Server-rendered form pattern: Access the token from the request context for hidden fields.
app.Get("/form", func(c *kruda.Ctx) error {
token := c.Get("csrf_token").(string)
// Render form with hidden <input name="_csrf" value="token">
return c.HTML(renderForm(token))
})Configuration:
app.Use(middleware.CSRF(middleware.CSRFConfig{
CookieName: "_csrf", // cookie name (default)
HeaderName: "X-CSRF-Token", // header to check (default)
CookiePath: "/", // cookie path (default)
CookieSecure: true, // set for HTTPS-only
SameSite: http.SameSiteStrictMode, // default
MaxAge: 3600, // 1 hour (default)
TokenLength: 32, // 32 bytes = 64 hex chars (default)
Skip: func(c *kruda.Ctx) bool {
return strings.HasPrefix(c.Path(), "/api/webhook")
},
}))Key security properties:
- Constant-time comparison prevents timing attacks
- SameSite=Strict prevents cross-site cookie sending
- HttpOnly=false on CSRF cookie (JS must read it for double-submit pattern)
- Token refresh on every request prevents replay
- Minimum 16-byte tokens enforced at init (panics if shorter)
Applications using Bearer token authentication (
Authorizationheader) are generally not vulnerable to CSRF.
Session Management
Source: contrib/session/
Session middleware with a pluggable store interface. Default in-memory store included.
import "github.com/go-kruda/kruda/contrib/session"
app.Use(session.New())Usage in handlers:
app.Post("/login", func(c *kruda.Ctx) error {
sess := session.GetSession(c)
sess.Set("user_id", 42)
sess.Set("role", "admin")
return c.JSON(kruda.Map{"ok": true})
})
app.Get("/profile", func(c *kruda.Ctx) error {
sess := session.GetSession(c)
userID := sess.GetInt("user_id")
role := sess.GetString("role")
return c.JSON(kruda.Map{"user_id": userID, "role": role})
})
app.Post("/logout", func(c *kruda.Ctx) error {
sess := session.GetSession(c)
sess.Destroy() // removes from store + expires cookie
return c.JSON(kruda.Map{"ok": true})
})Session API:
| Method | Description |
|---|---|
Get(key) | Get any value |
GetString(key, default...) | Get string with optional default |
GetInt(key, default...) | Get int with optional default |
Set(key, value) | Store a value |
Delete(key) | Remove a key |
Clear() | Remove all values |
Destroy() | Delete session from store + expire cookie |
ID() | Get session ID |
IsNew() | True if session was just created |
Configuration:
app.Use(session.New(session.Config{
CookieName: "_session", // default
CookiePath: "/", // default
CookieSecure: true, // for HTTPS
CookieHTTPOnly: true, // default (prevents JS access)
CookieSameSite: http.SameSiteLaxMode, // default
MaxAge: 86400, // 24h cookie (default)
IdleTimeout: 30 * time.Minute, // server-side expiry (default)
Store: session.NewMemoryStore(), // default
Skip: func(method, path string) bool {
return strings.HasPrefix(path, "/static/")
},
}))Custom store: Implement the Store interface for Redis, database, or other backends:
type Store interface {
Get(id string) (*SessionData, error)
Save(id string, data *SessionData, ttl time.Duration) error
Delete(id string) error
}Session security properties:
- 32-byte session IDs from crypto/rand (64 hex chars)
- HttpOnly=true by default (prevents XSS session theft)
- SameSite=Lax by default
- Server-side expiration via IdleTimeout (independent of cookie MaxAge)
- Automatic cleanup of expired sessions in MemoryStore
JWT Best Practices
Kruda's JWT middleware (contrib/jwt) validates exp and nbf claims automatically. Tokens presented before their nbf time are rejected with ErrTokenNotYetValid.
- Store signing keys in environment variables or a secrets manager, never in source code.
- Use short-lived tokens (15-30 minutes) with a refresh token flow.
- Set
nbfto the token's issue time to prevent pre-dating.
app.Use(jwt.New(jwt.Config{
Secret: []byte(os.Getenv("JWT_SECRET")),
Expiration: 30 * time.Minute,
}))WebSocket Security
Message Timeout (Slowloris Prevention)
Without a timeout, a client can send fragmented frames indefinitely. Set MessageTimeout to cap assembly time.
ws.New(ws.Config{
MessageTimeout: 30 * time.Second,
MaxMessageSize: 1 << 20, // 1 MB
})Ping Rate Limiting
A malicious client can flood the server with ping frames. MaxPingPerSecond closes the connection when exceeded.
ws.New(ws.Config{
MaxPingPerSecond: 10,
})File Upload Validation
Uploaded filenames are automatically sanitized with filepath.Base() to strip directory components. Validate extensions in your handler:
type UploadReq struct {
Avatar *kruda.FileUpload `form:"avatar" validate:"required,max_size=5mb,mime=image/*"`
}
kruda.Post[UploadReq, any](app, "/upload", func(c *kruda.C[UploadReq]) (*any, error) {
file := c.In.Avatar
ext := strings.ToLower(filepath.Ext(file.Filename))
allowed := map[string]bool{".jpg": true, ".png": true, ".webp": true}
if !allowed[ext] {
return nil, kruda.NewError(400, "unsupported file type")
}
// file.Open() returns io.ReadCloser for processing
return nil, c.NoContent()
})Recovery Middleware
In development, stack traces help debugging. In production they leak internal paths and dependencies.
// Development
app.Use(middleware.Recovery())
// Production -- hide stack traces
app.Use(middleware.Recovery(middleware.RecoveryConfig{
DisableStackTrace: true,
}))
// Production -- report to external service
app.Use(middleware.Recovery(middleware.RecoveryConfig{
DisableStackTrace: true,
PanicHandler: func(c *kruda.Ctx, v any) {
sentry.CaptureException(fmt.Errorf("panic: %v", v))
c.Status(500).JSON(map[string]string{"error": "internal error"})
},
}))Request ID & Route Regex
Request ID Validation
Incoming X-Request-ID headers are validated against [a-zA-Z0-9_-] with a 256-character limit. Invalid characters cause the header to be replaced with a generated UUID v4.
Custom Generator functions should only produce allowed characters.
Route Regex Constraints
Patterns with regex constraints (e.g. /:id<^\d+$>) are checked for nested quantifiers at registration time. Patterns like (a+)+ that could cause catastrophic backtracking (ReDoS) are rejected with a panic.
Keep regex constraints simple:
app.Get("/:id<^\\d+$>", handler) // digits only
app.Get("/:slug<^[a-z0-9-]+$>", handler) // slugsDev Mode
In development mode (WithDevMode(true) or KRUDA_ENV=development):
X-Frame-Optionsis relaxed toSAMEORIGINfor dev tools- Rich error pages are rendered with source context
- Environment variables are filtered (no
SECRET,PASSWORD,TOKEN,KEY,CREDENTIAL,AUTH)
Dev mode defaults to false -- must be explicitly enabled.
Production Checklist
- [ ] Use
middleware.RecoverywithDisableStackTrace: true - [ ] Enable
middleware.CSRF()for form-based or cookie-authenticated routes - [ ] Configure CORS with explicit origins (never
*with credentials) - [ ] Use
session.New()withCookieSecure: truefor HTTPS - [ ] Set
MessageTimeoutandMaxPingPerSecondon WebSocket endpoints - [ ] Validate file upload extensions in your handler
- [ ] Use strong JWT secrets from environment variables or a vault
- [ ] Add
middleware.PathTraversal()before serving static files - [ ] Keep route regex constraints simple to avoid ReDoS
app := kruda.New()
app.Use(middleware.Recovery(middleware.RecoveryConfig{DisableStackTrace: true}))
app.Use(middleware.RequestID())
app.Use(middleware.CSRF())
app.Use(session.New(session.Config{CookieSecure: true}))
app.Use(middleware.PathTraversal())
app.Use(middleware.CORS(middleware.CORSConfig{
AllowOrigins: []string{"https://app.example.com"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
AllowCredentials: true,
MaxAge: 86400,
}))