Skip to content

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:

HeaderDefault ValuePurpose
X-Content-Type-OptionsnosniffPrevents MIME type sniffing
X-Frame-OptionsDENYPrevents clickjacking
X-XSS-Protection0Disabled per modern best practice (CSP preferred)
Referrer-Policystrict-origin-when-cross-originControls 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().

go
// 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-referrer

Threat 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 via WithPathTraversal() or WithSecurity().
ThreatSeverityStatus
Path TraversalHighMitigated
Header Injection (CRLF)HighMitigated
Denial of Service (DoS)MediumMitigated
CORS BypassMediumMitigated
Cross-Site Scripting (XSS)MediumMitigated (headers)
Cross-Site Request Forgery (CSRF)MediumMitigated (middleware)

Path Traversal Prevention

Source: router.go -- cleanPath()

When enabled, request paths containing . or percent-encoded segments are normalized before route matching:

  1. Decodes percent-encoded sequences (%2e%2e%2f -> ../)
  2. Normalizes via path.Clean() to resolve . and .. segments
  3. Ensures the result starts with /
  4. 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 normally

Enable 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 \r and \n from header values (fast path when no CRLF present)
  • isValidHeaderKey() validates keys contain only token characters per RFC 7230
go
// 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

SettingDefaultOption
Max body size4 MBWithBodyLimit(bytes) / WithMaxBodySize(bytes)
Max header size8 KBWithHeaderLimit(bytes)
Read timeout30sWithReadTimeout(d)
Write timeout30sWithWriteTimeout(d)
Idle timeout120sWithIdleTimeout(d)
Trust proxy headersfalseWithTrustProxy(true)

Body and header limits are enforced on all transports, including Wing:

  • Bodies exceeding BodyLimit return HTTP 413 before the handler runs.
  • Headers exceeding HeaderLimit return HTTP 431.
  • Chunked transfer-encoded bodies are rejected with HTTP 501 on Wing (Wing does not dechunk; use a proxy).
  • When TrustProxy is enabled, X-Forwarded-For and X-Real-IP are 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.)

go
app := kruda.New(
    kruda.WithMaxBodySize(1024 * 1024), // 1MB
    kruda.WithReadTimeout(10 * time.Second),
)

CORS Configuration

Source: middleware/cors.go

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: true with AllowOrigins: ["*"] panics at startup (CORS spec violation).
  • Preflight handling: OPTIONS requests return HTTP 204 with appropriate Access-Control-Allow-* headers.
  • Vary header: Vary: Origin is 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.

go
app.Use(middleware.CSRF())

How it works:

  1. Safe methods (GET, HEAD, OPTIONS, TRACE): Generates a random 32-byte token (crypto/rand), sets a _csrf cookie, and stores the token in c.Set("csrf_token", token) for template rendering.
  2. Unsafe methods (POST, PUT, DELETE, PATCH): Validates the X-CSRF-Token header against the _csrf cookie using crypto/subtle.ConstantTimeCompare. Rejects with 403 on mismatch.
  3. 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.

javascript
// 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.

go
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:

go
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 (Authorization header) are generally not vulnerable to CSRF.

Session Management

Source: contrib/session/

Session middleware with a pluggable store interface. Default in-memory store included.

go
import "github.com/go-kruda/kruda/contrib/session"

app.Use(session.New())

Usage in handlers:

go
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:

MethodDescription
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:

go
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 CookieHTTPOnly enabled so browser JavaScript cannot read session IDs.
  • Set CookieSecure: true on HTTPS deployments.
  • Use http.SameSiteLaxMode for most browser apps, or http.SameSiteStrictMode when cross-site login and callback flows are not needed.
  • If http.SameSiteNoneMode is required, also set CookieSecure: true.
  • Use a shared persistent Store for 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, and SameSite attributes.

Custom store: Implement the Store interface for Redis, database, or other backends:

go
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 nbf to the token's issue time to prevent pre-dating.
go
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.

go
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.

go
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:

go
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.

go
// 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:

go
app.Get("/:id<^\\d+$>", handler)         // digits only
app.Get("/:slug<^[a-z0-9-]+$>", handler) // slugs

Dev Mode

In development mode (WithDevMode(true) or KRUDA_ENV=development):

  • X-Frame-Options is relaxed to SAMEORIGIN for 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.Recovery with DisableStackTrace: true
  • [ ] Enable middleware.CSRF() for form-based or cookie-authenticated routes
  • [ ] Configure CORS with explicit origins (never * with credentials)
  • [ ] Use session.New() with CookieSecure: true for HTTPS
  • [ ] Set MessageTimeout and MaxPingPerSecond on 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
go
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,
}))

Released under the MIT License.