mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +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.
430 lines
11 KiB
Go
430 lines
11 KiB
Go
// Package chunking provides chunk validation functionality
|
|
package chunking
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
// TokenValidator provides comprehensive validation for tokens and chunks
|
|
type TokenValidator struct{}
|
|
|
|
// NewTokenValidator creates a new token validator
|
|
func NewTokenValidator() *TokenValidator {
|
|
return &TokenValidator{}
|
|
}
|
|
|
|
// ValidateTokenSize validates that a token is within size limits
|
|
func (tv *TokenValidator) ValidateTokenSize(token string, config TokenConfig) error {
|
|
if len(token) == 0 {
|
|
return nil // Empty token is allowed
|
|
}
|
|
|
|
if len(token) < config.MinLength {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "token too short",
|
|
Details: fmt.Sprintf("length %d < minimum %d", len(token), config.MinLength),
|
|
}
|
|
}
|
|
|
|
if len(token) > config.MaxLength {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "token too long",
|
|
Details: fmt.Sprintf("length %d > maximum %d", len(token), config.MaxLength),
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateJWTFormat validates that a token has proper JWT format
|
|
func (tv *TokenValidator) ValidateJWTFormat(token string, tokenType string) error {
|
|
if token == "" {
|
|
return nil // Empty token is not an error
|
|
}
|
|
|
|
// JWT tokens must have exactly 3 parts separated by dots
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
return &ValidationError{
|
|
Type: tokenType,
|
|
Reason: "invalid JWT format",
|
|
Details: fmt.Sprintf("expected 3 parts, got %d", len(parts)),
|
|
}
|
|
}
|
|
|
|
// Each part must be non-empty
|
|
for i, part := range parts {
|
|
if part == "" {
|
|
return &ValidationError{
|
|
Type: tokenType,
|
|
Reason: "empty JWT part",
|
|
Details: fmt.Sprintf("part %d is empty", i+1),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate each part is valid base64
|
|
for i, part := range parts {
|
|
if err := tv.validateBase64JWT(part); err != nil {
|
|
return &ValidationError{
|
|
Type: tokenType,
|
|
Reason: "invalid base64 in JWT part",
|
|
Details: fmt.Sprintf("part %d: %v", i+1, err),
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateTokenContent performs comprehensive content validation
|
|
func (tv *TokenValidator) ValidateTokenContent(token string, config TokenConfig) error {
|
|
if token == "" {
|
|
return nil
|
|
}
|
|
|
|
// Validate character set
|
|
if err := tv.validateCharacterSet(token, config); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Validate token structure based on type
|
|
if config.RequireJWTFormat {
|
|
return tv.validateJWTContent(token, config)
|
|
} else if config.AllowOpaqueTokens {
|
|
return tv.validateOpaqueTokenContent(token, config)
|
|
} else {
|
|
// Try JWT first, then fall back to opaque validation
|
|
if err := tv.validateJWTContent(token, config); err != nil {
|
|
return tv.validateOpaqueTokenContent(token, config)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// validateCharacterSet validates the character set of a token
|
|
func (tv *TokenValidator) validateCharacterSet(token string, config TokenConfig) error {
|
|
for i, r := range token {
|
|
if !tv.isValidTokenCharacter(r) {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "invalid character",
|
|
Details: fmt.Sprintf("invalid character at position %d: %c (0x%X)", i, r, r),
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isValidTokenCharacter checks if a character is valid in a token
|
|
func (tv *TokenValidator) isValidTokenCharacter(r rune) bool {
|
|
// Allow alphanumeric characters
|
|
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
|
return true
|
|
}
|
|
|
|
// Allow common token characters
|
|
validChars := ".-_~:/?#[]@!$&'()*+,;="
|
|
return strings.ContainsRune(validChars, r)
|
|
}
|
|
|
|
// validateJWTContent validates the content of a JWT token
|
|
func (tv *TokenValidator) validateJWTContent(token string, config TokenConfig) error {
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "invalid JWT structure",
|
|
Details: "JWT must have exactly 3 parts",
|
|
}
|
|
}
|
|
|
|
// Validate header
|
|
if err := tv.validateJWTHeader(parts[0], config); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Validate payload
|
|
if err := tv.validateJWTPayload(parts[1], config); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Validate signature
|
|
if err := tv.validateJWTSignature(parts[2], config); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateJWTHeader validates a JWT header
|
|
func (tv *TokenValidator) validateJWTHeader(header string, config TokenConfig) error {
|
|
decoded, err := tv.base64URLDecode(header)
|
|
if err != nil {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "invalid header encoding",
|
|
Details: err.Error(),
|
|
}
|
|
}
|
|
|
|
var headerData map[string]interface{}
|
|
if err := json.Unmarshal(decoded, &headerData); err != nil {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "invalid header JSON",
|
|
Details: err.Error(),
|
|
}
|
|
}
|
|
|
|
// Check required fields
|
|
if _, ok := headerData["alg"]; !ok {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "missing algorithm",
|
|
Details: "JWT header must contain 'alg' field",
|
|
}
|
|
}
|
|
|
|
if _, ok := headerData["typ"]; !ok {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "missing type",
|
|
Details: "JWT header must contain 'typ' field",
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateJWTPayload validates a JWT payload
|
|
func (tv *TokenValidator) validateJWTPayload(payload string, config TokenConfig) error {
|
|
decoded, err := tv.base64URLDecode(payload)
|
|
if err != nil {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "invalid payload encoding",
|
|
Details: err.Error(),
|
|
}
|
|
}
|
|
|
|
var payloadData map[string]interface{}
|
|
if err := json.Unmarshal(decoded, &payloadData); err != nil {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "invalid payload JSON",
|
|
Details: err.Error(),
|
|
}
|
|
}
|
|
|
|
// For ID tokens, check required claims
|
|
if config.Type == "id" {
|
|
requiredClaims := []string{"iss", "sub", "aud", "exp", "iat"}
|
|
for _, claim := range requiredClaims {
|
|
if _, ok := payloadData[claim]; !ok {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "missing required claim",
|
|
Details: fmt.Sprintf("ID token must contain '%s' claim", claim),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateJWTSignature validates a JWT signature part
|
|
func (tv *TokenValidator) validateJWTSignature(signature string, config TokenConfig) error {
|
|
if signature == "" {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "empty signature",
|
|
Details: "JWT signature cannot be empty",
|
|
}
|
|
}
|
|
|
|
// Just validate it's valid base64URL
|
|
_, err := tv.base64URLDecode(signature)
|
|
if err != nil {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "invalid signature encoding",
|
|
Details: err.Error(),
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateOpaqueTokenContent validates opaque token content
|
|
func (tv *TokenValidator) validateOpaqueTokenContent(token string, config TokenConfig) error {
|
|
if token == "" {
|
|
return nil
|
|
}
|
|
|
|
// Basic sanity checks for opaque tokens
|
|
if len(token) < 8 {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "token too short for opaque token",
|
|
Details: "opaque tokens should be at least 8 characters",
|
|
}
|
|
}
|
|
|
|
// Check for reasonable entropy
|
|
if tv.hasLowEntropy(token) {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "low entropy",
|
|
Details: "token appears to have low entropy",
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// hasLowEntropy checks if a token has suspiciously low entropy
|
|
func (tv *TokenValidator) hasLowEntropy(token string) bool {
|
|
if len(token) < 8 {
|
|
return true
|
|
}
|
|
|
|
// Count unique characters
|
|
uniqueChars := make(map[rune]bool)
|
|
for _, r := range token {
|
|
uniqueChars[r] = true
|
|
}
|
|
|
|
// If less than 50% of characters are unique, consider it low entropy
|
|
entropyRatio := float64(len(uniqueChars)) / float64(len(token))
|
|
return entropyRatio < 0.5
|
|
}
|
|
|
|
// validateBase64JWT validates base64URL encoding
|
|
func (tv *TokenValidator) validateBase64JWT(data string) error {
|
|
_, err := tv.base64URLDecode(data)
|
|
return err
|
|
}
|
|
|
|
// base64URLDecode decodes base64URL encoded data
|
|
func (tv *TokenValidator) base64URLDecode(data string) ([]byte, error) {
|
|
// Add padding if needed
|
|
switch len(data) % 4 {
|
|
case 2:
|
|
data += "=="
|
|
case 3:
|
|
data += "="
|
|
}
|
|
|
|
// Replace URL-safe characters
|
|
data = strings.ReplaceAll(data, "-", "+")
|
|
data = strings.ReplaceAll(data, "_", "/")
|
|
|
|
return base64.StdEncoding.DecodeString(data)
|
|
}
|
|
|
|
// ValidateChunkStructure validates the structure of chunk data
|
|
func (tv *TokenValidator) ValidateChunkStructure(chunks []ChunkData, config TokenConfig) error {
|
|
if len(chunks) == 0 {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "no chunks provided",
|
|
Details: "chunk list is empty",
|
|
}
|
|
}
|
|
|
|
if len(chunks) > config.MaxChunks {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "too many chunks",
|
|
Details: fmt.Sprintf("got %d chunks, maximum is %d", len(chunks), config.MaxChunks),
|
|
}
|
|
}
|
|
|
|
// Validate each chunk
|
|
expectedTotal := chunks[0].Total
|
|
seenIndices := make(map[int]bool)
|
|
|
|
for i, chunk := range chunks {
|
|
// Check for duplicate indices
|
|
if seenIndices[chunk.Index] {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "duplicate chunk index",
|
|
Details: fmt.Sprintf("chunk index %d appears multiple times", chunk.Index),
|
|
}
|
|
}
|
|
seenIndices[chunk.Index] = true
|
|
|
|
// Validate individual chunk
|
|
if err := tv.validateChunkData(chunk, expectedTotal, config); err != nil {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "invalid chunk data",
|
|
Details: fmt.Sprintf("chunk %d: %v", i, err),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for missing indices
|
|
for i := 0; i < expectedTotal; i++ {
|
|
if !seenIndices[i] {
|
|
return &ValidationError{
|
|
Type: config.Type,
|
|
Reason: "missing chunk index",
|
|
Details: fmt.Sprintf("chunk with index %d is missing", i),
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateChunkData validates individual chunk data
|
|
func (tv *TokenValidator) validateChunkData(chunk ChunkData, expectedTotal int, config TokenConfig) error {
|
|
if chunk.Index < 0 {
|
|
return fmt.Errorf("negative index: %d", chunk.Index)
|
|
}
|
|
|
|
if chunk.Total != expectedTotal {
|
|
return fmt.Errorf("inconsistent total: got %d, expected %d", chunk.Total, expectedTotal)
|
|
}
|
|
|
|
if chunk.Index >= chunk.Total {
|
|
return fmt.Errorf("index %d exceeds total %d", chunk.Index, chunk.Total)
|
|
}
|
|
|
|
if chunk.Content == "" {
|
|
return fmt.Errorf("empty content")
|
|
}
|
|
|
|
if len(chunk.Content) > config.MaxChunkSize {
|
|
return fmt.Errorf("chunk too large: %d > %d", len(chunk.Content), config.MaxChunkSize)
|
|
}
|
|
|
|
if chunk.Checksum == "" {
|
|
return fmt.Errorf("empty checksum")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidationError represents a validation error
|
|
type ValidationError struct {
|
|
Type string
|
|
Reason string
|
|
Details string
|
|
}
|
|
|
|
// Error implements the error interface
|
|
func (ve *ValidationError) Error() string {
|
|
return fmt.Sprintf("%s validation error: %s - %s", ve.Type, ve.Reason, ve.Details)
|
|
}
|