mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-10-12 05:32:36 +00:00
The JSON log path re-marshaled redacted output with no size cap, so a large body was logged in full. An XML-form secret whose closing tag fell past the truncation cut also leaked its prefix. Cap the re-marshaled JSON at MaxLogBodySize after redaction. Strip a trailing unterminated sensitive tag. Fold tag names with a length-preserving ASCII lower so the cut index stays valid.
1018 lines
34 KiB
Go
1018 lines
34 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"testing"
|
||
"unicode/utf8"
|
||
|
||
"github.com/stretchr/testify/suite"
|
||
)
|
||
|
||
type ProxyLoggingSecurityTestSuite struct {
|
||
suite.Suite
|
||
}
|
||
|
||
func TestProxyLoggingSecurityTestSuite(t *testing.T) {
|
||
suite.Run(t, new(ProxyLoggingSecurityTestSuite))
|
||
}
|
||
|
||
// TestSensitiveDataSanitization tests that sensitive data is properly redacted from logs
|
||
func (suite *ProxyLoggingSecurityTestSuite) TestSensitiveDataSanitization() {
|
||
tests := []struct {
|
||
name string
|
||
input map[string]any
|
||
expected map[string]any
|
||
contentType string
|
||
description string
|
||
}{
|
||
{
|
||
name: "Password field redaction",
|
||
input: map[string]any{
|
||
"username": "user123",
|
||
"password": "secret123",
|
||
"email": "[email protected]",
|
||
},
|
||
expected: map[string]any{
|
||
"username": "user123",
|
||
"password": "[REDACTED]",
|
||
"email": "[REDACTED]",
|
||
},
|
||
contentType: "application/json",
|
||
description: "Should redact password and email fields",
|
||
},
|
||
{
|
||
name: "API key and token redaction",
|
||
input: map[string]any{
|
||
"data": "normal data",
|
||
"api_key": "sk-123456789",
|
||
"token": "bearer-token-123",
|
||
"auth": "auth-value",
|
||
},
|
||
expected: map[string]any{
|
||
"data": "normal data",
|
||
"api_key": "[REDACTED]",
|
||
"token": "[REDACTED]",
|
||
"auth": "[REDACTED]",
|
||
},
|
||
contentType: "application/json",
|
||
description: "Should redact API keys and tokens",
|
||
},
|
||
{
|
||
name: "Nested sensitive fields",
|
||
input: map[string]any{
|
||
"user": map[string]any{
|
||
"name": "John Doe",
|
||
"password": "secret123",
|
||
"profile": map[string]any{
|
||
"api_key": "sk-nested-key",
|
||
"bio": "User bio",
|
||
},
|
||
},
|
||
"public_data": "visible",
|
||
},
|
||
expected: map[string]any{
|
||
"user": map[string]any{
|
||
"name": "John Doe",
|
||
"password": "[REDACTED]",
|
||
"profile": map[string]any{
|
||
"api_key": "[REDACTED]",
|
||
"bio": "User bio",
|
||
},
|
||
},
|
||
"public_data": "visible",
|
||
},
|
||
contentType: "application/json",
|
||
description: "Should redact nested sensitive fields",
|
||
},
|
||
{
|
||
name: "Array with sensitive data",
|
||
input: map[string]any{
|
||
"users": []any{
|
||
map[string]any{
|
||
"name": "User1",
|
||
"password": "pass1",
|
||
},
|
||
map[string]any{
|
||
"name": "User2",
|
||
"token": "token2",
|
||
},
|
||
},
|
||
},
|
||
expected: map[string]any{
|
||
"users": []any{
|
||
map[string]any{
|
||
"name": "User1",
|
||
"password": "[REDACTED]",
|
||
},
|
||
map[string]any{
|
||
"name": "User2",
|
||
"token": "[REDACTED]",
|
||
},
|
||
},
|
||
},
|
||
contentType: "application/json",
|
||
description: "Should redact sensitive fields in arrays",
|
||
},
|
||
{
|
||
name: "Credit card and financial data",
|
||
input: map[string]any{
|
||
"order_id": "12345",
|
||
"credit_card": "4111111111111111",
|
||
"cvv": "123",
|
||
"amount": 100.50,
|
||
},
|
||
expected: map[string]any{
|
||
"order_id": "12345",
|
||
"credit_card": "[REDACTED]",
|
||
"cvv": "[REDACTED]",
|
||
"amount": json.Number("100.5"),
|
||
},
|
||
contentType: "application/json",
|
||
description: "Should redact financial sensitive data",
|
||
},
|
||
{
|
||
name: "Personal identifiable information",
|
||
input: map[string]any{
|
||
"name": "John Doe",
|
||
"ssn": "123-45-6789",
|
||
"phone": "+1-555-123-4567",
|
||
"address": "123 Main St",
|
||
"age": 30,
|
||
},
|
||
expected: map[string]any{
|
||
"name": "John Doe",
|
||
"ssn": "[REDACTED]",
|
||
"phone": "[REDACTED]",
|
||
"address": "[REDACTED]",
|
||
"age": json.Number("30"),
|
||
},
|
||
contentType: "application/json",
|
||
description: "Should redact PII data",
|
||
},
|
||
{
|
||
name: "Mixed case field names",
|
||
input: map[string]any{
|
||
"UserName": "john",
|
||
"PASSWORD": "secret",
|
||
"Api_Key": "key123",
|
||
"Bearer": "token",
|
||
},
|
||
expected: map[string]any{
|
||
"UserName": "john",
|
||
"PASSWORD": "[REDACTED]",
|
||
"Api_Key": "[REDACTED]",
|
||
"Bearer": "[REDACTED]",
|
||
},
|
||
contentType: "application/json",
|
||
description: "Should handle mixed case field names",
|
||
},
|
||
{
|
||
name: "Various password patterns",
|
||
input: map[string]any{
|
||
"pwd": "secret1",
|
||
"passwd": "secret2",
|
||
"password": "secret3",
|
||
"pass": "secret4", // Now redacted for better security coverage
|
||
},
|
||
expected: map[string]any{
|
||
"pwd": "[REDACTED]",
|
||
"passwd": "[REDACTED]",
|
||
"password": "[REDACTED]",
|
||
"pass": "[REDACTED]",
|
||
},
|
||
contentType: "application/json",
|
||
description: "Should handle various password field patterns",
|
||
},
|
||
{
|
||
name: "Various auth patterns",
|
||
input: map[string]any{
|
||
"authorization": "Bearer token123",
|
||
"auth": "basic auth",
|
||
"bearer": "token456",
|
||
"session": "sess123",
|
||
"sessionid": "session456",
|
||
"session_id": "session789",
|
||
"cookie": "cookie_value",
|
||
},
|
||
expected: map[string]any{
|
||
"authorization": "[REDACTED]",
|
||
"auth": "[REDACTED]",
|
||
"bearer": "[REDACTED]",
|
||
"session": "[REDACTED]",
|
||
"sessionid": "[REDACTED]",
|
||
"session_id": "[REDACTED]",
|
||
"cookie": "[REDACTED]",
|
||
},
|
||
contentType: "application/json",
|
||
description: "Should handle various authentication field patterns",
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
suite.Run(tt.name, func() {
|
||
// Convert input to JSON bytes
|
||
inputBytes, err := json.Marshal(tt.input)
|
||
suite.NoError(err)
|
||
|
||
// Test the sanitization function
|
||
result := sanitizeForLogging(inputBytes, tt.contentType)
|
||
|
||
// Parse the result back to compare
|
||
var sanitized map[string]any
|
||
decoder := json.NewDecoder(strings.NewReader(result))
|
||
decoder.UseNumber() // Preserve number precision and type
|
||
err = decoder.Decode(&sanitized)
|
||
suite.NoError(err, "Sanitized result should be valid JSON")
|
||
|
||
// Compare the result with expected
|
||
suite.Equal(tt.expected, sanitized, tt.description)
|
||
|
||
// Verify no sensitive data remains in the string representation
|
||
resultStr := strings.ToLower(result)
|
||
if strings.Contains(tt.name, "password") || strings.Contains(tt.name, "secret") {
|
||
suite.NotContains(resultStr, "secret", "Should not contain 'secret' in result")
|
||
}
|
||
if strings.Contains(tt.name, "key") {
|
||
suite.NotContains(resultStr, "sk-", "Should not contain API key prefix")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestSensitiveDataSanitizationNonJSON tests sanitization for non-JSON content
|
||
func (suite *ProxyLoggingSecurityTestSuite) TestSensitiveDataSanitizationNonJSON() {
|
||
tests := []struct {
|
||
name string
|
||
input string
|
||
contentType string
|
||
description string
|
||
shouldNotContain []string
|
||
shouldContainSanitized []string
|
||
}{
|
||
{
|
||
name: "Form data with password",
|
||
input: "username=john&password=secret123&[email protected]",
|
||
contentType: "application/x-www-form-urlencoded",
|
||
shouldNotContain: []string{"secret123"},
|
||
shouldContainSanitized: []string{"password=[REDACTED]"},
|
||
description: "Should redact password in form data",
|
||
},
|
||
{
|
||
name: "Query string with sensitive data",
|
||
input: "?user=john&api_key=sk-123456&public=data",
|
||
contentType: "text/plain",
|
||
shouldNotContain: []string{"sk-123456"},
|
||
shouldContainSanitized: []string{"api_key=[REDACTED]"},
|
||
description: "Should redact API key in query string",
|
||
},
|
||
{
|
||
name: "Large body truncation",
|
||
input: strings.Repeat("a", 1500) + "password=secret",
|
||
contentType: "text/plain",
|
||
shouldNotContain: []string{},
|
||
shouldContainSanitized: []string{"[truncated]"},
|
||
description: "Should truncate large bodies",
|
||
},
|
||
{
|
||
name: "XML-like content with sensitive data",
|
||
input: "<user><name>John</name><password>secret123</password></user>",
|
||
contentType: "application/xml",
|
||
shouldNotContain: []string{"secret123"},
|
||
shouldContainSanitized: []string{"password=[REDACTED]"},
|
||
description: "Should redact sensitive data in XML-like content",
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
suite.Run(tt.name, func() {
|
||
result := sanitizeForLogging([]byte(tt.input), tt.contentType)
|
||
|
||
// Check that sensitive data is removed
|
||
for _, sensitiveData := range tt.shouldNotContain {
|
||
suite.NotContains(result, sensitiveData,
|
||
"Result should not contain sensitive data: %s", sensitiveData)
|
||
}
|
||
|
||
// Check that redaction markers are present
|
||
for _, redactedPattern := range tt.shouldContainSanitized {
|
||
suite.Contains(result, redactedPattern,
|
||
"Result should contain redaction marker: %s", redactedPattern)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestSanitizeHeaders tests header sanitization
|
||
func (suite *ProxyLoggingSecurityTestSuite) TestSanitizeHeaders() {
|
||
tests := []struct {
|
||
input map[string]string
|
||
expected map[string]string
|
||
name string
|
||
}{
|
||
{
|
||
name: "Authorization header redaction",
|
||
input: map[string]string{
|
||
"Content-Type": "application/json",
|
||
"Authorization": "Bearer token123",
|
||
"User-Agent": "Test/1.0",
|
||
},
|
||
expected: map[string]string{
|
||
"Content-Type": "application/json",
|
||
"Authorization": "[REDACTED]",
|
||
"User-Agent": "Test/1.0",
|
||
},
|
||
},
|
||
{
|
||
name: "API key headers redaction",
|
||
input: map[string]string{
|
||
"X-API-Key": "sk-123456",
|
||
"X-Auth-Token": "auth-token-123",
|
||
"X-API-Secret": "secret-key",
|
||
"Content-Length": "100",
|
||
},
|
||
expected: map[string]string{
|
||
"X-API-Key": "[REDACTED]",
|
||
"X-Auth-Token": "[REDACTED]",
|
||
"X-API-Secret": "[REDACTED]",
|
||
"Content-Length": "100",
|
||
},
|
||
},
|
||
{
|
||
name: "Cookie headers redaction",
|
||
input: map[string]string{
|
||
"Cookie": "sessionid=abc123; userid=456",
|
||
"Set-Cookie": "token=xyz789; Path=/",
|
||
"Host": "example.com",
|
||
},
|
||
expected: map[string]string{
|
||
"Cookie": "[REDACTED]",
|
||
"Set-Cookie": "[REDACTED]",
|
||
"Host": "example.com",
|
||
},
|
||
},
|
||
{
|
||
name: "Mixed case headers",
|
||
input: map[string]string{
|
||
"AUTHORIZATION": "Bearer token",
|
||
"x-api-key": "key123",
|
||
"Content-TYPE": "json",
|
||
},
|
||
expected: map[string]string{
|
||
"AUTHORIZATION": "[REDACTED]",
|
||
"x-api-key": "[REDACTED]",
|
||
"Content-TYPE": "json",
|
||
},
|
||
},
|
||
{
|
||
name: "CSRF and access tokens",
|
||
input: map[string]string{
|
||
"X-CSRF-Token": "csrf123",
|
||
"X-Access-Token": "access456",
|
||
"Accept": "application/json",
|
||
},
|
||
expected: map[string]string{
|
||
"X-CSRF-Token": "[REDACTED]",
|
||
"X-Access-Token": "[REDACTED]",
|
||
"Accept": "application/json",
|
||
},
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
suite.Run(tt.name, func() {
|
||
result := sanitizeHeaders(tt.input)
|
||
suite.Equal(tt.expected, result)
|
||
|
||
// Verify original headers are not modified
|
||
for key, originalValue := range tt.input {
|
||
suite.Equal(originalValue, tt.input[key],
|
||
"Original headers should not be modified")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestRedactSensitiveFields tests the recursive redaction function
|
||
func (suite *ProxyLoggingSecurityTestSuite) TestRedactSensitiveFields() {
|
||
sensitiveFields := []string{"password", "token", "secret"}
|
||
|
||
suite.Run("Deep nested structure", func() {
|
||
data := map[string]any{
|
||
"level1": map[string]any{
|
||
"level2": map[string]any{
|
||
"level3": map[string]any{
|
||
"password": "testdeepsecret",
|
||
"public": "data",
|
||
},
|
||
"token": "testlevel2token",
|
||
},
|
||
"normal": "value",
|
||
},
|
||
"secret": "testtoplevel",
|
||
}
|
||
|
||
redactSensitiveFields(data, sensitiveFields)
|
||
|
||
// Verify deep nesting is handled
|
||
level3 := data["level1"].(map[string]any)["level2"].(map[string]any)["level3"].(map[string]any)
|
||
suite.Equal("[REDACTED]", level3["password"])
|
||
suite.Equal("data", level3["public"])
|
||
|
||
// Verify intermediate levels
|
||
level2 := data["level1"].(map[string]any)["level2"].(map[string]any)
|
||
suite.Equal("[REDACTED]", level2["token"])
|
||
|
||
// Verify top level
|
||
suite.Equal("[REDACTED]", data["secret"])
|
||
level1 := data["level1"].(map[string]any)
|
||
suite.Equal("value", level1["normal"])
|
||
})
|
||
|
||
suite.Run("Array of objects", func() {
|
||
data := map[string]any{
|
||
"users": []any{
|
||
map[string]any{
|
||
"name": "User1",
|
||
"password": "testpass1",
|
||
},
|
||
map[string]any{
|
||
"name": "User2",
|
||
"token": "testtoken2",
|
||
},
|
||
"not-an-object", // Should be ignored
|
||
},
|
||
}
|
||
|
||
redactSensitiveFields(data, sensitiveFields)
|
||
|
||
users := data["users"].([]any)
|
||
user1 := users[0].(map[string]any)
|
||
user2 := users[1].(map[string]any)
|
||
|
||
suite.Equal("[REDACTED]", user1["password"])
|
||
suite.Equal("User1", user1["name"])
|
||
suite.Equal("[REDACTED]", user2["token"])
|
||
suite.Equal("User2", user2["name"])
|
||
suite.Equal("not-an-object", users[2])
|
||
})
|
||
}
|
||
|
||
// TestRedactPatternInString tests string pattern redaction
|
||
func (suite *ProxyLoggingSecurityTestSuite) TestRedactPatternInString() {
|
||
tests := []struct {
|
||
name string
|
||
input string
|
||
pattern string
|
||
expected string
|
||
}{
|
||
{
|
||
name: "JSON-style pattern",
|
||
input: `{"password": "secret123", "user": "john"}`,
|
||
pattern: "password",
|
||
expected: `{"password":"[REDACTED]", "user": "john"}`,
|
||
},
|
||
{
|
||
name: "Form-style pattern with equals",
|
||
input: "username=john&password=secret&email=test",
|
||
pattern: "password",
|
||
expected: "username=john&password=[REDACTED]&email=test",
|
||
},
|
||
{
|
||
name: "Double quoted pattern",
|
||
input: `password="secret123"`,
|
||
pattern: "password",
|
||
expected: `password="[REDACTED]"`,
|
||
},
|
||
{
|
||
name: "Single quoted pattern",
|
||
input: `password='secret123'`,
|
||
pattern: "password",
|
||
expected: `password='[REDACTED]'`,
|
||
},
|
||
{
|
||
name: "No match",
|
||
input: "normal text without sensitive data",
|
||
pattern: "password",
|
||
expected: "normal text without sensitive data",
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
suite.Run(tt.name, func() {
|
||
result := redactPatternInString(tt.input, tt.pattern)
|
||
suite.Equal(tt.expected, result)
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestSanitizationPerformance tests performance of sanitization functions.
|
||
// The redacted JSON for 1000 users is far larger than MaxLogBodySize, so the
|
||
// size-cap fix (Defect A) truncates it - the result is no longer guaranteed
|
||
// to be complete, parseable JSON. This asserts the bounded-output contract
|
||
// instead of unmarshaling the whole thing.
|
||
func (suite *ProxyLoggingSecurityTestSuite) TestSanitizationPerformance() {
|
||
// Create a large JSON structure with sensitive data
|
||
largeData := make(map[string]any)
|
||
for i := 0; i < 1000; i++ {
|
||
largeData[fmt.Sprintf("user_%d", i)] = map[string]any{
|
||
"name": fmt.Sprintf("User%d", i),
|
||
"password": fmt.Sprintf("secret%d", i),
|
||
"email": fmt.Sprintf("user%[email protected]", i),
|
||
"public": fmt.Sprintf("public_data_%d", i),
|
||
}
|
||
}
|
||
|
||
largeJSON, err := json.Marshal(largeData)
|
||
suite.NoError(err)
|
||
|
||
// Test that sanitization completes in reasonable time
|
||
result := sanitizeForLogging(largeJSON, "application/json")
|
||
|
||
// Output must be bounded and carry the truncation marker.
|
||
suite.LessOrEqual(len(result), MaxLogBodySize+len(TruncatedSuffix),
|
||
"sanitized large JSON output must be bounded by MaxLogBodySize")
|
||
suite.True(strings.HasSuffix(result, TruncatedSuffix),
|
||
"sanitized large JSON output must carry the truncation marker")
|
||
suite.True(utf8.ValidString(result), "sanitized large JSON output must be valid UTF-8")
|
||
|
||
// Verify sensitive data was redacted (spot check): redaction runs on the
|
||
// parsed structure before re-marshaling and truncating, so no plaintext
|
||
// secret value can appear in the retained window regardless of where the
|
||
// cut lands.
|
||
suite.NotContains(result, "secret0", "plaintext password must not leak")
|
||
suite.NotContains(result, "[email protected]", "plaintext email must not leak")
|
||
suite.Contains(result, RedactedPlaceholder, "retained window should show redaction")
|
||
}
|
||
|
||
// TestEdgeCases tests edge cases and error conditions
|
||
func (suite *ProxyLoggingSecurityTestSuite) TestEdgeCases() {
|
||
suite.Run("Empty body", func() {
|
||
result := sanitizeForLogging([]byte{}, "application/json")
|
||
suite.Equal("", result)
|
||
})
|
||
|
||
suite.Run("Invalid JSON", func() {
|
||
invalidJSON := []byte(`{"invalid": json}`)
|
||
result := sanitizeForLogging(invalidJSON, "application/json")
|
||
// Should fall back to string sanitization
|
||
suite.Contains(result, "invalid")
|
||
})
|
||
|
||
suite.Run("Nil data", func() {
|
||
// Test with nil maps (should not panic)
|
||
sensitiveFields := []string{"password"}
|
||
|
||
// This should not panic
|
||
suite.NotPanics(func() {
|
||
data := make(map[string]any)
|
||
data["test"] = nil
|
||
redactSensitiveFields(data, sensitiveFields)
|
||
})
|
||
})
|
||
|
||
suite.Run("Empty headers", func() {
|
||
result := sanitizeHeaders(map[string]string{})
|
||
suite.Equal(map[string]string{}, result)
|
||
})
|
||
|
||
suite.Run("Very large content type", func() {
|
||
largeContentType := strings.Repeat("json", 1000)
|
||
result := sanitizeForLogging([]byte(`{"test": "data"}`), largeContentType)
|
||
suite.Contains(result, "test")
|
||
})
|
||
}
|
||
|
||
// BenchmarkSanitizeForLogging benchmarks the sanitization function
|
||
func BenchmarkSanitizeForLogging(b *testing.B) {
|
||
testData := map[string]any{
|
||
"username": "testuser",
|
||
"password": "secret123",
|
||
"api_key": "sk-123456789",
|
||
"data": "normal data",
|
||
"nested": map[string]any{
|
||
"token": "nested-token",
|
||
"value": "nested-value",
|
||
},
|
||
}
|
||
|
||
jsonData, _ := json.Marshal(testData)
|
||
|
||
b.ResetTimer()
|
||
for i := 0; i < b.N; i++ {
|
||
sanitizeForLogging(jsonData, "application/json")
|
||
}
|
||
}
|
||
|
||
// BenchmarkSanitizeHeaders benchmarks header sanitization
|
||
func BenchmarkSanitizeHeaders(b *testing.B) {
|
||
headers := map[string]string{
|
||
"Content-Type": "application/json",
|
||
"Authorization": "Bearer token123",
|
||
"X-API-Key": "sk-123456",
|
||
"User-Agent": "Test/1.0",
|
||
"Accept": "application/json",
|
||
"Content-Length": "100",
|
||
}
|
||
|
||
b.ResetTimer()
|
||
for i := 0; i < b.N; i++ {
|
||
sanitizeHeaders(headers)
|
||
}
|
||
}
|
||
|
||
func (suite *ProxyLoggingSecurityTestSuite) TestSanitizeTruncatedBodyRedactsAndKeepsUTF8() {
|
||
// A large body whose JSON parse fails (malformed JSON) must still redact
|
||
// sensitive fields in the retained prefix, and the truncation must not
|
||
// cut a multi-byte UTF-8 codepoint.
|
||
secret := "hunter2-S3cret"
|
||
big := strings.Repeat("a", MaxLogBodySize+10)
|
||
// Unterminated string -> JSON decode fails -> falls through to truncation.
|
||
malformed := `{"password": "` + secret + `"` + big
|
||
|
||
result := sanitizeForLogging([]byte(malformed), "application/json")
|
||
if strings.Contains(result, secret) {
|
||
suite.T().Errorf("truncated body leaked secret: %q", result)
|
||
}
|
||
if !strings.Contains(result, RedactedPlaceholder) {
|
||
suite.T().Errorf("expected redaction placeholder in %q", result)
|
||
}
|
||
if !utf8.ValidString(result) {
|
||
suite.T().Errorf("result is not valid UTF-8: %q", result)
|
||
}
|
||
if !strings.HasSuffix(result, TruncatedSuffix) {
|
||
suite.T().Errorf("expected truncated suffix in %q", result)
|
||
}
|
||
}
|
||
|
||
func (suite *ProxyLoggingSecurityTestSuite) TestTruncateUTF8RuneBoundary() {
|
||
// A multi-byte rune straddling the byte cutoff must be backed off, not split.
|
||
orig := strings.Repeat("a", MaxLogBodySize-1) + "界"
|
||
out := truncateUTF8(orig, MaxLogBodySize)
|
||
if !utf8.ValidString(out) {
|
||
suite.T().Fatalf("truncateUTF8 produced invalid UTF-8: %x", out)
|
||
}
|
||
if len(out) != MaxLogBodySize-1 {
|
||
suite.T().Fatalf("expected backoff to %d bytes, got %d", MaxLogBodySize-1, len(out))
|
||
}
|
||
}
|
||
|
||
// TestTruncateUTF8 covers the truncateUTF8 backoff logic directly: it must
|
||
// never validate the whole string (that emptied any body with a single
|
||
// invalid byte anywhere), and it must stay O(1) backoff bounded by
|
||
// utf8.UTFMax-1, not O(n) whole-string re-validation per byte removed.
|
||
func (suite *ProxyLoggingSecurityTestSuite) TestTruncateUTF8() {
|
||
const limit = 100
|
||
|
||
tests := []struct {
|
||
name string
|
||
input string
|
||
maxBytes int
|
||
checkFunc func(t *testing.T, out string)
|
||
}{
|
||
{
|
||
name: "multi-byte rune straddling the cut is backed off, not split",
|
||
input: strings.Repeat("a", limit-1) + "界", // 界 is 3 bytes, starts at limit-1
|
||
maxBytes: limit,
|
||
checkFunc: func(t *testing.T, out string) {
|
||
if !utf8.ValidString(out) {
|
||
t.Fatalf("output is not valid UTF-8: %x", out)
|
||
}
|
||
if out != strings.Repeat("a", limit-1) {
|
||
t.Fatalf("expected the partial rune dropped entirely, got %q", out)
|
||
}
|
||
},
|
||
},
|
||
{
|
||
name: "invalid byte early in the string cuts near the limit, not to empty",
|
||
input: strings.Repeat("a", limit-1) + string([]byte{0x80}) + strings.Repeat("b", 500),
|
||
maxBytes: limit,
|
||
checkFunc: func(t *testing.T, out string) {
|
||
if out == "" {
|
||
t.Fatalf("truncateUTF8 emptied a string with a single stray invalid byte")
|
||
}
|
||
if len(out) < limit-utf8.UTFMax {
|
||
t.Fatalf("expected cut near the %d-byte limit, got %d bytes: %q", limit, len(out), out)
|
||
}
|
||
},
|
||
},
|
||
{
|
||
name: "pure ASCII is cut exactly at maxBytes, unchanged behavior",
|
||
input: strings.Repeat("x", 500),
|
||
maxBytes: limit,
|
||
checkFunc: func(t *testing.T, out string) {
|
||
if len(out) != limit {
|
||
t.Fatalf("expected exact cut at %d bytes, got %d", limit, len(out))
|
||
}
|
||
if out != strings.Repeat("x", limit) {
|
||
t.Fatalf("unexpected ASCII truncation result: %q", out)
|
||
}
|
||
},
|
||
},
|
||
{
|
||
name: "string shorter than maxBytes is returned unchanged",
|
||
input: "short string",
|
||
maxBytes: limit,
|
||
checkFunc: func(t *testing.T, out string) {
|
||
if out != "short string" {
|
||
t.Fatalf("expected input unchanged, got %q", out)
|
||
}
|
||
},
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
suite.Run(tt.name, func() {
|
||
out := truncateUTF8(tt.input, tt.maxBytes)
|
||
tt.checkFunc(suite.T(), out)
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestTruncateUTF8NotQuadratic guards the O(n^2) regression: truncating a
|
||
// body that is invalid UTF-8 overall (one stray byte, otherwise huge) must
|
||
// stay fast and must not collapse the result to empty.
|
||
func (suite *ProxyLoggingSecurityTestSuite) TestTruncateUTF8NotQuadratic() {
|
||
body := string([]byte{0x80}) + strings.Repeat("a", MaxLogBodySize*10)
|
||
out := truncateUTF8(body, MaxLogBodySize)
|
||
if len(out) == 0 {
|
||
suite.T().Fatalf("truncateUTF8 collapsed an otherwise-valid large body to empty")
|
||
}
|
||
if len(out) < MaxLogBodySize-utf8.UTFMax {
|
||
suite.T().Fatalf("expected cut near %d bytes, got %d", MaxLogBodySize, len(out))
|
||
}
|
||
}
|
||
|
||
// TestSanitizeTruncatedBodySecretStraddlingCutNotExposed verifies that a
|
||
// sensitive field value whose closing quote falls past the MaxLogBodySize
|
||
// cut point (so the "field":"value" redaction regex cannot match it) is
|
||
// still not leaked in the truncated log line.
|
||
func (suite *ProxyLoggingSecurityTestSuite) TestSanitizeTruncatedBodySecretStraddlingCutNotExposed() {
|
||
secret := "S3cretStraddle-" + strings.Repeat("Z", 100)
|
||
head := strings.Repeat("x", MaxLogBodySize-50)
|
||
body := head + `"password": "` + secret + `"}`
|
||
suite.Greater(len(body), MaxLogBodySize, "test body must exceed MaxLogBodySize to hit the truncation path")
|
||
|
||
// Sanity check: the opening quote of the secret value must land before
|
||
// the cut, and the closing quote after it, so the value straddles it.
|
||
openIdx := strings.Index(body, secret)
|
||
suite.Less(openIdx, MaxLogBodySize, "secret must start before the cut")
|
||
suite.Greater(openIdx+len(secret), MaxLogBodySize, "secret must extend past the cut")
|
||
|
||
result := sanitizeForLogging([]byte(body), "text/plain")
|
||
|
||
suite.NotContains(result, secret, "full secret leaked")
|
||
suite.NotContains(result, "S3cretStraddle", "partial secret prefix leaked")
|
||
suite.True(utf8.ValidString(result), "result must be valid UTF-8")
|
||
suite.True(strings.HasSuffix(result, TruncatedSuffix), "result must carry the truncated suffix")
|
||
}
|
||
|
||
// TestSanitizeForLoggingJSONBoundary covers the JSON path's size cap
|
||
// (Defect A): a redacted-then-remarshaled JSON body larger than
|
||
// MaxLogBodySize must be bounded and carry the truncation marker, exactly
|
||
// like the non-JSON path, while a body under the cap is unaffected.
|
||
func (suite *ProxyLoggingSecurityTestSuite) TestSanitizeForLoggingJSONBoundary() {
|
||
tests := []struct {
|
||
name string
|
||
build func() []byte
|
||
checkFunc func(t *testing.T, result string)
|
||
}{
|
||
{
|
||
name: "JSON body over MaxLogBodySize is bounded and redacted",
|
||
build: func() []byte {
|
||
input := map[string]any{
|
||
"password": "JsonSecretOverCap-DoNotLeak",
|
||
"padding": strings.Repeat("p", MaxLogBodySize*2),
|
||
}
|
||
b, err := json.Marshal(input)
|
||
suite.Require().NoError(err)
|
||
suite.Require().Greater(len(b), MaxLogBodySize, "test body must exceed MaxLogBodySize")
|
||
return b
|
||
},
|
||
checkFunc: func(t *testing.T, result string) {
|
||
if len(result) > MaxLogBodySize+len(TruncatedSuffix) {
|
||
t.Fatalf("result not bounded: got %d bytes", len(result))
|
||
}
|
||
if !strings.HasSuffix(result, TruncatedSuffix) {
|
||
t.Fatalf("expected truncation marker, got %q", result)
|
||
}
|
||
if !utf8.ValidString(result) {
|
||
t.Fatalf("result is not valid UTF-8: %q", result)
|
||
}
|
||
if strings.Contains(result, "JsonSecretOverCap") {
|
||
t.Fatalf("secret leaked in truncated JSON output: %q", result)
|
||
}
|
||
},
|
||
},
|
||
{
|
||
name: "JSON body under MaxLogBodySize is unchanged behavior",
|
||
build: func() []byte {
|
||
input := map[string]any{
|
||
"password": "shortsecret",
|
||
"name": "ok",
|
||
}
|
||
b, err := json.Marshal(input)
|
||
suite.Require().NoError(err)
|
||
suite.Require().Less(len(b), MaxLogBodySize, "test body must stay under MaxLogBodySize")
|
||
return b
|
||
},
|
||
checkFunc: func(t *testing.T, result string) {
|
||
if strings.HasSuffix(result, TruncatedSuffix) {
|
||
t.Fatalf("small JSON body should not be truncated, got %q", result)
|
||
}
|
||
var sanitized map[string]any
|
||
if err := json.Unmarshal([]byte(result), &sanitized); err != nil {
|
||
t.Fatalf("result should be valid JSON: %v", err)
|
||
}
|
||
if sanitized["password"] != RedactedPlaceholder {
|
||
t.Fatalf("expected password redacted, got %v", sanitized["password"])
|
||
}
|
||
if sanitized["name"] != "ok" {
|
||
t.Fatalf("expected non-sensitive field preserved, got %v", sanitized["name"])
|
||
}
|
||
},
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
suite.Run(tt.name, func() {
|
||
body := tt.build()
|
||
result := sanitizeForLogging(body, "application/json")
|
||
tt.checkFunc(suite.T(), result)
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestSanitizeTruncatedBodyXMLTagBoundary covers Defect B: a sensitive XML
|
||
// open tag whose closing tag falls past the MaxLogBodySize cut must be
|
||
// stripped from the truncated output (the closing-tag-requiring XML
|
||
// redaction pattern otherwise cannot match it), a sensitive tag that is
|
||
// fully closed before the cut must still be redacted as before, and a
|
||
// non-sensitive tag straddling the cut must be left untouched.
|
||
func (suite *ProxyLoggingSecurityTestSuite) TestSanitizeTruncatedBodyXMLTagBoundary() {
|
||
tests := []struct {
|
||
name string
|
||
build func() string
|
||
checkFunc func(t *testing.T, result string)
|
||
}{
|
||
{
|
||
name: "sensitive XML tag straddling the cut is stripped",
|
||
build: func() string {
|
||
head := strings.Repeat("x", 900)
|
||
secret := "S3cretXmlStraddle_" + strings.Repeat("Z", 200)
|
||
body := head + "<password>" + secret + "</password>"
|
||
suite.Require().Greater(len(body), MaxLogBodySize, "test body must exceed MaxLogBodySize")
|
||
return body
|
||
},
|
||
checkFunc: func(t *testing.T, result string) {
|
||
if strings.Contains(result, "S3cretXmlStraddle") {
|
||
t.Fatalf("leading secret bytes leaked in truncated output: %q", result)
|
||
}
|
||
if !strings.HasSuffix(result, TruncatedSuffix) {
|
||
t.Fatalf("expected truncation marker, got %q", result)
|
||
}
|
||
if !utf8.ValidString(result) {
|
||
t.Fatalf("result is not valid UTF-8: %q", result)
|
||
}
|
||
},
|
||
},
|
||
{
|
||
name: "sensitive XML tag fully inside the cap is still redacted",
|
||
build: func() string {
|
||
secret := "FullyInsideXmlSecret"
|
||
body := "<password>" + secret + "</password>" + strings.Repeat("y", MaxLogBodySize+500)
|
||
suite.Require().Greater(len(body), MaxLogBodySize, "test body must exceed MaxLogBodySize")
|
||
return body
|
||
},
|
||
checkFunc: func(t *testing.T, result string) {
|
||
if strings.Contains(result, "FullyInsideXmlSecret") {
|
||
t.Fatalf("secret that was fully inside the cap leaked: %q", result)
|
||
}
|
||
if !strings.Contains(result, RedactedPlaceholder) {
|
||
t.Fatalf("expected redaction marker for the fully-contained tag, got %q", result)
|
||
}
|
||
},
|
||
},
|
||
{
|
||
name: "non-sensitive tag straddling the cut is preserved",
|
||
build: func() string {
|
||
head := strings.Repeat("x", 900)
|
||
marker := "SAFE_CONTENT_MARKER_" + strings.Repeat("Q", 200)
|
||
body := head + "<description>" + marker + "</description>"
|
||
suite.Require().Greater(len(body), MaxLogBodySize, "test body must exceed MaxLogBodySize")
|
||
return body
|
||
},
|
||
checkFunc: func(t *testing.T, result string) {
|
||
if !strings.Contains(result, "SAFE_CONTENT_MARKER") {
|
||
t.Fatalf("non-sensitive content should be preserved, got %q", result)
|
||
}
|
||
if !strings.HasSuffix(result, TruncatedSuffix) {
|
||
t.Fatalf("expected truncation marker, got %q", result)
|
||
}
|
||
},
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
suite.Run(tt.name, func() {
|
||
body := tt.build()
|
||
result := sanitizeForLogging([]byte(body), "application/xml")
|
||
tt.checkFunc(suite.T(), result)
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestSanitizeTruncatedBodyTrimsPartialRuneOnPlainBody verifies the
|
||
// rune-boundary trim in sanitizeTruncatedBody runs on the raw
|
||
// body[:MaxLogBodySize] cut, not only in the final truncateUTF8 call. The
|
||
// body here is plain non-JSON UTF-8 prose with no quotes and no sensitive
|
||
// field names, so redactPatternInString/stripTrailingUnterminatedQuote
|
||
// cannot match anything and cannot shrink the prefix: by the time the final
|
||
// truncateUTF8 safety-net call runs, len(prefix) is already <=
|
||
// MaxLogBodySize, so its len-guard makes it a no-op. Only trimming the raw
|
||
// cut up front (before redaction) can remove the multi-byte rune straddling
|
||
// byte MaxLogBodySize here.
|
||
func (suite *ProxyLoggingSecurityTestSuite) TestSanitizeTruncatedBodyTrimsPartialRuneOnPlainBody() {
|
||
// "界" is 3 bytes and starts at byte MaxLogBodySize-1, so it straddles
|
||
// the cut point exactly like TestTruncateUTF8RuneBoundary's input, but
|
||
// driven through the full sanitizeForLogging -> sanitizeTruncatedBody
|
||
// path instead of calling truncateUTF8 directly.
|
||
body := strings.Repeat("a", MaxLogBodySize-1) + "界" + strings.Repeat("b", 200)
|
||
suite.Greater(len(body), MaxLogBodySize, "test body must exceed MaxLogBodySize to hit the truncation path")
|
||
|
||
result := sanitizeForLogging([]byte(body), "text/plain")
|
||
|
||
suite.True(utf8.ValidString(result), "result must be valid UTF-8")
|
||
suite.True(strings.HasSuffix(result, TruncatedSuffix), "result must carry the truncated suffix")
|
||
suite.NotContains(result, "界", "a split multi-byte rune must not survive truncation")
|
||
}
|
||
|
||
// TestStripTrailingUnterminatedTagClosingTagSplitByCutNotExposed covers the
|
||
// MUST-1 defect: stripTrailingUnterminatedTag treated a bare "</" left at
|
||
// the tail of a truncated prefix as proof the value was "already redacted",
|
||
// even when the closing tag itself was split by the truncation cut (e.g.
|
||
// "...secretvalue</pa"). The XML redaction pattern needs the complete
|
||
// "</field>" tag to match, so a split tag was never actually redacted and
|
||
// the secret leaked. The fix requires the complete closing tag before
|
||
// leaving the value alone.
|
||
func (suite *ProxyLoggingSecurityTestSuite) TestStripTrailingUnterminatedTagClosingTagSplitByCutNotExposed() {
|
||
secret := "SECRETLEAKME"
|
||
openTag := "<password>"
|
||
closeTag := "</password>"
|
||
const splitPoint = 4 // retains "</pa" - a partial closing tag, not the complete "</password>"
|
||
head := strings.Repeat("x", MaxLogBodySize-len(openTag)-len(secret)-splitPoint)
|
||
body := head + openTag + secret + closeTag
|
||
suite.Require().Greater(len(body), MaxLogBodySize, "test body must exceed MaxLogBodySize to hit the truncation path")
|
||
|
||
result := sanitizeForLogging([]byte(body), "application/xml")
|
||
|
||
suite.NotContains(result, secret, "secret leaked when its closing tag was split by the truncation cut")
|
||
suite.True(utf8.ValidString(result), "result must be valid UTF-8")
|
||
suite.True(strings.HasSuffix(result, TruncatedSuffix), "result must carry the truncated suffix")
|
||
}
|
||
|
||
// TestStripTrailingUnterminatedTagByteDriftFromCaseFold covers the MUST-2
|
||
// defect: stripTrailingUnterminatedTag computed cut as a byte index into
|
||
// strings.ToLower(s), then applied that index to s directly. strings.ToLower
|
||
// is not byte-length-preserving for every rune, so a multi-byte case-folding
|
||
// rune sitting before a dangling sensitive tag drifted the cut index out of
|
||
// alignment with s. A growing fold (U+023A, +1 byte) pushed the index past
|
||
// the real tag position, so a "cut" that should have removed the tag instead
|
||
// retained it - and the secret with it. A shrinking fold (U+212A, -2 bytes)
|
||
// pulled the index short of the real tag position, landing mid-rune in the
|
||
// still-multi-byte original run and producing invalid UTF-8 in the output.
|
||
func (suite *ProxyLoggingSecurityTestSuite) TestStripTrailingUnterminatedTagByteDriftFromCaseFold() {
|
||
suite.Run("growing case fold (U+023A) before a truncated tag does not retain the secret", func() {
|
||
secret := "DRIFTSECRET"
|
||
openTag := "<password>"
|
||
const foldRune = "Ⱥ" // 2 raw bytes; strings.ToLower grows it to 3 bytes (U+2C65)
|
||
const repeats = 100
|
||
// No closing tag anywhere in the body, so the value is never
|
||
// redacted by the normal XML pattern - only stripTrailingUnterminatedTag's
|
||
// cut can remove it, isolating the index-drift defect from the
|
||
// separate split-closing-tag defect covered above.
|
||
body := strings.Repeat(foldRune, repeats) + openTag + secret + strings.Repeat("y", MaxLogBodySize)
|
||
suite.Require().Greater(len(body), MaxLogBodySize, "test body must exceed MaxLogBodySize to hit the truncation path")
|
||
|
||
result := sanitizeForLogging([]byte(body), "text/plain")
|
||
|
||
suite.NotContains(result, secret, "secret retained: case-fold byte-length growth drifted the cut index past the real tag position")
|
||
suite.True(utf8.ValidString(result), "result must be valid UTF-8")
|
||
})
|
||
|
||
suite.Run("shrinking case fold (U+212A) before a truncated tag yields valid UTF-8", func() {
|
||
secret := "KELVINDRIFTSECRET"
|
||
openTag := "<password>"
|
||
const foldRune = "K" // 3 raw bytes; strings.ToLower shrinks it to 1 byte ('k')
|
||
const repeats = 100
|
||
body := strings.Repeat(foldRune, repeats) + openTag + secret + strings.Repeat("y", MaxLogBodySize)
|
||
suite.Require().Greater(len(body), MaxLogBodySize, "test body must exceed MaxLogBodySize to hit the truncation path")
|
||
|
||
result := sanitizeForLogging([]byte(body), "text/plain")
|
||
|
||
suite.True(utf8.ValidString(result), "case-fold byte-length shrinkage must not slice s mid-rune and produce invalid UTF-8")
|
||
suite.NotContains(result, secret, "secret must not be retained either")
|
||
})
|
||
}
|