mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-06 22:49:43 +00:00
1b49e133da
* Fix bug affecting Azure OIDC authentication ( and most likely others ) * Fixes issue #51 * Ensure that appended roles are unique. Update the documentation. * Improvements targetting possible memory usage spikes. * Additional fixes and cleanup * Refactoring code to fix the issues identified by the users. * Modernize run * Fieldalignment * Multiple changes to improve performance and reduce complexity. - Optimise the errors and recovery. - Deduplicate code in metadata cache. - Remove unused performance monitoring code. - Simplify session management and settings handling. * Fix claims issue. * Add ability to overwrite the default scopes in the settings file * Well.. that escalated quickly. Completely forgot that Traefik uses outdated Yaegi and requires compatibility with 1.20 ( pre-generic Go code ). * Bugfix #51: Ensures that user provided scopes overrides work. * fixup! Bugfix #51: Ensures that user provided scopes overrides work. * fixup! fixup! Bugfix #51: Ensures that user provided scopes overrides work. * Abstract the provider logic into a separate package. * Additional micro fixes and cleanups. * Simplify all the things. * fixup! Simplify all the things. * fixup! fixup! Simplify all the things. * fixup! fixup! fixup! Simplify all the things. * fixup! fixup! fixup! fixup! Simplify all the things. * ... * Cleanup tests. * fixup! Cleanup tests. * fixup! fixup! fixup! Cleanup tests. * fixup! fixup! fixup! fixup! Cleanup tests. * fixup! fixup! fixup! fixup! fixup! Cleanup tests. * Issue #53: Fix CSRF token handling in reverse proxy 1. ✅ HTTPS Detection Fixed (session.go:723) - Now uses X-Forwarded-Proto header instead of r.URL.Scheme - Properly detects HTTPS in reverse proxy environments 2. ✅ SameSite Cookie Attribute Fixed - Removed automatic SameSiteStrictMode for HTTPS (would break OAuth) - Keeps SameSiteLaxMode to allow OAuth callbacks from external domains - Only uses Strict for AJAX requests which don't involve OAuth redirects 3. ✅ Cookie Domain Handling Fixed - Now respects X-Forwarded-Host header for cookie domain - Ensures cookies are set for the public domain, not internal proxy domain 4. ✅ EnhanceSessionSecurity Properly Integrated - Function is now actually called during session save - Applies security enhancements without breaking OAuth flow Why Issue #53 Failed Before: 1. Cookies were not marked Secure in HTTPS environments (browser wouldn't send them back) 2. If they had been Secure with SameSite=Strict, Azure callbacks would still fail 3. Cookie domain might have been wrong (internal vs public domain) Why It Works Now: 1. Cookies are properly marked Secure for HTTPS 2. Uses SameSite=Lax to allow OAuth provider callbacks 3. Cookie domain uses public domain from X-Forwarded-Host 4. CSRF token persists through the entire OAuth flow * Next set of enhancements together with memory usage improvements. * Memory leak fixes and optimisations. * CSRF and Cookie Domain fixes * fixup! CSRF and Cookie Domain fixes * Metadata cache leak fix + profiling * fixup! Metadata cache leak fix + profiling * Memory leaks hunting, part 1337. * Further pursue of perfection. * fixup! Further pursue of perfection. * fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection. * Clear race conditions * fixup! Clear race conditions * Weekend fun with memory leaks * Splitting code into multiple files with reasonable testing coverage. ``` ok github.com/lukaszraczylo/traefikoidc 117.017s coverage: 72.6% of statements ok github.com/lukaszraczylo/traefikoidc/auth 0.505s coverage: 87.1% of statements ok github.com/lukaszraczylo/traefikoidc/circuit_breaker 0.283s coverage: 99.0% of statements github.com/lukaszraczylo/traefikoidc/config coverage: 0.0% of statements ok github.com/lukaszraczylo/traefikoidc/handlers 0.349s coverage: 98.2% of statements ok github.com/lukaszraczylo/traefikoidc/internal/providers (cached) coverage: 94.3% of statements ok github.com/lukaszraczylo/traefikoidc/middleware 0.808s coverage: 78.0% of statements ok github.com/lukaszraczylo/traefikoidc/recovery 0.653s coverage: 100.0% of statements ok github.com/lukaszraczylo/traefikoidc/session/chunking (cached) coverage: 87.8% of statements ok github.com/lukaszraczylo/traefikoidc/session/core (cached) coverage: 85.6% of statements ok github.com/lukaszraczylo/traefikoidc/session/crypto (cached) coverage: 81.8% of statements ok github.com/lukaszraczylo/traefikoidc/session/storage (cached) coverage: 93.5% of statements ok github.com/lukaszraczylo/traefikoidc/session/validators (cached) coverage: 98.8% of statements ```` * fixup! Splitting code into multiple files with reasonable testing coverage. * fixup! fixup! Splitting code into multiple files with reasonable testing coverage. * Weekend fun with further optimisations. * fixup! Weekend fun with further optimisations. * fixup! fixup! Weekend fun with further optimisations. * fixup! fixup! fixup! Weekend fun with further optimisations. * fixup! fixup! fixup! fixup! Weekend fun with further optimisations. * fixup! fixup! fixup! fixup! fixup! Weekend fun with further optimisations. * Pre-release cleanup. * Enhance test coverage. * fixup! Enhance test coverage. * fixup! fixup! Enhance test coverage. * fixup! fixup! fixup! Enhance test coverage.
301 lines
7.7 KiB
Go
301 lines
7.7 KiB
Go
// Package validators provides validation functionality for session data
|
|
package validators
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
maxBrowserCookieSize = 3500
|
|
maxCookieSize = 1200
|
|
)
|
|
|
|
// SessionValidator provides validation operations for session data
|
|
type SessionValidator struct{}
|
|
|
|
// NewSessionValidator creates a new session validator
|
|
func NewSessionValidator() *SessionValidator {
|
|
return &SessionValidator{}
|
|
}
|
|
|
|
// ValidateChunkSize checks if a chunk will fit within browser cookie limits.
|
|
// It estimates the encoded size including cookie overhead and headers
|
|
// to ensure the chunk won't exceed browser-imposed cookie size limits.
|
|
func (sv *SessionValidator) ValidateChunkSize(chunkData string) bool {
|
|
estimatedEncodedSize := len(chunkData) + (len(chunkData)*50)/100
|
|
return estimatedEncodedSize <= maxBrowserCookieSize
|
|
}
|
|
|
|
// IsCorruptionMarker detects if data contains known corruption indicators.
|
|
// It checks for specific corruption markers and invalid characters
|
|
// that indicate the data has been tampered with or corrupted.
|
|
func (sv *SessionValidator) IsCorruptionMarker(data string) bool {
|
|
if data == "" {
|
|
return false
|
|
}
|
|
|
|
corruptionMarkers := []string{
|
|
"__CORRUPTION_MARKER_TEST__",
|
|
"__INVALID_BASE64_DATA__",
|
|
"__CORRUPTED_CHUNK_DATA__",
|
|
"!@#$%^&*()",
|
|
"<<<CORRUPTED>>>",
|
|
}
|
|
|
|
for _, marker := range corruptionMarkers {
|
|
if data == marker {
|
|
return true
|
|
}
|
|
}
|
|
|
|
if len(data) > 10 {
|
|
invalidChars := "!@#$%^&*(){}[]|\\:;\"'<>?,`~"
|
|
for _, char := range invalidChars {
|
|
if strings.ContainsRune(data, char) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// ValidateTokenFormat validates that a token has the correct JWT format
|
|
func (sv *SessionValidator) ValidateTokenFormat(token, tokenType string) error {
|
|
if token == "" {
|
|
return nil // Empty token is not an error
|
|
}
|
|
|
|
// JWT tokens should have exactly 3 parts separated by dots
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
return &ValidationError{
|
|
Type: tokenType,
|
|
Reason: "invalid JWT format",
|
|
Details: "token must have exactly 3 parts separated by dots",
|
|
}
|
|
}
|
|
|
|
// Each part should be non-empty
|
|
for i, part := range parts {
|
|
if part == "" {
|
|
return &ValidationError{
|
|
Type: tokenType,
|
|
Reason: "empty token part",
|
|
Details: strings.Join([]string{"token part", string(rune(i + 1)), "is empty"}, " "),
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateSessionIntegrity performs comprehensive validation of session data integrity
|
|
func (sv *SessionValidator) ValidateSessionIntegrity(sessionData SessionData) error {
|
|
if sessionData == nil {
|
|
return &ValidationError{
|
|
Type: "session",
|
|
Reason: "nil session data",
|
|
Details: "session data cannot be nil",
|
|
}
|
|
}
|
|
|
|
// Check authentication state consistency
|
|
authenticated := sessionData.GetAuthenticated()
|
|
email := sessionData.GetEmail()
|
|
|
|
if authenticated && email == "" {
|
|
return &ValidationError{
|
|
Type: "session",
|
|
Reason: "authentication inconsistency",
|
|
Details: "session is authenticated but has no email",
|
|
}
|
|
}
|
|
|
|
// Validate token formats if present
|
|
if accessToken := sessionData.GetAccessToken(); accessToken != "" {
|
|
if err := sv.ValidateTokenFormat(accessToken, "access"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if idToken := sessionData.GetIDToken(); idToken != "" {
|
|
if err := sv.ValidateTokenFormat(idToken, "id"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if refreshToken := sessionData.GetRefreshToken(); refreshToken != "" {
|
|
// Refresh tokens don't have to be JWTs, so we do basic validation
|
|
if len(refreshToken) == 0 {
|
|
return &ValidationError{
|
|
Type: "refresh",
|
|
Reason: "empty refresh token",
|
|
Details: "refresh token cannot be empty if set",
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateSessionTiming validates session timing and expiration
|
|
func (sv *SessionValidator) ValidateSessionTiming(sessionData SessionData, maxAge time.Duration) error {
|
|
if sessionData == nil {
|
|
return &ValidationError{
|
|
Type: "session",
|
|
Reason: "nil session data",
|
|
Details: "session data cannot be nil",
|
|
}
|
|
}
|
|
|
|
// Check refresh token timing
|
|
refreshTokenIssuedAt := sessionData.GetRefreshTokenIssuedAt()
|
|
if !refreshTokenIssuedAt.IsZero() {
|
|
age := time.Since(refreshTokenIssuedAt)
|
|
if age > maxAge {
|
|
return &ValidationError{
|
|
Type: "timing",
|
|
Reason: "refresh token expired",
|
|
Details: strings.Join([]string{"refresh token age", age.String(), "exceeds max age", maxAge.String()}, " "),
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateEmailDomain validates that an email belongs to an allowed domain
|
|
func (sv *SessionValidator) ValidateEmailDomain(email string, allowedDomains map[string]struct{}) error {
|
|
if email == "" {
|
|
return &ValidationError{
|
|
Type: "email",
|
|
Reason: "empty email",
|
|
Details: "email cannot be empty",
|
|
}
|
|
}
|
|
|
|
if len(allowedDomains) == 0 {
|
|
return nil // No domain restrictions
|
|
}
|
|
|
|
parts := strings.Split(email, "@")
|
|
if len(parts) != 2 {
|
|
return &ValidationError{
|
|
Type: "email",
|
|
Reason: "invalid email format",
|
|
Details: "email must contain exactly one @ symbol",
|
|
}
|
|
}
|
|
|
|
domain := parts[1]
|
|
if _, allowed := allowedDomains[domain]; !allowed {
|
|
return &ValidationError{
|
|
Type: "email",
|
|
Reason: "domain not allowed",
|
|
Details: strings.Join([]string{"domain", domain, "is not in allowed domains list"}, " "),
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SplitIntoChunks splits a string into chunks that fit within cookie size limits
|
|
func (sv *SessionValidator) SplitIntoChunks(s string, chunkSize int) []string {
|
|
effectiveChunkSize := min(chunkSize, maxCookieSize)
|
|
|
|
var chunks []string
|
|
for len(s) > 0 {
|
|
if len(s) > effectiveChunkSize {
|
|
chunks = append(chunks, s[:effectiveChunkSize])
|
|
s = s[effectiveChunkSize:]
|
|
} else {
|
|
chunks = append(chunks, s)
|
|
break
|
|
}
|
|
}
|
|
return chunks
|
|
}
|
|
|
|
// ValidateChunks validates all chunks in a chunk set
|
|
func (sv *SessionValidator) ValidateChunks(chunks []string) error {
|
|
for i, chunk := range chunks {
|
|
if chunk == "" {
|
|
return &ValidationError{
|
|
Type: "chunk",
|
|
Reason: "empty chunk",
|
|
Details: strings.Join([]string{"chunk", string(rune(i)), "is empty"}, " "),
|
|
}
|
|
}
|
|
|
|
if !sv.ValidateChunkSize(chunk) {
|
|
return &ValidationError{
|
|
Type: "chunk",
|
|
Reason: "chunk too large",
|
|
Details: strings.Join([]string{"chunk", string(rune(i)), "exceeds size limit"}, " "),
|
|
}
|
|
}
|
|
|
|
if sv.IsCorruptionMarker(chunk) {
|
|
return &ValidationError{
|
|
Type: "chunk",
|
|
Reason: "corrupted chunk",
|
|
Details: strings.Join([]string{"chunk", string(rune(i)), "contains corruption markers"}, " "),
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidationError represents a validation error with context
|
|
type ValidationError struct {
|
|
Type string
|
|
Reason string
|
|
Details string
|
|
}
|
|
|
|
// Error implements the error interface
|
|
func (ve *ValidationError) Error() string {
|
|
return strings.Join([]string{ve.Type, "validation error:", ve.Reason, "-", ve.Details}, " ")
|
|
}
|
|
|
|
// SessionData interface for validation operations
|
|
type SessionData interface {
|
|
GetAuthenticated() bool
|
|
GetEmail() string
|
|
GetAccessToken() string
|
|
GetIDToken() string
|
|
GetRefreshToken() string
|
|
GetRefreshTokenIssuedAt() time.Time
|
|
}
|
|
|
|
// Utility functions
|
|
|
|
// min returns the minimum of two integers
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// ValidateChunkSize is a package-level function for backward compatibility
|
|
func ValidateChunkSize(chunkData string) bool {
|
|
sv := &SessionValidator{}
|
|
return sv.ValidateChunkSize(chunkData)
|
|
}
|
|
|
|
// IsCorruptionMarker is a package-level function for backward compatibility
|
|
func IsCorruptionMarker(data string) bool {
|
|
sv := &SessionValidator{}
|
|
return sv.IsCorruptionMarker(data)
|
|
}
|
|
|
|
// SplitIntoChunks is a package-level function for backward compatibility
|
|
func SplitIntoChunks(s string, chunkSize int) []string {
|
|
sv := &SessionValidator{}
|
|
return sv.SplitIntoChunks(s, chunkSize)
|
|
}
|