mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-17 04:37:48 +00:00
Refactoring code to fix the issues identified by the users.
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
// TestTokenCorruptionScenario reproduces the exact failure pattern from GitHub issue #53:
|
||||
// Token verified successfully multiple times, then fails with "signature verification failed"
|
||||
func TestTokenCorruptionScenario(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
|
||||
// Create a valid JWT token
|
||||
validJWT := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImV4cCI6OTk5OTk5OTk5OX0.signature"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tokenSize int
|
||||
iterations int
|
||||
expectConsistent bool
|
||||
corruptionScenario func(*SessionData) // Function to simulate corruption
|
||||
}{
|
||||
{
|
||||
name: "Small token - multiple retrievals",
|
||||
tokenSize: len(validJWT),
|
||||
iterations: 10,
|
||||
expectConsistent: true,
|
||||
},
|
||||
{
|
||||
name: "Large chunked token - multiple retrievals",
|
||||
tokenSize: 5000,
|
||||
iterations: 10,
|
||||
expectConsistent: true,
|
||||
},
|
||||
{
|
||||
name: "Compression corruption simulation",
|
||||
tokenSize: 2000,
|
||||
iterations: 5,
|
||||
expectConsistent: false, // Will be corrupted intentionally
|
||||
corruptionScenario: func(session *SessionData) {
|
||||
// Simulate corruption by directly modifying session values
|
||||
if session.accessSession != nil {
|
||||
// Simulate corrupted compressed data
|
||||
session.accessSession.Values["token"] = "corrupted_base64_!@#$"
|
||||
session.accessSession.Values["compressed"] = true
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Chunk reassembly corruption simulation",
|
||||
tokenSize: 25000, // Large enough to force chunking even after compression
|
||||
iterations: 5,
|
||||
expectConsistent: false, // Will be corrupted intentionally
|
||||
corruptionScenario: func(session *SessionData) {
|
||||
// Simulate chunk corruption with invalid base64 characters
|
||||
if len(session.accessTokenChunks) > 0 {
|
||||
if chunk, exists := session.accessTokenChunks[0]; exists {
|
||||
chunk.Values["token_chunk"] = "invalid_base64_!@#$%"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
|
||||
session, err := sm.GetSession(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get session: %v", err)
|
||||
}
|
||||
defer session.ReturnToPool()
|
||||
|
||||
// Create token of specified size
|
||||
token := createTokenOfSize(validJWT, tt.tokenSize)
|
||||
|
||||
// 1. Store the token
|
||||
session.SetAccessToken(token)
|
||||
t.Logf("Stored token of size %d bytes", len(token))
|
||||
|
||||
// 2. Verify token can be retrieved multiple times successfully
|
||||
var retrievedTokens []string
|
||||
for i := 0; i < tt.iterations; i++ {
|
||||
retrieved := session.GetAccessToken()
|
||||
retrievedTokens = append(retrievedTokens, retrieved)
|
||||
|
||||
if tt.expectConsistent && retrieved != token {
|
||||
t.Errorf("Iteration %d: Token mismatch, expected consistency", i)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Apply corruption scenario if specified
|
||||
if tt.corruptionScenario != nil {
|
||||
tt.corruptionScenario(session)
|
||||
}
|
||||
|
||||
// 4. Retrieve token after potential corruption
|
||||
finalRetrieved := session.GetAccessToken()
|
||||
|
||||
if tt.expectConsistent {
|
||||
// With fixes, token should still be retrievable correctly
|
||||
if finalRetrieved != token {
|
||||
t.Errorf("Final retrieval failed - corruption not handled correctly")
|
||||
t.Logf("Expected: %q", token)
|
||||
t.Logf("Got: %q", finalRetrieved)
|
||||
}
|
||||
} else {
|
||||
// For corruption scenarios, expect empty string (graceful failure)
|
||||
if finalRetrieved != "" {
|
||||
t.Errorf("Expected corruption to result in empty token, got: %q", finalRetrieved)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Verify all previous retrievals were consistent (if expected)
|
||||
if tt.expectConsistent {
|
||||
for i, retrieved := range retrievedTokens {
|
||||
if retrieved != token {
|
||||
t.Errorf("Iteration %d produced inconsistent result", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompressionIntegrityFailure tests scenarios where compression fails integrity checks
|
||||
func TestCompressionIntegrityFailure(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
expectSame bool
|
||||
}{
|
||||
{
|
||||
name: "Valid JWT",
|
||||
token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig",
|
||||
expectSame: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid JWT - wrong dots",
|
||||
token: "invalid.token",
|
||||
expectSame: true, // Should return unchanged
|
||||
},
|
||||
{
|
||||
name: "Oversized token",
|
||||
token: "header." + strings.Repeat("A", 60000) + ".sig",
|
||||
expectSame: true, // Should return unchanged due to size limit
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
compressed := compressToken(tt.token)
|
||||
|
||||
if tt.expectSame && compressed != tt.token {
|
||||
// If we expect the token to remain the same but it was compressed,
|
||||
// verify round-trip integrity
|
||||
decompressed := decompressToken(compressed)
|
||||
if decompressed != tt.token {
|
||||
t.Errorf("Compression integrity failed: original=%q, decompressed=%q", tt.token, decompressed)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChunkReassemblyEdgeCases tests edge cases in chunk reassembly that could cause corruption
|
||||
func TestChunkReassemblyEdgeCases(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
|
||||
session, err := sm.GetSession(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get session: %v", err)
|
||||
}
|
||||
defer session.ReturnToPool()
|
||||
|
||||
// Create a large token that will definitely be chunked
|
||||
largeToken := createTokenOfSize("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig", 8000)
|
||||
|
||||
// Store the token to create chunks
|
||||
session.SetAccessToken(largeToken)
|
||||
|
||||
if len(session.accessTokenChunks) == 0 {
|
||||
t.Skip("Token was not chunked, skipping reassembly tests")
|
||||
}
|
||||
|
||||
t.Logf("Token was split into %d chunks", len(session.accessTokenChunks))
|
||||
|
||||
// Test various corruption scenarios
|
||||
corruptionTests := []struct {
|
||||
name string
|
||||
corruption func(map[int]*sessions.Session)
|
||||
expectEmpty bool
|
||||
}{
|
||||
{
|
||||
name: "Gap in chunk sequence",
|
||||
corruption: func(chunks map[int]*sessions.Session) {
|
||||
// Remove chunk 1 if it exists
|
||||
delete(chunks, 1)
|
||||
},
|
||||
expectEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "Chunk with nil value",
|
||||
corruption: func(chunks map[int]*sessions.Session) {
|
||||
if chunk, exists := chunks[0]; exists {
|
||||
chunk.Values["token_chunk"] = nil
|
||||
}
|
||||
},
|
||||
expectEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "Chunk with wrong type",
|
||||
corruption: func(chunks map[int]*sessions.Session) {
|
||||
if chunk, exists := chunks[0]; exists {
|
||||
chunk.Values["token_chunk"] = 12345 // Should be string
|
||||
}
|
||||
},
|
||||
expectEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "Empty chunk data",
|
||||
corruption: func(chunks map[int]*sessions.Session) {
|
||||
if chunk, exists := chunks[0]; exists {
|
||||
chunk.Values["token_chunk"] = ""
|
||||
}
|
||||
},
|
||||
expectEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "Excessive chunk count",
|
||||
corruption: func(chunks map[int]*sessions.Session) {
|
||||
// This test simulates having too many chunks (>50 limit)
|
||||
// We'll create a scenario by adding many fake chunks
|
||||
for i := 0; i < 60; i++ {
|
||||
fakeSession := &sessions.Session{Values: make(map[interface{}]interface{})}
|
||||
fakeSession.Values["token_chunk"] = "fake_chunk_data"
|
||||
chunks[i] = fakeSession
|
||||
}
|
||||
},
|
||||
expectEmpty: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, ct := range corruptionTests {
|
||||
t.Run(ct.name, func(t *testing.T) {
|
||||
// Get a fresh session for each test
|
||||
freshReq := httptest.NewRequest("GET", "http://example.com/foo", nil)
|
||||
freshSession, err := sm.GetSession(freshReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get fresh session: %v", err)
|
||||
}
|
||||
defer freshSession.ReturnToPool()
|
||||
|
||||
// Store the large token again
|
||||
freshSession.SetAccessToken(largeToken)
|
||||
|
||||
// Apply corruption
|
||||
ct.corruption(freshSession.accessTokenChunks)
|
||||
|
||||
// Try to retrieve the token
|
||||
retrieved := freshSession.GetAccessToken()
|
||||
|
||||
if ct.expectEmpty {
|
||||
if retrieved != "" {
|
||||
t.Errorf("Expected empty token due to corruption, got: %q", retrieved)
|
||||
}
|
||||
} else {
|
||||
if retrieved != largeToken {
|
||||
t.Errorf("Expected original token, got: %q", retrieved)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRaceConditionProtection tests that concurrent access doesn't cause corruption
|
||||
func TestRaceConditionProtection(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
|
||||
session, err := sm.GetSession(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get session: %v", err)
|
||||
}
|
||||
defer session.ReturnToPool()
|
||||
|
||||
const numGoroutines = 20
|
||||
const numOperations = 50
|
||||
|
||||
// Create tokens of different sizes
|
||||
tokens := []string{
|
||||
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig1",
|
||||
createTokenOfSize("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig2", 3000),
|
||||
createTokenOfSize("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig3", 6000),
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errChan := make(chan error, numGoroutines*numOperations)
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
|
||||
for j := 0; j < numOperations; j++ {
|
||||
tokenIndex := (goroutineID + j) % len(tokens)
|
||||
expectedToken := tokens[tokenIndex]
|
||||
|
||||
// Set token
|
||||
session.SetAccessToken(expectedToken)
|
||||
|
||||
// Retrieve token
|
||||
retrieved := session.GetAccessToken()
|
||||
|
||||
// Verify it's a valid JWT (should have exactly 2 dots)
|
||||
if retrieved != "" && strings.Count(retrieved, ".") != 2 {
|
||||
errChan <- fmt.Errorf("goroutine %d, op %d: invalid JWT format in retrieved token: %q",
|
||||
goroutineID, j, retrieved)
|
||||
continue
|
||||
}
|
||||
|
||||
// The retrieved token should be one of the valid tokens we set
|
||||
// (due to concurrent access, it might not be the exact one we just set)
|
||||
isValidToken := false
|
||||
for _, validToken := range tokens {
|
||||
if retrieved == validToken {
|
||||
isValidToken = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if retrieved != "" && !isValidToken {
|
||||
errChan <- fmt.Errorf("goroutine %d, op %d: retrieved unknown token: %q",
|
||||
goroutineID, j, retrieved)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errChan)
|
||||
|
||||
// Check for any errors
|
||||
for err := range errChan {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryExhaustionProtection tests protection against memory exhaustion attacks
|
||||
func TestMemoryExhaustionProtection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupCorruption func() string
|
||||
expectRejection bool
|
||||
}{
|
||||
{
|
||||
name: "Extremely large compressed data",
|
||||
setupCorruption: func() string {
|
||||
return base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("A"), 200*1024)) // 200KB
|
||||
},
|
||||
expectRejection: true,
|
||||
},
|
||||
{
|
||||
name: "Malformed gzip bomb attempt",
|
||||
setupCorruption: func() string {
|
||||
// Create data that looks like gzip but would decompress to huge size
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
gz.Write(bytes.Repeat([]byte("A"), 10*1024)) // 10KB that compresses well
|
||||
gz.Close()
|
||||
|
||||
compressed := buf.Bytes()
|
||||
// Modify to make it potentially dangerous
|
||||
return base64.StdEncoding.EncodeToString(compressed)
|
||||
},
|
||||
expectRejection: false, // Our decompression has size limits
|
||||
},
|
||||
{
|
||||
name: "Token with excessive chunk simulation",
|
||||
setupCorruption: func() string {
|
||||
// This will be tested in the session layer
|
||||
return strings.Repeat("chunk.", 100) + "final"
|
||||
},
|
||||
expectRejection: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
corruptedData := tt.setupCorruption()
|
||||
|
||||
result := decompressToken(corruptedData)
|
||||
|
||||
if tt.expectRejection {
|
||||
// Should return original corrupted data, not attempt decompression
|
||||
if result != corruptedData {
|
||||
t.Errorf("Expected rejection of dangerous data, but decompression was attempted")
|
||||
}
|
||||
}
|
||||
|
||||
// Verify no excessive memory was used (this test would catch OOM in practice)
|
||||
// The fact that we reach this point means memory limits were effective
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackwardCompatibility ensures that sessions created before the fixes still work
|
||||
func TestBackwardCompatibility(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
sm, err := NewSessionManager("0123456789abcdef0123456789abcdef0123456789abcdef", false, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create session manager: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "http://example.com/foo", nil)
|
||||
session, err := sm.GetSession(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get session: %v", err)
|
||||
}
|
||||
defer session.ReturnToPool()
|
||||
|
||||
// Simulate old-style session data (without new validation fields)
|
||||
oldStyleToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.oldsig"
|
||||
|
||||
// Manually set token without going through new SetAccessToken validation
|
||||
session.accessSession.Values["token"] = oldStyleToken
|
||||
session.accessSession.Values["compressed"] = false
|
||||
|
||||
// Should still be retrievable
|
||||
retrieved := session.GetAccessToken()
|
||||
if retrieved != oldStyleToken {
|
||||
t.Errorf("Backward compatibility failed: expected %q, got %q", oldStyleToken, retrieved)
|
||||
}
|
||||
|
||||
// Test with simulated old compressed token
|
||||
oldCompressed := compressToken(oldStyleToken)
|
||||
session.accessSession.Values["token"] = oldCompressed
|
||||
session.accessSession.Values["compressed"] = true
|
||||
|
||||
retrieved2 := session.GetAccessToken()
|
||||
if retrieved2 != oldStyleToken {
|
||||
t.Errorf("Backward compatibility with compression failed: expected %q, got %q", oldStyleToken, retrieved2)
|
||||
}
|
||||
}
|
||||
|
||||
// createTokenOfSize creates a JWT token of approximately the specified size
|
||||
func createTokenOfSize(baseToken string, targetSize int) string {
|
||||
parts := strings.Split(baseToken, ".")
|
||||
if len(parts) != 3 {
|
||||
return baseToken
|
||||
}
|
||||
|
||||
header, payload, signature := parts[0], parts[1], parts[2]
|
||||
currentSize := len(baseToken)
|
||||
|
||||
if currentSize >= targetSize {
|
||||
return baseToken
|
||||
}
|
||||
|
||||
// Expand the payload to reach target size
|
||||
paddingNeeded := targetSize - len(header) - len(signature) - 2 // Account for dots
|
||||
if paddingNeeded > 0 {
|
||||
// Decode current payload, add padding, re-encode
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
// If we can't decode, just pad with random base64-safe characters to resist compression
|
||||
randomBytes := make([]byte, paddingNeeded)
|
||||
rand.Read(randomBytes)
|
||||
// Encode as base64 to make it base64-safe
|
||||
padData := base64.RawURLEncoding.EncodeToString(randomBytes)
|
||||
payload = payload + padData
|
||||
} else {
|
||||
// Add padding to the JSON - use random data to resist compression
|
||||
randomBytes := make([]byte, paddingNeeded/2)
|
||||
rand.Read(randomBytes)
|
||||
// Encode as base64 to make it JSON-safe
|
||||
padData := base64.StdEncoding.EncodeToString(randomBytes)
|
||||
newPayload := fmt.Sprintf(`{"original":%s,"padding":"%s"}`, string(decoded), padData)
|
||||
payload = base64.RawURLEncoding.EncodeToString([]byte(newPayload))
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s.%s.%s", header, payload, signature)
|
||||
}
|
||||
Reference in New Issue
Block a user