Security
Kruda keeps behavior-affecting hardening explicit so applications can choose the security/performance tradeoff they need. Use WithSecureHeaders() to enable default security response headers, or WithSecurity() to enable both security headers and app-level path traversal prevention.
For vulnerability reporting, see SECURITY.md.
Security Headers
When enabled via WithSecureHeaders(), normal handler responses include 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 |
Kruda does not emit a Server header by default, so framework identity and version details are not exposed by normal responses.
Prebuilt Wing static responses from StaticText and StaticJSON are written directly for maximum throughput and bypass middleware, lifecycle hooks, cookies, CORS, and secure-header injection. Prefer normal handlers when a response needs application behavior or WithSecureHeaders().
// 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. Header injection prevention, body size limits, and timeouts are active by default. Security response headers are opt-in via
WithSecureHeaders(), and app-level path traversal prevention is opt-in viaWithPathTraversal()orWithSecurity().
| 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()
When enabled, request paths containing . or percent-encoded segments 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, wing_transport.go
| Setting | Default | Option |
|---|---|---|
| Max body size | 4 MB | WithBodyLimit(bytes) / WithMaxBodySize(bytes) |
| Max header size | 8 KB | WithHeaderLimit(bytes) |
| Read timeout | 30s | WithReadTimeout(d) |
| Write timeout | 30s | WithWriteTimeout(d) |
| Idle timeout | 120s | WithIdleTimeout(d) |
| Trust proxy headers | false | WithTrustProxy(true) |
Body and header limits are enforced on all transports, including Wing:
- Bodies exceeding
BodyLimitreturn HTTP 413 before the handler runs. - Headers exceeding
HeaderLimitreturn HTTP 431. - Chunked transfer-encoded bodies are rejected with HTTP 501 on Wing (Wing does not dechunk; use a proxy).
- When
TrustProxyis enabled,X-Forwarded-ForandX-Real-IPare used for the client IP on all transports.
Timeouts are enforced at the transport level and apply during body accumulation as well as idle connections.
Wing request pipeline and parser-level rejects
Well-formed requests route through Wing's full serveKruda pipeline, so middleware, lifecycle hooks, and error handling all run — including for app-level rejections such as 404, 405, and path-traversal 400. Anything wired to the OnResponse hook (e.g. the contrib/observability RED-metrics hook) sees those responses.
Parser-level rejects are different and bypass the app pipeline entirely — no middleware, no hooks, no span or metric — by design, because the request never becomes a valid app request:
- 413 / 431 / 501 write a minimal status line and then close the connection.
- Malformed requests close the connection silently, with no HTTP response written.
This is intentional: attack and malformed traffic should not consume app resources or pollute telemetry. (Note: serve_fast.go is the fasthttp transport path, not Wing.)
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/")
},
}))Production guidance:
- Keep
CookieHTTPOnlyenabled so browser JavaScript cannot read session IDs. - Set
CookieSecure: trueon HTTPS deployments. - Use
http.SameSiteLaxModefor most browser apps, orhttp.SameSiteStrictModewhen cross-site login and callback flows are not needed. - If
http.SameSiteNoneModeis required, also setCookieSecure: true. - Use a shared persistent
Storefor multi-instance deployments; the default memory store is single-process only. - Call
sess.Destroy()on logout. Kruda expires the cookie using the configured path, domain,Secure,HttpOnly, andSameSiteattributes.
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,
}))