mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-14 04:07:46 +00:00
fixup! Cleanup.
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
// Package errors provides unified error handling for OIDC operations
|
||||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ErrorCode represents specific error types
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
// Authentication errors
|
||||
ErrCodeAuthenticationFailed ErrorCode = "AUTH_FAILED"
|
||||
ErrCodeTokenExpired ErrorCode = "TOKEN_EXPIRED"
|
||||
ErrCodeTokenInvalid ErrorCode = "TOKEN_INVALID"
|
||||
ErrCodeSessionExpired ErrorCode = "SESSION_EXPIRED"
|
||||
ErrCodeCSRFMismatch ErrorCode = "CSRF_MISMATCH"
|
||||
ErrCodeNonceMismatch ErrorCode = "NONCE_MISMATCH"
|
||||
|
||||
// Configuration errors
|
||||
ErrCodeConfigInvalid ErrorCode = "CONFIG_INVALID"
|
||||
ErrCodeProviderUnreachable ErrorCode = "PROVIDER_UNREACHABLE"
|
||||
ErrCodeMetadataFailed ErrorCode = "METADATA_FAILED"
|
||||
|
||||
// Network errors
|
||||
ErrCodeNetworkTimeout ErrorCode = "NETWORK_TIMEOUT"
|
||||
ErrCodeRateLimited ErrorCode = "RATE_LIMITED"
|
||||
ErrCodeServiceUnavailable ErrorCode = "SERVICE_UNAVAILABLE"
|
||||
|
||||
// Validation errors
|
||||
ErrCodeValidationFailed ErrorCode = "VALIDATION_FAILED"
|
||||
ErrCodeDomainNotAllowed ErrorCode = "DOMAIN_NOT_ALLOWED"
|
||||
ErrCodeUserNotAllowed ErrorCode = "USER_NOT_ALLOWED"
|
||||
ErrCodeRoleNotAllowed ErrorCode = "ROLE_NOT_ALLOWED"
|
||||
)
|
||||
|
||||
// OIDCError represents a structured error with context
|
||||
type OIDCError struct {
|
||||
Code ErrorCode `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details string `json:"details,omitempty"`
|
||||
HTTPStatus int `json:"http_status"`
|
||||
Internal error `json:"-"` // Internal error, not exposed
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (e *OIDCError) Error() string {
|
||||
if e.Details != "" {
|
||||
return fmt.Sprintf("%s: %s (%s)", e.Code, e.Message, e.Details)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap returns the internal error for error wrapping
|
||||
func (e *OIDCError) Unwrap() error {
|
||||
return e.Internal
|
||||
}
|
||||
|
||||
// IsRetryable indicates if the error is temporary and can be retried
|
||||
func (e *OIDCError) IsRetryable() bool {
|
||||
return e.Code == ErrCodeNetworkTimeout ||
|
||||
e.Code == ErrCodeServiceUnavailable ||
|
||||
e.Code == ErrCodeProviderUnreachable
|
||||
}
|
||||
|
||||
// IsAuthenticationError indicates if this is an authentication-related error
|
||||
func (e *OIDCError) IsAuthenticationError() bool {
|
||||
return e.Code == ErrCodeAuthenticationFailed ||
|
||||
e.Code == ErrCodeTokenExpired ||
|
||||
e.Code == ErrCodeTokenInvalid ||
|
||||
e.Code == ErrCodeSessionExpired ||
|
||||
e.Code == ErrCodeCSRFMismatch ||
|
||||
e.Code == ErrCodeNonceMismatch
|
||||
}
|
||||
|
||||
// IsAuthorizationError indicates if this is an authorization-related error
|
||||
func (e *OIDCError) IsAuthorizationError() bool {
|
||||
return e.Code == ErrCodeDomainNotAllowed ||
|
||||
e.Code == ErrCodeUserNotAllowed ||
|
||||
e.Code == ErrCodeRoleNotAllowed
|
||||
}
|
||||
|
||||
// ToJSON converts the error to a JSON response
|
||||
func (e *OIDCError) ToJSON() map[string]any {
|
||||
result := map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": string(e.Code),
|
||||
"message": e.Message,
|
||||
},
|
||||
}
|
||||
|
||||
if e.Details != "" {
|
||||
result["error"].(map[string]any)["details"] = e.Details
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Error constructors for common scenarios
|
||||
|
||||
// NewAuthenticationError creates an authentication-related error
|
||||
func NewAuthenticationError(code ErrorCode, message string, internal error) *OIDCError {
|
||||
status := http.StatusUnauthorized
|
||||
if code == ErrCodeSessionExpired {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
|
||||
return &OIDCError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
HTTPStatus: status,
|
||||
Internal: internal,
|
||||
}
|
||||
}
|
||||
|
||||
// NewAuthorizationError creates an authorization-related error
|
||||
func NewAuthorizationError(code ErrorCode, message string, details string) *OIDCError {
|
||||
return &OIDCError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Details: details,
|
||||
HTTPStatus: http.StatusForbidden,
|
||||
}
|
||||
}
|
||||
|
||||
// NewConfigurationError creates a configuration-related error
|
||||
func NewConfigurationError(code ErrorCode, message string, internal error) *OIDCError {
|
||||
return &OIDCError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
HTTPStatus: http.StatusInternalServerError,
|
||||
Internal: internal,
|
||||
}
|
||||
}
|
||||
|
||||
// NewNetworkError creates a network-related error
|
||||
func NewNetworkError(code ErrorCode, message string, internal error) *OIDCError {
|
||||
status := http.StatusServiceUnavailable
|
||||
if code == ErrCodeRateLimited {
|
||||
status = http.StatusTooManyRequests
|
||||
}
|
||||
|
||||
return &OIDCError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
HTTPStatus: status,
|
||||
Internal: internal,
|
||||
}
|
||||
}
|
||||
|
||||
// NewValidationError creates a validation-related error
|
||||
func NewValidationError(code ErrorCode, message string, details string) *OIDCError {
|
||||
return &OIDCError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Details: details,
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience functions for common error patterns
|
||||
|
||||
// WrapAuthenticationError wraps an existing error as an authentication error
|
||||
func WrapAuthenticationError(err error, message string) *OIDCError {
|
||||
return NewAuthenticationError(ErrCodeAuthenticationFailed, message, err)
|
||||
}
|
||||
|
||||
// WrapTokenError wraps a token-related error
|
||||
func WrapTokenError(err error, tokenType string) *OIDCError {
|
||||
message := fmt.Sprintf("Token validation failed: %s", tokenType)
|
||||
return NewAuthenticationError(ErrCodeTokenInvalid, message, err)
|
||||
}
|
||||
|
||||
// WrapProviderError wraps a provider communication error
|
||||
func WrapProviderError(err error, providerURL string) *OIDCError {
|
||||
message := fmt.Sprintf("Provider communication failed: %s", providerURL)
|
||||
return NewNetworkError(ErrCodeProviderUnreachable, message, err)
|
||||
}
|
||||
|
||||
// IsOIDCError checks if an error is an OIDCError
|
||||
func IsOIDCError(err error) (*OIDCError, bool) {
|
||||
oidcErr, ok := err.(*OIDCError)
|
||||
return oidcErr, ok
|
||||
}
|
||||
|
||||
// GetHTTPStatus extracts HTTP status from error, defaulting to 500
|
||||
func GetHTTPStatus(err error) int {
|
||||
if oidcErr, ok := IsOIDCError(err); ok {
|
||||
return oidcErr.HTTPStatus
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// FormatUserMessage creates a user-friendly error message
|
||||
func FormatUserMessage(err error) string {
|
||||
if oidcErr, ok := IsOIDCError(err); ok {
|
||||
switch oidcErr.Code {
|
||||
case ErrCodeDomainNotAllowed:
|
||||
return "Your email domain is not authorized for this application"
|
||||
case ErrCodeUserNotAllowed:
|
||||
return "Your account is not authorized for this application"
|
||||
case ErrCodeRoleNotAllowed:
|
||||
return "You do not have the required permissions for this application"
|
||||
case ErrCodeSessionExpired:
|
||||
return "Your session has expired. Please log in again"
|
||||
case ErrCodeTokenExpired:
|
||||
return "Your authentication has expired. Please log in again"
|
||||
case ErrCodeProviderUnreachable:
|
||||
return "Authentication service is temporarily unavailable. Please try again later"
|
||||
case ErrCodeRateLimited:
|
||||
return "Too many requests. Please wait a moment and try again"
|
||||
default:
|
||||
return "Authentication failed. Please try again"
|
||||
}
|
||||
}
|
||||
return "An unexpected error occurred. Please try again"
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// Package handlers provides authentication flow management
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuthFlowHandler manages the complete OIDC authentication flow
|
||||
type AuthFlowHandler struct {
|
||||
sessionHandler *SessionHandler
|
||||
tokenHandler TokenHandler
|
||||
logger Logger
|
||||
excludedURLs map[string]struct{}
|
||||
initComplete chan struct{}
|
||||
issuerURL string
|
||||
}
|
||||
|
||||
// TokenHandler interface for token operations
|
||||
type TokenHandler interface {
|
||||
VerifyToken(token string) error
|
||||
RefreshToken(refreshToken string) (*TokenResponse, error)
|
||||
}
|
||||
|
||||
// TokenResponse represents token exchange response
|
||||
type TokenResponse struct {
|
||||
IDToken string `json:"id_token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// AuthFlowResult represents the result of authentication flow processing
|
||||
type AuthFlowResult struct {
|
||||
Authenticated bool
|
||||
RequiresAuth bool
|
||||
RequiresRefresh bool
|
||||
Error error
|
||||
RedirectURL string
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
// NewAuthFlowHandler creates a new authentication flow handler
|
||||
func NewAuthFlowHandler(sessionHandler *SessionHandler, tokenHandler TokenHandler, logger Logger, excludedURLs map[string]struct{}, initComplete chan struct{}, issuerURL string) *AuthFlowHandler {
|
||||
return &AuthFlowHandler{
|
||||
sessionHandler: sessionHandler,
|
||||
tokenHandler: tokenHandler,
|
||||
logger: logger,
|
||||
excludedURLs: excludedURLs,
|
||||
initComplete: initComplete,
|
||||
issuerURL: issuerURL,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessRequest handles the main authentication flow
|
||||
func (h *AuthFlowHandler) ProcessRequest(rw http.ResponseWriter, req *http.Request) AuthFlowResult {
|
||||
// Check if URL should be excluded
|
||||
if h.shouldExcludeURL(req.URL.Path) {
|
||||
h.logger.Debugf("Request path %s excluded by configuration, bypassing OIDC", req.URL.Path)
|
||||
return AuthFlowResult{Authenticated: true}
|
||||
}
|
||||
|
||||
// Check for streaming requests
|
||||
if h.isStreamingRequest(req) {
|
||||
h.logger.Debugf("Streaming request detected, bypassing OIDC")
|
||||
return AuthFlowResult{Authenticated: true}
|
||||
}
|
||||
|
||||
// Wait for initialization
|
||||
if !h.waitForInitialization(req) {
|
||||
return AuthFlowResult{
|
||||
Error: ErrInitializationTimeout,
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
// Get and validate session
|
||||
session, err := h.sessionHandler.sessionManager.GetSession(req)
|
||||
if err != nil {
|
||||
h.logger.Errorf("Error getting session: %v", err)
|
||||
return AuthFlowResult{
|
||||
RequiresAuth: true,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
defer session.ReturnToPoolSafely()
|
||||
|
||||
// Clean up old cookies
|
||||
h.sessionHandler.sessionManager.CleanupOldCookies(rw, req)
|
||||
|
||||
// Validate session
|
||||
validationResult := h.sessionHandler.ValidateSession(session)
|
||||
if !validationResult.Valid {
|
||||
if validationResult.NeedsAuth {
|
||||
return AuthFlowResult{RequiresAuth: true}
|
||||
}
|
||||
return AuthFlowResult{
|
||||
Error: ErrSessionInvalid,
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
}
|
||||
}
|
||||
|
||||
// Check token validity and refresh if needed
|
||||
return h.validateAndRefreshTokens(session, req, rw)
|
||||
}
|
||||
|
||||
// shouldExcludeURL checks if a URL should bypass authentication
|
||||
func (h *AuthFlowHandler) shouldExcludeURL(path string) bool {
|
||||
for excludedURL := range h.excludedURLs {
|
||||
if len(path) >= len(excludedURL) && path[:len(excludedURL)] == excludedURL {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isStreamingRequest checks if request is a streaming request that should bypass auth
|
||||
func (h *AuthFlowHandler) isStreamingRequest(req *http.Request) bool {
|
||||
acceptHeader := req.Header.Get("Accept")
|
||||
return acceptHeader == "text/event-stream"
|
||||
}
|
||||
|
||||
// waitForInitialization waits for OIDC provider initialization
|
||||
func (h *AuthFlowHandler) waitForInitialization(req *http.Request) bool {
|
||||
select {
|
||||
case <-h.initComplete:
|
||||
if h.issuerURL == "" {
|
||||
h.logger.Error("OIDC provider metadata initialization failed")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case <-req.Context().Done():
|
||||
h.logger.Debug("Request cancelled while waiting for OIDC initialization")
|
||||
return false
|
||||
case <-time.After(30 * time.Second):
|
||||
h.logger.Error("Timeout waiting for OIDC initialization")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// validateAndRefreshTokens handles token validation and refresh logic
|
||||
func (h *AuthFlowHandler) validateAndRefreshTokens(session Session, req *http.Request, rw http.ResponseWriter) AuthFlowResult {
|
||||
// Check access token if present
|
||||
if accessToken := session.GetAccessToken(); accessToken != "" {
|
||||
if err := h.tokenHandler.VerifyToken(accessToken); err != nil {
|
||||
h.logger.Errorf("Access token validation failed: %v", err)
|
||||
|
||||
// Try refresh if refresh token is available
|
||||
if refreshToken := session.GetRefreshToken(); refreshToken != "" {
|
||||
return h.attemptTokenRefresh(session, req, rw)
|
||||
}
|
||||
|
||||
return AuthFlowResult{RequiresAuth: true}
|
||||
}
|
||||
}
|
||||
|
||||
// Check ID token
|
||||
if idToken := session.GetIDToken(); idToken != "" {
|
||||
if err := h.tokenHandler.VerifyToken(idToken); err != nil {
|
||||
h.logger.Errorf("ID token validation failed: %v", err)
|
||||
|
||||
// Try refresh if refresh token is available
|
||||
if refreshToken := session.GetRefreshToken(); refreshToken != "" {
|
||||
return h.attemptTokenRefresh(session, req, rw)
|
||||
}
|
||||
|
||||
return AuthFlowResult{RequiresAuth: true}
|
||||
}
|
||||
}
|
||||
|
||||
return AuthFlowResult{Authenticated: true}
|
||||
}
|
||||
|
||||
// attemptTokenRefresh tries to refresh tokens
|
||||
func (h *AuthFlowHandler) attemptTokenRefresh(session Session, req *http.Request, rw http.ResponseWriter) AuthFlowResult {
|
||||
refreshToken := session.GetRefreshToken()
|
||||
if refreshToken == "" {
|
||||
return AuthFlowResult{RequiresAuth: true}
|
||||
}
|
||||
|
||||
// Check if this is an AJAX request
|
||||
if h.sessionHandler.IsAjaxRequest(req) {
|
||||
return AuthFlowResult{
|
||||
Error: ErrSessionExpiredAjax,
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
}
|
||||
}
|
||||
|
||||
_, err := h.tokenHandler.RefreshToken(refreshToken)
|
||||
if err != nil {
|
||||
h.logger.Errorf("Token refresh failed: %v", err)
|
||||
return AuthFlowResult{RequiresAuth: true}
|
||||
}
|
||||
|
||||
// Update session with new tokens would be handled here
|
||||
// Implementation depends on the actual session interface
|
||||
|
||||
if err := session.Save(req, rw); err != nil {
|
||||
h.logger.Errorf("Failed to save refreshed session: %v", err)
|
||||
return AuthFlowResult{
|
||||
Error: err,
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
}
|
||||
}
|
||||
|
||||
return AuthFlowResult{Authenticated: true}
|
||||
}
|
||||
|
||||
// Common errors
|
||||
var (
|
||||
ErrInitializationTimeout = &AuthFlowError{Code: "INIT_TIMEOUT", Message: "OIDC initialization timeout"}
|
||||
ErrSessionInvalid = &AuthFlowError{Code: "SESSION_INVALID", Message: "Invalid session"}
|
||||
ErrSessionExpiredAjax = &AuthFlowError{Code: "SESSION_EXPIRED_AJAX", Message: "Session expired for AJAX request"}
|
||||
)
|
||||
|
||||
// AuthFlowError represents authentication flow errors
|
||||
type AuthFlowError struct {
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *AuthFlowError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package optimization provides memory and performance optimizations
|
||||
package optimization
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// StringBuilderPool provides a pool of reusable string builders
|
||||
type StringBuilderPool struct {
|
||||
pool sync.Pool
|
||||
}
|
||||
|
||||
// NewStringBuilderPool creates a new string builder pool
|
||||
func NewStringBuilderPool() *StringBuilderPool {
|
||||
return &StringBuilderPool{
|
||||
pool: sync.Pool{
|
||||
New: func() any {
|
||||
buf := make([]byte, 0, 256) // Pre-allocate 256 bytes
|
||||
return &buf
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a string builder from the pool
|
||||
func (p *StringBuilderPool) Get() []byte {
|
||||
bufPtr := p.pool.Get().(*[]byte)
|
||||
return *bufPtr
|
||||
}
|
||||
|
||||
// Put returns a string builder to the pool
|
||||
func (p *StringBuilderPool) Put(buf []byte) {
|
||||
if cap(buf) < 4096 { // Don't pool overly large buffers
|
||||
buf = buf[:0] // Reset length but keep capacity
|
||||
p.pool.Put(&buf)
|
||||
}
|
||||
}
|
||||
|
||||
// ByteSlicePool provides a pool of reusable byte slices
|
||||
type ByteSlicePool struct {
|
||||
pool sync.Pool
|
||||
size int
|
||||
}
|
||||
|
||||
// NewByteSlicePool creates a new byte slice pool with specified size
|
||||
func NewByteSlicePool(size int) *ByteSlicePool {
|
||||
return &ByteSlicePool{
|
||||
size: size,
|
||||
pool: sync.Pool{
|
||||
New: func() any {
|
||||
buf := make([]byte, size)
|
||||
return &buf
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a byte slice from the pool
|
||||
func (p *ByteSlicePool) Get() []byte {
|
||||
bufPtr := p.pool.Get().(*[]byte)
|
||||
return *bufPtr
|
||||
}
|
||||
|
||||
// Put returns a byte slice to the pool
|
||||
func (p *ByteSlicePool) Put(buf []byte) {
|
||||
if len(buf) == p.size {
|
||||
p.pool.Put(&buf)
|
||||
}
|
||||
}
|
||||
|
||||
// Global pools for common use cases
|
||||
var (
|
||||
globalStringBuilderPool = NewStringBuilderPool()
|
||||
globalByteSlicePool = NewByteSlicePool(2048)
|
||||
)
|
||||
|
||||
// GetStringBuilder gets a string builder from the global pool
|
||||
func GetStringBuilder() []byte {
|
||||
return globalStringBuilderPool.Get()
|
||||
}
|
||||
|
||||
// PutStringBuilder returns a string builder to the global pool
|
||||
func PutStringBuilder(buf []byte) {
|
||||
globalStringBuilderPool.Put(buf)
|
||||
}
|
||||
|
||||
// GetByteSlice gets a byte slice from the global pool
|
||||
func GetByteSlice() []byte {
|
||||
return globalByteSlicePool.Get()
|
||||
}
|
||||
|
||||
// PutByteSlice returns a byte slice to the global pool
|
||||
func PutByteSlice(buf []byte) {
|
||||
globalByteSlicePool.Put(buf)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// Package patterns provides cached compiled regex patterns for performance optimization
|
||||
package patterns
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// RegexCache manages compiled regex patterns with thread-safe access
|
||||
type RegexCache struct {
|
||||
patterns map[string]*regexp.Regexp
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewRegexCache creates a new regex cache instance
|
||||
func NewRegexCache() *RegexCache {
|
||||
return &RegexCache{
|
||||
patterns: make(map[string]*regexp.Regexp),
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a compiled regex pattern, compiling and caching it if not present
|
||||
func (c *RegexCache) Get(pattern string) (*regexp.Regexp, error) {
|
||||
// First try read lock for existing pattern
|
||||
c.mu.RLock()
|
||||
if regex, exists := c.patterns[pattern]; exists {
|
||||
c.mu.RUnlock()
|
||||
return regex, nil
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
// Pattern not found, acquire write lock to compile and cache
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Double-check in case another goroutine compiled it while we waited
|
||||
if regex, exists := c.patterns[pattern]; exists {
|
||||
return regex, nil
|
||||
}
|
||||
|
||||
// Compile the pattern
|
||||
regex, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the compiled pattern
|
||||
c.patterns[pattern] = regex
|
||||
return regex, nil
|
||||
}
|
||||
|
||||
// MustGet is like Get but panics if the pattern cannot be compiled
|
||||
func (c *RegexCache) MustGet(pattern string) *regexp.Regexp {
|
||||
regex, err := c.Get(pattern)
|
||||
if err != nil {
|
||||
panic("regex compilation failed for pattern '" + pattern + "': " + err.Error())
|
||||
}
|
||||
return regex
|
||||
}
|
||||
|
||||
// Precompile compiles and caches multiple patterns at once
|
||||
func (c *RegexCache) Precompile(patterns []string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
for _, pattern := range patterns {
|
||||
if _, exists := c.patterns[pattern]; !exists {
|
||||
regex, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.patterns[pattern] = regex
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Size returns the number of cached patterns
|
||||
func (c *RegexCache) Size() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.patterns)
|
||||
}
|
||||
|
||||
// Clear removes all cached patterns
|
||||
func (c *RegexCache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.patterns = make(map[string]*regexp.Regexp)
|
||||
}
|
||||
|
||||
// Global regex cache instance
|
||||
var globalCache = NewRegexCache()
|
||||
|
||||
// Common regex patterns used throughout the OIDC implementation
|
||||
const (
|
||||
// Email validation pattern (RFC 5322 compliant)
|
||||
EmailPattern = `^[a-zA-Z0-9.!#$%&'*+/=?^_` + "`" + `{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$`
|
||||
|
||||
// Domain validation pattern
|
||||
DomainPattern = `^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$`
|
||||
|
||||
// URL validation pattern (http/https)
|
||||
URLPattern = `^https?://[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*(/.*)?$`
|
||||
|
||||
// JWT token pattern (three base64url parts separated by dots)
|
||||
JWTPattern = `^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$`
|
||||
|
||||
// Bearer token pattern (Authorization header)
|
||||
BearerTokenPattern = `^Bearer\s+([A-Za-z0-9._~+/-]+=*)$`
|
||||
|
||||
// Client ID pattern (alphanumeric with common separators)
|
||||
ClientIDPattern = `^[a-zA-Z0-9._-]+$`
|
||||
|
||||
// Scope pattern (space-separated alphanumeric with underscores)
|
||||
ScopePattern = `^[a-zA-Z0-9_]+(\s+[a-zA-Z0-9_]+)*$`
|
||||
|
||||
// Session ID pattern (hexadecimal)
|
||||
SessionIDPattern = `^[a-fA-F0-9]{32,128}$`
|
||||
|
||||
// CSRF token pattern (base64url)
|
||||
CSRFTokenPattern = `^[A-Za-z0-9_-]+$`
|
||||
|
||||
// Nonce pattern (base64url)
|
||||
NoncePattern = `^[A-Za-z0-9_-]+$`
|
||||
|
||||
// Code verifier pattern for PKCE (base64url, 43-128 chars)
|
||||
CodeVerifierPattern = `^[A-Za-z0-9_-]{43,128}$`
|
||||
|
||||
// Authorization code pattern (base64url)
|
||||
AuthCodePattern = `^[A-Za-z0-9._~+/-]+=*$`
|
||||
|
||||
// Redirect URI validation (must be absolute HTTP/HTTPS URL)
|
||||
RedirectURIPattern = `^https?://[^\s/$.?#].[^\s]*$`
|
||||
|
||||
// User-Agent pattern for bot detection
|
||||
BotUserAgentPattern = `(?i)(bot|crawler|spider|scraper|curl|wget|python|java|go-http)`
|
||||
|
||||
// IP address pattern (IPv4)
|
||||
IPv4Pattern = `^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$`
|
||||
|
||||
// Tenant ID pattern (UUID format for Azure, etc.)
|
||||
TenantIDPattern = `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`
|
||||
)
|
||||
|
||||
// Precompiled common patterns for immediate use
|
||||
var (
|
||||
EmailRegex *regexp.Regexp
|
||||
DomainRegex *regexp.Regexp
|
||||
URLRegex *regexp.Regexp
|
||||
JWTRegex *regexp.Regexp
|
||||
BearerTokenRegex *regexp.Regexp
|
||||
ClientIDRegex *regexp.Regexp
|
||||
ScopeRegex *regexp.Regexp
|
||||
SessionIDRegex *regexp.Regexp
|
||||
CSRFTokenRegex *regexp.Regexp
|
||||
NonceRegex *regexp.Regexp
|
||||
CodeVerifierRegex *regexp.Regexp
|
||||
AuthCodeRegex *regexp.Regexp
|
||||
RedirectURIRegex *regexp.Regexp
|
||||
BotUserAgentRegex *regexp.Regexp
|
||||
IPv4Regex *regexp.Regexp
|
||||
TenantIDRegex *regexp.Regexp
|
||||
)
|
||||
|
||||
// Initialize precompiled patterns
|
||||
func init() {
|
||||
commonPatterns := []string{
|
||||
EmailPattern,
|
||||
DomainPattern,
|
||||
URLPattern,
|
||||
JWTPattern,
|
||||
BearerTokenPattern,
|
||||
ClientIDPattern,
|
||||
ScopePattern,
|
||||
SessionIDPattern,
|
||||
CSRFTokenPattern,
|
||||
NoncePattern,
|
||||
CodeVerifierPattern,
|
||||
AuthCodePattern,
|
||||
RedirectURIPattern,
|
||||
BotUserAgentPattern,
|
||||
IPv4Pattern,
|
||||
TenantIDPattern,
|
||||
}
|
||||
|
||||
if err := globalCache.Precompile(commonPatterns); err != nil {
|
||||
panic("Failed to precompile common regex patterns: " + err.Error())
|
||||
}
|
||||
|
||||
// Assign precompiled patterns to global variables for easy access
|
||||
EmailRegex = globalCache.MustGet(EmailPattern)
|
||||
DomainRegex = globalCache.MustGet(DomainPattern)
|
||||
URLRegex = globalCache.MustGet(URLPattern)
|
||||
JWTRegex = globalCache.MustGet(JWTPattern)
|
||||
BearerTokenRegex = globalCache.MustGet(BearerTokenPattern)
|
||||
ClientIDRegex = globalCache.MustGet(ClientIDPattern)
|
||||
ScopeRegex = globalCache.MustGet(ScopePattern)
|
||||
SessionIDRegex = globalCache.MustGet(SessionIDPattern)
|
||||
CSRFTokenRegex = globalCache.MustGet(CSRFTokenPattern)
|
||||
NonceRegex = globalCache.MustGet(NoncePattern)
|
||||
CodeVerifierRegex = globalCache.MustGet(CodeVerifierPattern)
|
||||
AuthCodeRegex = globalCache.MustGet(AuthCodePattern)
|
||||
RedirectURIRegex = globalCache.MustGet(RedirectURIPattern)
|
||||
BotUserAgentRegex = globalCache.MustGet(BotUserAgentPattern)
|
||||
IPv4Regex = globalCache.MustGet(IPv4Pattern)
|
||||
TenantIDRegex = globalCache.MustGet(TenantIDPattern)
|
||||
}
|
||||
|
||||
// Global helper functions for common validations
|
||||
|
||||
// ValidateEmail checks if an email address is valid
|
||||
func ValidateEmail(email string) bool {
|
||||
return EmailRegex.MatchString(email)
|
||||
}
|
||||
|
||||
// ValidateDomain checks if a domain name is valid
|
||||
func ValidateDomain(domain string) bool {
|
||||
return DomainRegex.MatchString(domain)
|
||||
}
|
||||
|
||||
// ValidateURL checks if a URL is valid (http/https)
|
||||
func ValidateURL(url string) bool {
|
||||
return URLRegex.MatchString(url)
|
||||
}
|
||||
|
||||
// ValidateJWT checks if a token has valid JWT format
|
||||
func ValidateJWT(token string) bool {
|
||||
return JWTRegex.MatchString(token)
|
||||
}
|
||||
|
||||
// ExtractBearerToken extracts the token from a Bearer authorization header
|
||||
func ExtractBearerToken(authHeader string) (string, bool) {
|
||||
matches := BearerTokenRegex.FindStringSubmatch(authHeader)
|
||||
if len(matches) == 2 {
|
||||
return matches[1], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ValidateClientID checks if a client ID has valid format
|
||||
func ValidateClientID(clientID string) bool {
|
||||
return ClientIDRegex.MatchString(clientID)
|
||||
}
|
||||
|
||||
// ValidateScopes checks if scopes string has valid format
|
||||
func ValidateScopes(scopes string) bool {
|
||||
return ScopeRegex.MatchString(scopes)
|
||||
}
|
||||
|
||||
// ValidateSessionID checks if a session ID has valid format
|
||||
func ValidateSessionID(sessionID string) bool {
|
||||
return SessionIDRegex.MatchString(sessionID)
|
||||
}
|
||||
|
||||
// ValidateCSRFToken checks if a CSRF token has valid format
|
||||
func ValidateCSRFToken(token string) bool {
|
||||
return CSRFTokenRegex.MatchString(token)
|
||||
}
|
||||
|
||||
// ValidateNonce checks if a nonce has valid format
|
||||
func ValidateNonce(nonce string) bool {
|
||||
return NonceRegex.MatchString(nonce)
|
||||
}
|
||||
|
||||
// ValidateCodeVerifier checks if a PKCE code verifier has valid format
|
||||
func ValidateCodeVerifier(verifier string) bool {
|
||||
return CodeVerifierRegex.MatchString(verifier)
|
||||
}
|
||||
|
||||
// ValidateAuthCode checks if an authorization code has valid format
|
||||
func ValidateAuthCode(code string) bool {
|
||||
return AuthCodeRegex.MatchString(code)
|
||||
}
|
||||
|
||||
// ValidateRedirectURI checks if a redirect URI is valid
|
||||
func ValidateRedirectURI(uri string) bool {
|
||||
return RedirectURIRegex.MatchString(uri)
|
||||
}
|
||||
|
||||
// IsBotUserAgent checks if a User-Agent suggests an automated client
|
||||
func IsBotUserAgent(userAgent string) bool {
|
||||
return BotUserAgentRegex.MatchString(userAgent)
|
||||
}
|
||||
|
||||
// ValidateIPv4 checks if an IP address is valid IPv4
|
||||
func ValidateIPv4(ip string) bool {
|
||||
return IPv4Regex.MatchString(ip)
|
||||
}
|
||||
|
||||
// ValidateTenantID checks if a tenant ID has valid UUID format
|
||||
func ValidateTenantID(tenantID string) bool {
|
||||
return TenantIDRegex.MatchString(tenantID)
|
||||
}
|
||||
|
||||
// GetGlobalCache returns the global regex cache instance
|
||||
func GetGlobalCache() *RegexCache {
|
||||
return globalCache
|
||||
}
|
||||
|
||||
// CompilePattern compiles a pattern using the global cache
|
||||
func CompilePattern(pattern string) (*regexp.Regexp, error) {
|
||||
return globalCache.Get(pattern)
|
||||
}
|
||||
|
||||
// MustCompilePattern compiles a pattern using the global cache, panicking on error
|
||||
func MustCompilePattern(pattern string) *regexp.Regexp {
|
||||
return globalCache.MustGet(pattern)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package patterns
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegexCache_Get(t *testing.T) {
|
||||
cache := NewRegexCache()
|
||||
|
||||
pattern := `^test\d+$`
|
||||
|
||||
// First call should compile and cache
|
||||
regex1, err := cache.Get(pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get regex: %v", err)
|
||||
}
|
||||
|
||||
// Second call should return cached version
|
||||
regex2, err := cache.Get(pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get cached regex: %v", err)
|
||||
}
|
||||
|
||||
// Should be the same instance
|
||||
if regex1 != regex2 {
|
||||
t.Error("Expected same regex instance from cache")
|
||||
}
|
||||
|
||||
// Test the regex works
|
||||
if !regex1.MatchString("test123") {
|
||||
t.Error("Regex should match 'test123'")
|
||||
}
|
||||
|
||||
if regex1.MatchString("test") {
|
||||
t.Error("Regex should not match 'test'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegexCache_ConcurrentAccess(t *testing.T) {
|
||||
cache := NewRegexCache()
|
||||
pattern := `^concurrent\d+$`
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]*regexp.Regexp, 10)
|
||||
errors := make([]error, 10)
|
||||
|
||||
// Launch multiple goroutines to access the same pattern
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
regex, err := cache.Get(pattern)
|
||||
results[index] = regex
|
||||
errors[index] = err
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Check all succeeded
|
||||
for i, err := range errors {
|
||||
if err != nil {
|
||||
t.Fatalf("Goroutine %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// All should return the same instance
|
||||
first := results[0]
|
||||
for i, regex := range results[1:] {
|
||||
if regex != first {
|
||||
t.Errorf("Goroutine %d got different regex instance", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegexCache_InvalidPattern(t *testing.T) {
|
||||
cache := NewRegexCache()
|
||||
|
||||
_, err := cache.Get(`[invalid`)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid regex pattern")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegexCache_Precompile(t *testing.T) {
|
||||
cache := NewRegexCache()
|
||||
|
||||
patterns := []string{
|
||||
`^test1$`,
|
||||
`^test2$`,
|
||||
`^test3$`,
|
||||
}
|
||||
|
||||
err := cache.Precompile(patterns)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to precompile patterns: %v", err)
|
||||
}
|
||||
|
||||
if cache.Size() != 3 {
|
||||
t.Errorf("Expected cache size 3, got %d", cache.Size())
|
||||
}
|
||||
|
||||
// Should be able to get precompiled patterns without error
|
||||
for _, pattern := range patterns {
|
||||
_, err := cache.Get(pattern)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get precompiled pattern %s: %v", pattern, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationFunctions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
function func(string) bool
|
||||
valid []string
|
||||
invalid []string
|
||||
}{
|
||||
{
|
||||
name: "ValidateEmail",
|
||||
function: ValidateEmail,
|
||||
valid: []string{"[email protected]", "[email protected]", "[email protected]"},
|
||||
invalid: []string{"invalid-email", "@domain.com", "user@", ""},
|
||||
},
|
||||
{
|
||||
name: "ValidateDomain",
|
||||
function: ValidateDomain,
|
||||
valid: []string{"example.com", "sub.domain.org", "test.co.uk"},
|
||||
invalid: []string{"", "invalid..domain", ".example.com", "domain."},
|
||||
},
|
||||
{
|
||||
name: "ValidateJWT",
|
||||
function: ValidateJWT,
|
||||
valid: []string{"eyJ0.eyJ1.sig", "a.b.c"},
|
||||
invalid: []string{"invalid", "a.b", "a.b.c.d", ""},
|
||||
},
|
||||
{
|
||||
name: "ValidateClientID",
|
||||
function: ValidateClientID,
|
||||
valid: []string{"client123", "my-client_id", "123.456"},
|
||||
invalid: []string{"", "client with spaces", "client@invalid"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
for _, valid := range tt.valid {
|
||||
if !tt.function(valid) {
|
||||
t.Errorf("%s should be valid: %s", tt.name, valid)
|
||||
}
|
||||
}
|
||||
|
||||
for _, invalid := range tt.invalid {
|
||||
if tt.function(invalid) {
|
||||
t.Errorf("%s should be invalid: %s", tt.name, invalid)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractBearerToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
header string
|
||||
expected string
|
||||
valid bool
|
||||
}{
|
||||
{"Bearer abc123", "abc123", true},
|
||||
{"Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9", "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9", true},
|
||||
{"bearer token123", "", false}, // case sensitive
|
||||
{"Basic abc123", "", false},
|
||||
{"Bearer", "", false},
|
||||
{"", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
token, valid := ExtractBearerToken(tt.header)
|
||||
if valid != tt.valid {
|
||||
t.Errorf("ExtractBearerToken(%q) valid = %v, want %v", tt.header, valid, tt.valid)
|
||||
}
|
||||
if token != tt.expected {
|
||||
t.Errorf("ExtractBearerToken(%q) token = %q, want %q", tt.header, token, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRegexCache_Get(b *testing.B) {
|
||||
cache := NewRegexCache()
|
||||
pattern := `^benchmark\d+$`
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
_, err := cache.Get(pattern)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkRegexCache_Validation(b *testing.B) {
|
||||
email := "[email protected]"
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
ValidateEmail(email)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkRegex_DirectCompile(b *testing.B) {
|
||||
pattern := `^benchmark\d+$`
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// Package security provides security-related middleware and utilities
|
||||
package security
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SecurityHeadersConfig configures security headers
|
||||
type SecurityHeadersConfig struct {
|
||||
// Content Security Policy
|
||||
ContentSecurityPolicy string
|
||||
|
||||
// HSTS settings
|
||||
StrictTransportSecurity string
|
||||
StrictTransportSecurityMaxAge int // seconds
|
||||
StrictTransportSecuritySubdomains bool
|
||||
StrictTransportSecurityPreload bool
|
||||
|
||||
// Frame options
|
||||
FrameOptions string // DENY, SAMEORIGIN, or ALLOW-FROM uri
|
||||
|
||||
// Content type options
|
||||
ContentTypeOptions string // nosniff
|
||||
|
||||
// XSS protection
|
||||
XSSProtection string // 1; mode=block
|
||||
|
||||
// Referrer policy
|
||||
ReferrerPolicy string
|
||||
|
||||
// Permissions policy
|
||||
PermissionsPolicy string
|
||||
|
||||
// Cross-origin settings
|
||||
CrossOriginEmbedderPolicy string
|
||||
CrossOriginOpenerPolicy string
|
||||
CrossOriginResourcePolicy string
|
||||
|
||||
// CORS settings
|
||||
CORSEnabled bool
|
||||
CORSAllowedOrigins []string
|
||||
CORSAllowedMethods []string
|
||||
CORSAllowedHeaders []string
|
||||
CORSAllowCredentials bool
|
||||
CORSMaxAge int // seconds
|
||||
|
||||
// Custom headers
|
||||
CustomHeaders map[string]string
|
||||
|
||||
// Security features
|
||||
DisableServerHeader bool
|
||||
DisablePoweredByHeader bool
|
||||
|
||||
// Development mode (less strict for local development)
|
||||
DevelopmentMode bool
|
||||
}
|
||||
|
||||
// DefaultSecurityConfig returns a secure default configuration
|
||||
func DefaultSecurityConfig() *SecurityHeadersConfig {
|
||||
return &SecurityHeadersConfig{
|
||||
ContentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';",
|
||||
|
||||
StrictTransportSecurityMaxAge: 31536000, // 1 year
|
||||
StrictTransportSecuritySubdomains: true,
|
||||
StrictTransportSecurityPreload: true,
|
||||
|
||||
FrameOptions: "DENY",
|
||||
ContentTypeOptions: "nosniff",
|
||||
XSSProtection: "1; mode=block",
|
||||
ReferrerPolicy: "strict-origin-when-cross-origin",
|
||||
|
||||
PermissionsPolicy: "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), speaker=()",
|
||||
|
||||
CrossOriginEmbedderPolicy: "require-corp",
|
||||
CrossOriginOpenerPolicy: "same-origin",
|
||||
CrossOriginResourcePolicy: "same-origin",
|
||||
|
||||
CORSEnabled: false,
|
||||
CORSAllowedMethods: []string{"GET", "POST", "OPTIONS"},
|
||||
CORSAllowedHeaders: []string{"Authorization", "Content-Type", "X-Requested-With"},
|
||||
CORSMaxAge: 86400, // 24 hours
|
||||
|
||||
DisableServerHeader: true,
|
||||
DisablePoweredByHeader: true,
|
||||
|
||||
DevelopmentMode: false,
|
||||
}
|
||||
}
|
||||
|
||||
// DevelopmentSecurityConfig returns a configuration suitable for development
|
||||
func DevelopmentSecurityConfig() *SecurityHeadersConfig {
|
||||
config := DefaultSecurityConfig()
|
||||
|
||||
// Relax CSP for development
|
||||
config.ContentSecurityPolicy = "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data: https: http:; connect-src 'self' ws: wss:;"
|
||||
|
||||
// Allow framing for development tools
|
||||
config.FrameOptions = "SAMEORIGIN"
|
||||
|
||||
// Enable CORS for local development
|
||||
config.CORSEnabled = true
|
||||
config.CORSAllowedOrigins = []string{"http://localhost:*", "http://127.0.0.1:*"}
|
||||
config.CORSAllowCredentials = true
|
||||
|
||||
// Relax cross-origin policies
|
||||
config.CrossOriginEmbedderPolicy = ""
|
||||
config.CrossOriginOpenerPolicy = "unsafe-none"
|
||||
config.CrossOriginResourcePolicy = "cross-origin"
|
||||
|
||||
config.DevelopmentMode = true
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// SecurityHeadersMiddleware applies security headers to HTTP responses
|
||||
type SecurityHeadersMiddleware struct {
|
||||
config *SecurityHeadersConfig
|
||||
}
|
||||
|
||||
// NewSecurityHeadersMiddleware creates a new security headers middleware
|
||||
func NewSecurityHeadersMiddleware(config *SecurityHeadersConfig) *SecurityHeadersMiddleware {
|
||||
if config == nil {
|
||||
config = DefaultSecurityConfig()
|
||||
}
|
||||
|
||||
return &SecurityHeadersMiddleware{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Apply applies security headers to the response
|
||||
func (m *SecurityHeadersMiddleware) Apply(rw http.ResponseWriter, req *http.Request) {
|
||||
headers := rw.Header()
|
||||
|
||||
// Content Security Policy
|
||||
if m.config.ContentSecurityPolicy != "" {
|
||||
headers.Set("Content-Security-Policy", m.config.ContentSecurityPolicy)
|
||||
}
|
||||
|
||||
// HSTS (only for HTTPS)
|
||||
if req.TLS != nil || req.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
hstsValue := m.buildHSTSHeader()
|
||||
if hstsValue != "" {
|
||||
headers.Set("Strict-Transport-Security", hstsValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Frame options
|
||||
if m.config.FrameOptions != "" {
|
||||
headers.Set("X-Frame-Options", m.config.FrameOptions)
|
||||
}
|
||||
|
||||
// Content type options
|
||||
if m.config.ContentTypeOptions != "" {
|
||||
headers.Set("X-Content-Type-Options", m.config.ContentTypeOptions)
|
||||
}
|
||||
|
||||
// XSS protection
|
||||
if m.config.XSSProtection != "" {
|
||||
headers.Set("X-XSS-Protection", m.config.XSSProtection)
|
||||
}
|
||||
|
||||
// Referrer policy
|
||||
if m.config.ReferrerPolicy != "" {
|
||||
headers.Set("Referrer-Policy", m.config.ReferrerPolicy)
|
||||
}
|
||||
|
||||
// Permissions policy
|
||||
if m.config.PermissionsPolicy != "" {
|
||||
headers.Set("Permissions-Policy", m.config.PermissionsPolicy)
|
||||
}
|
||||
|
||||
// Cross-origin policies
|
||||
if m.config.CrossOriginEmbedderPolicy != "" {
|
||||
headers.Set("Cross-Origin-Embedder-Policy", m.config.CrossOriginEmbedderPolicy)
|
||||
}
|
||||
|
||||
if m.config.CrossOriginOpenerPolicy != "" {
|
||||
headers.Set("Cross-Origin-Opener-Policy", m.config.CrossOriginOpenerPolicy)
|
||||
}
|
||||
|
||||
if m.config.CrossOriginResourcePolicy != "" {
|
||||
headers.Set("Cross-Origin-Resource-Policy", m.config.CrossOriginResourcePolicy)
|
||||
}
|
||||
|
||||
// CORS headers
|
||||
if m.config.CORSEnabled {
|
||||
m.applyCORSHeaders(rw, req)
|
||||
}
|
||||
|
||||
// Custom headers
|
||||
for name, value := range m.config.CustomHeaders {
|
||||
headers.Set(name, value)
|
||||
}
|
||||
|
||||
// Remove server identification headers
|
||||
if m.config.DisableServerHeader {
|
||||
headers.Del("Server")
|
||||
}
|
||||
|
||||
if m.config.DisablePoweredByHeader {
|
||||
headers.Del("X-Powered-By")
|
||||
}
|
||||
|
||||
// Add security timestamp for debugging
|
||||
if m.config.DevelopmentMode {
|
||||
headers.Set("X-Security-Headers-Applied", time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
// buildHSTSHeader constructs the HSTS header value
|
||||
func (m *SecurityHeadersMiddleware) buildHSTSHeader() string {
|
||||
if m.config.StrictTransportSecurityMaxAge <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := []string{
|
||||
"max-age=" + string(rune(m.config.StrictTransportSecurityMaxAge)),
|
||||
}
|
||||
|
||||
if m.config.StrictTransportSecuritySubdomains {
|
||||
parts = append(parts, "includeSubDomains")
|
||||
}
|
||||
|
||||
if m.config.StrictTransportSecurityPreload {
|
||||
parts = append(parts, "preload")
|
||||
}
|
||||
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// applyCORSHeaders applies CORS headers based on the request
|
||||
func (m *SecurityHeadersMiddleware) applyCORSHeaders(rw http.ResponseWriter, req *http.Request) {
|
||||
headers := rw.Header()
|
||||
origin := req.Header.Get("Origin")
|
||||
|
||||
// Check if origin is allowed
|
||||
if origin != "" && m.isOriginAllowed(origin) {
|
||||
headers.Set("Access-Control-Allow-Origin", origin)
|
||||
} else if len(m.config.CORSAllowedOrigins) == 1 && m.config.CORSAllowedOrigins[0] == "*" {
|
||||
headers.Set("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
|
||||
// Set other CORS headers
|
||||
if len(m.config.CORSAllowedMethods) > 0 {
|
||||
headers.Set("Access-Control-Allow-Methods", strings.Join(m.config.CORSAllowedMethods, ", "))
|
||||
}
|
||||
|
||||
if len(m.config.CORSAllowedHeaders) > 0 {
|
||||
headers.Set("Access-Control-Allow-Headers", strings.Join(m.config.CORSAllowedHeaders, ", "))
|
||||
}
|
||||
|
||||
if m.config.CORSAllowCredentials {
|
||||
headers.Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
|
||||
if m.config.CORSMaxAge > 0 {
|
||||
headers.Set("Access-Control-Max-Age", string(rune(m.config.CORSMaxAge)))
|
||||
}
|
||||
|
||||
// Handle preflight requests
|
||||
if req.Method == "OPTIONS" {
|
||||
headers.Set("Access-Control-Allow-Methods", strings.Join(m.config.CORSAllowedMethods, ", "))
|
||||
headers.Set("Access-Control-Allow-Headers", strings.Join(m.config.CORSAllowedHeaders, ", "))
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// isOriginAllowed checks if the origin is in the allowed list
|
||||
func (m *SecurityHeadersMiddleware) isOriginAllowed(origin string) bool {
|
||||
for _, allowed := range m.config.CORSAllowedOrigins {
|
||||
if m.matchOrigin(origin, allowed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchOrigin checks if an origin matches an allowed pattern
|
||||
func (m *SecurityHeadersMiddleware) matchOrigin(origin, pattern string) bool {
|
||||
// Exact match
|
||||
if origin == pattern {
|
||||
return true
|
||||
}
|
||||
|
||||
// Wildcard subdomain match (e.g., "https://*.example.com")
|
||||
if strings.Contains(pattern, "*") {
|
||||
// Simple wildcard matching for subdomains
|
||||
if strings.HasPrefix(pattern, "https://*.") {
|
||||
domain := strings.TrimPrefix(pattern, "https://*.")
|
||||
if strings.HasSuffix(origin, "."+domain) || origin == "https://"+domain {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(pattern, "http://*.") {
|
||||
domain := strings.TrimPrefix(pattern, "http://*.")
|
||||
if strings.HasSuffix(origin, "."+domain) || origin == "http://"+domain {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Port wildcard match (e.g., "http://localhost:*")
|
||||
if strings.HasSuffix(pattern, ":*") {
|
||||
prefix := strings.TrimSuffix(pattern, ":*")
|
||||
if strings.HasPrefix(origin, prefix+":") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Wrap wraps an HTTP handler with security headers
|
||||
func (m *SecurityHeadersMiddleware) Wrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
m.Apply(rw, req)
|
||||
next.ServeHTTP(rw, req)
|
||||
})
|
||||
}
|
||||
|
||||
// SecurityHeadersHandler is a convenience function that creates and applies security headers
|
||||
func SecurityHeadersHandler(config *SecurityHeadersConfig) func(http.ResponseWriter, *http.Request) {
|
||||
middleware := NewSecurityHeadersMiddleware(config)
|
||||
return middleware.Apply
|
||||
}
|
||||
|
||||
// Common security header presets
|
||||
|
||||
// StrictSecurityConfig returns a very strict security configuration
|
||||
func StrictSecurityConfig() *SecurityHeadersConfig {
|
||||
config := DefaultSecurityConfig()
|
||||
|
||||
// Very strict CSP
|
||||
config.ContentSecurityPolicy = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
|
||||
|
||||
// Stricter frame options
|
||||
config.FrameOptions = "DENY"
|
||||
|
||||
// Disable CORS entirely
|
||||
config.CORSEnabled = false
|
||||
|
||||
// Very strict cross-origin policies
|
||||
config.CrossOriginEmbedderPolicy = "require-corp"
|
||||
config.CrossOriginOpenerPolicy = "same-origin"
|
||||
config.CrossOriginResourcePolicy = "same-site"
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// APISecurityConfig returns a configuration suitable for APIs
|
||||
func APISecurityConfig() *SecurityHeadersConfig {
|
||||
config := DefaultSecurityConfig()
|
||||
|
||||
// API-friendly CSP
|
||||
config.ContentSecurityPolicy = "default-src 'none'; frame-ancestors 'none';"
|
||||
|
||||
// Enable CORS for APIs
|
||||
config.CORSEnabled = true
|
||||
config.CORSAllowedMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}
|
||||
config.CORSAllowedHeaders = []string{"Authorization", "Content-Type", "X-Requested-With", "X-API-Key"}
|
||||
|
||||
// API-appropriate policies
|
||||
config.CrossOriginResourcePolicy = "cross-origin"
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// ValidateConfig validates the security configuration
|
||||
func (c *SecurityHeadersConfig) Validate() error {
|
||||
// Validate HSTS max age
|
||||
if c.StrictTransportSecurityMaxAge < 0 {
|
||||
c.StrictTransportSecurityMaxAge = 0
|
||||
}
|
||||
|
||||
// Validate CORS max age
|
||||
if c.CORSMaxAge < 0 {
|
||||
c.CORSMaxAge = 0
|
||||
}
|
||||
|
||||
// Validate frame options
|
||||
validFrameOptions := []string{"DENY", "SAMEORIGIN", ""}
|
||||
isValidFrameOption := false
|
||||
for _, valid := range validFrameOptions {
|
||||
if c.FrameOptions == valid || strings.HasPrefix(c.FrameOptions, "ALLOW-FROM ") {
|
||||
isValidFrameOption = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isValidFrameOption {
|
||||
c.FrameOptions = "DENY"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyToResponseWriter is a helper function to quickly apply security headers
|
||||
func ApplySecurityHeaders(rw http.ResponseWriter, req *http.Request, config *SecurityHeadersConfig) {
|
||||
middleware := NewSecurityHeadersMiddleware(config)
|
||||
middleware.Apply(rw, req)
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultSecurityConfig(t *testing.T) {
|
||||
config := DefaultSecurityConfig()
|
||||
|
||||
if config.ContentSecurityPolicy == "" {
|
||||
t.Error("Expected default CSP to be set")
|
||||
}
|
||||
|
||||
if config.FrameOptions != "DENY" {
|
||||
t.Errorf("Expected frame options to be DENY, got %s", config.FrameOptions)
|
||||
}
|
||||
|
||||
if !config.DisableServerHeader {
|
||||
t.Error("Expected server header to be disabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersMiddleware_Apply(t *testing.T) {
|
||||
config := DefaultSecurityConfig()
|
||||
middleware := NewSecurityHeadersMiddleware(config)
|
||||
|
||||
// Create a mock request (HTTPS)
|
||||
req := httptest.NewRequest("GET", "https://example.com/test", nil)
|
||||
req.TLS = &tls.ConnectionState{} // Mock TLS
|
||||
|
||||
// Create a response recorder
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
// Apply security headers
|
||||
middleware.Apply(rr, req)
|
||||
|
||||
headers := rr.Header()
|
||||
|
||||
// Check that security headers are set
|
||||
if headers.Get("Content-Security-Policy") == "" {
|
||||
t.Error("Expected CSP header to be set")
|
||||
}
|
||||
|
||||
if headers.Get("X-Frame-Options") != "DENY" {
|
||||
t.Errorf("Expected X-Frame-Options to be DENY, got %s", headers.Get("X-Frame-Options"))
|
||||
}
|
||||
|
||||
if headers.Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Errorf("Expected X-Content-Type-Options to be nosniff, got %s", headers.Get("X-Content-Type-Options"))
|
||||
}
|
||||
|
||||
if headers.Get("X-XSS-Protection") != "1; mode=block" {
|
||||
t.Errorf("Expected X-XSS-Protection to be '1; mode=block', got %s", headers.Get("X-XSS-Protection"))
|
||||
}
|
||||
|
||||
// Check HSTS for HTTPS requests
|
||||
hsts := headers.Get("Strict-Transport-Security")
|
||||
if hsts == "" {
|
||||
t.Error("Expected HSTS header for HTTPS request")
|
||||
}
|
||||
|
||||
if !strings.Contains(hsts, "max-age=") {
|
||||
t.Error("Expected HSTS header to contain max-age")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersMiddleware_HTTPSOnly(t *testing.T) {
|
||||
config := DefaultSecurityConfig()
|
||||
middleware := NewSecurityHeadersMiddleware(config)
|
||||
|
||||
// Test HTTP request (no HSTS)
|
||||
req := httptest.NewRequest("GET", "http://example.com/test", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
middleware.Apply(rr, req)
|
||||
|
||||
if rr.Header().Get("Strict-Transport-Security") != "" {
|
||||
t.Error("Expected no HSTS header for HTTP request")
|
||||
}
|
||||
|
||||
// Test HTTPS request (with HSTS)
|
||||
req = httptest.NewRequest("GET", "https://example.com/test", nil)
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
rr = httptest.NewRecorder()
|
||||
|
||||
middleware.Apply(rr, req)
|
||||
|
||||
if rr.Header().Get("Strict-Transport-Security") == "" {
|
||||
t.Error("Expected HSTS header for HTTPS request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSHeaders(t *testing.T) {
|
||||
config := DefaultSecurityConfig()
|
||||
config.CORSEnabled = true
|
||||
config.CORSAllowedOrigins = []string{"https://example.com", "https://*.test.com"}
|
||||
config.CORSAllowCredentials = true
|
||||
|
||||
middleware := NewSecurityHeadersMiddleware(config)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
origin string
|
||||
expectedOrigin string
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
origin: "https://example.com",
|
||||
expectedOrigin: "https://example.com",
|
||||
},
|
||||
{
|
||||
name: "wildcard subdomain match",
|
||||
origin: "https://api.test.com",
|
||||
expectedOrigin: "https://api.test.com",
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
origin: "https://malicious.com",
|
||||
expectedOrigin: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "https://example.com/test", nil)
|
||||
if tt.origin != "" {
|
||||
req.Header.Set("Origin", tt.origin)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
middleware.Apply(rr, req)
|
||||
|
||||
actualOrigin := rr.Header().Get("Access-Control-Allow-Origin")
|
||||
if actualOrigin != tt.expectedOrigin {
|
||||
t.Errorf("Expected origin %s, got %s", tt.expectedOrigin, actualOrigin)
|
||||
}
|
||||
|
||||
if tt.expectedOrigin != "" {
|
||||
// Should have credentials header
|
||||
if rr.Header().Get("Access-Control-Allow-Credentials") != "true" {
|
||||
t.Error("Expected credentials header for allowed origin")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSPreflight(t *testing.T) {
|
||||
config := DefaultSecurityConfig()
|
||||
config.CORSEnabled = true
|
||||
config.CORSAllowedOrigins = []string{"*"}
|
||||
config.CORSAllowedMethods = []string{"GET", "POST", "OPTIONS"}
|
||||
|
||||
middleware := NewSecurityHeadersMiddleware(config)
|
||||
|
||||
req := httptest.NewRequest("OPTIONS", "https://example.com/test", nil)
|
||||
req.Header.Set("Origin", "https://other.com")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
middleware.Apply(rr, req)
|
||||
|
||||
if rr.Header().Get("Access-Control-Allow-Origin") != "*" {
|
||||
t.Error("Expected wildcard origin for preflight request")
|
||||
}
|
||||
|
||||
if rr.Header().Get("Access-Control-Allow-Methods") == "" {
|
||||
t.Error("Expected methods header for preflight request")
|
||||
}
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for preflight, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginMatching(t *testing.T) {
|
||||
config := &SecurityHeadersConfig{
|
||||
CORSEnabled: true,
|
||||
CORSAllowedOrigins: []string{
|
||||
"https://example.com",
|
||||
"https://*.example.com",
|
||||
"http://localhost:*",
|
||||
},
|
||||
}
|
||||
|
||||
middleware := NewSecurityHeadersMiddleware(config)
|
||||
|
||||
tests := []struct {
|
||||
origin string
|
||||
expected bool
|
||||
}{
|
||||
{"https://example.com", true},
|
||||
{"https://api.example.com", true},
|
||||
{"https://sub.api.example.com", true},
|
||||
{"http://localhost:3000", true},
|
||||
{"http://localhost:8080", true},
|
||||
{"https://malicious.com", false},
|
||||
{"http://example.com", false}, // Different scheme
|
||||
{"https://example.com.evil.com", false}, // Domain suffix attack
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.origin, func(t *testing.T) {
|
||||
result := middleware.isOriginAllowed(tt.origin)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Origin %s: expected %v, got %v", tt.origin, tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevelopmentMode(t *testing.T) {
|
||||
config := DevelopmentSecurityConfig()
|
||||
|
||||
if !config.DevelopmentMode {
|
||||
t.Error("Expected development mode to be enabled")
|
||||
}
|
||||
|
||||
if !config.CORSEnabled {
|
||||
t.Error("Expected CORS to be enabled in development mode")
|
||||
}
|
||||
|
||||
if config.FrameOptions != "SAMEORIGIN" {
|
||||
t.Errorf("Expected frame options to be SAMEORIGIN in dev mode, got %s", config.FrameOptions)
|
||||
}
|
||||
|
||||
// Should be less strict CSP
|
||||
if strings.Contains(config.ContentSecurityPolicy, "'none'") {
|
||||
t.Error("Expected less strict CSP in development mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictSecurityConfig(t *testing.T) {
|
||||
config := StrictSecurityConfig()
|
||||
|
||||
if !strings.Contains(config.ContentSecurityPolicy, "'none'") {
|
||||
t.Error("Expected very strict CSP with 'none' defaults")
|
||||
}
|
||||
|
||||
if config.CORSEnabled {
|
||||
t.Error("Expected CORS to be disabled in strict mode")
|
||||
}
|
||||
|
||||
if config.FrameOptions != "DENY" {
|
||||
t.Error("Expected frame options to be DENY in strict mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPISecurityConfig(t *testing.T) {
|
||||
config := APISecurityConfig()
|
||||
|
||||
if !config.CORSEnabled {
|
||||
t.Error("Expected CORS to be enabled for API config")
|
||||
}
|
||||
|
||||
methods := config.CORSAllowedMethods
|
||||
expectedMethods := []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}
|
||||
|
||||
for _, method := range expectedMethods {
|
||||
found := false
|
||||
for _, allowed := range methods {
|
||||
if allowed == method {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected method %s to be allowed in API config", method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareWrap(t *testing.T) {
|
||||
config := DefaultSecurityConfig()
|
||||
middleware := NewSecurityHeadersMiddleware(config)
|
||||
|
||||
// Create a simple handler
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
|
||||
// Wrap with security middleware
|
||||
wrappedHandler := middleware.Wrap(handler)
|
||||
|
||||
req := httptest.NewRequest("GET", "https://example.com/test", nil)
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(rr, req)
|
||||
|
||||
// Check response
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
if rr.Body.String() != "OK" {
|
||||
t.Errorf("Expected body 'OK', got %s", rr.Body.String())
|
||||
}
|
||||
|
||||
// Check security headers were applied
|
||||
if rr.Header().Get("X-Frame-Options") == "" {
|
||||
t.Error("Expected security headers to be applied by wrapper")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidation(t *testing.T) {
|
||||
config := &SecurityHeadersConfig{
|
||||
StrictTransportSecurityMaxAge: -1,
|
||||
CORSMaxAge: -1,
|
||||
FrameOptions: "INVALID",
|
||||
}
|
||||
|
||||
err := config.Validate()
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected validation error: %v", err)
|
||||
}
|
||||
|
||||
// Should fix invalid values
|
||||
if config.StrictTransportSecurityMaxAge != 0 {
|
||||
t.Error("Expected negative HSTS max age to be reset to 0")
|
||||
}
|
||||
|
||||
if config.CORSMaxAge != 0 {
|
||||
t.Error("Expected negative CORS max age to be reset to 0")
|
||||
}
|
||||
|
||||
if config.FrameOptions != "DENY" {
|
||||
t.Error("Expected invalid frame options to be reset to DENY")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSecurityHeadersApply(b *testing.B) {
|
||||
config := DefaultSecurityConfig()
|
||||
middleware := NewSecurityHeadersMiddleware(config)
|
||||
|
||||
req := httptest.NewRequest("GET", "https://example.com/test", nil)
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
rr := httptest.NewRecorder()
|
||||
middleware.Apply(rr, req)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user