Files
traefikoidc/session/chunking/chunk_validator.go
T
lukaszraczylo 1b49e133da Complete rebuild of the plugin
* 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.
2025-09-18 11:01:30 +01:00

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)
}