Improve caching by adding user ids and roles to hash.

This commit is contained in:
2025-11-22 17:02:16 +00:00
parent 28223b40da
commit 39dc7b49cf
11 changed files with 198 additions and 43 deletions
+42 -4
View File
@@ -3,6 +3,7 @@ package libpack_cache
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"sync/atomic"
"time"
@@ -27,7 +28,9 @@ type CacheConfig struct {
MaxMemorySize int64 `json:"max_memory_size"` // Maximum memory size in bytes
MaxEntries int64 `json:"max_entries"` // Maximum number of entries
}
TTL int `json:"ttl"`
TTL int `json:"ttl"`
IncludeUserContext bool `json:"include_user_context"` // Include user ID and role in cache key
PerUserCacheDisabled bool `json:"per_user_cache_disabled"` // Disable per-user caching (backward compatibility)
}
type CacheStats struct {
@@ -52,10 +55,14 @@ var (
config *CacheConfig
)
// CalculateHash generates an MD5 hash from the request body.
// CalculateHash generates an MD5 hash from the request body and optionally user context.
// For GraphQL requests, this includes both the query and variables,
// ensuring that identical queries with different variables are cached separately.
//
// SECURITY FIX: This function now includes user ID and role in the cache key by default
// to prevent data leakage between authenticated users. Set CACHE_PER_USER_DISABLED=true
// to revert to the old behavior (NOT RECOMMENDED for multi-user applications).
//
// Example GraphQL request body:
//
// {
@@ -63,8 +70,39 @@ var (
// "variables": { "id": "123" }
// }
//
// Different variable values will produce different cache keys.
func CalculateHash(c *fiber.Ctx) string {
// With user context enabled (default):
// - Same query, same variables, same user → same cache key
// - Same query, same variables, different user → different cache key
//
// Different variable values will always produce different cache keys.
func CalculateHash(c *fiber.Ctx, userID string, userRole string) string {
cacheKeyData := string(c.Body())
// Include user context in cache key (default behavior for security)
// Only skip if explicitly disabled via configuration (backward compatibility)
if config != nil && !config.PerUserCacheDisabled {
// Normalize empty user values to prevent cache key collisions
if userID == "" {
userID = "-"
}
if userRole == "" {
userRole = "-"
}
// Append user context to ensure cache isolation between users
cacheKeyData = fmt.Sprintf("%s|uid:%s|role:%s", cacheKeyData, userID, userRole)
}
return strutil.Md5(cacheKeyData)
}
// CalculateHashLegacy generates a cache hash using only the request body (DEPRECATED).
// This function exists for backward compatibility only and should NOT be used
// in production multi-user applications as it creates a security vulnerability
// where users can see each other's cached data.
//
// Deprecated: Use CalculateHash with user context instead.
func CalculateHashLegacy(c *fiber.Ctx) string {
return strutil.Md5(c.Body())
}
+78 -16
View File
@@ -20,7 +20,7 @@ func (suite *Tests) Test_CalculateHash() {
// Test with empty body
suite.Run("empty body", func() {
ctx.Request().SetBody([]byte(""))
hash := CalculateHash(ctx)
hash := CalculateHash(ctx, "user1", "admin")
assert.NotEmpty(hash)
assert.Equal(32, len(hash)) // MD5 hash is 32 characters
})
@@ -28,7 +28,7 @@ func (suite *Tests) Test_CalculateHash() {
// Test with non-empty body
suite.Run("non-empty body", func() {
ctx.Request().SetBody([]byte("test body"))
hash := CalculateHash(ctx)
hash := CalculateHash(ctx, "user1", "admin")
assert.NotEmpty(hash)
assert.Equal(32, len(hash))
})
@@ -36,10 +36,10 @@ func (suite *Tests) Test_CalculateHash() {
// Test with different bodies produce different hashes
suite.Run("different bodies", func() {
ctx.Request().SetBody([]byte("body1"))
hash1 := CalculateHash(ctx)
hash1 := CalculateHash(ctx, "user1", "admin")
ctx.Request().SetBody([]byte("body2"))
hash2 := CalculateHash(ctx)
hash2 := CalculateHash(ctx, "user1", "admin")
assert.NotEqual(hash1, hash2)
})
@@ -51,10 +51,10 @@ func (suite *Tests) Test_CalculateHash() {
query2 := []byte(`{"query":"query GetUser($id: ID!) { user(id: $id) { name } }","variables":{"id":"456"}}`)
ctx.Request().SetBody(query1)
hash1 := CalculateHash(ctx)
hash1 := CalculateHash(ctx, "user1", "admin")
ctx.Request().SetBody(query2)
hash2 := CalculateHash(ctx)
hash2 := CalculateHash(ctx, "user1", "admin")
assert.NotEqual(hash1, hash2, "Different variables should produce different cache keys")
})
@@ -66,13 +66,83 @@ func (suite *Tests) Test_CalculateHash() {
query2 := []byte(`{"query":"query GetUsers { users { name } }","variables":{}}`)
ctx.Request().SetBody(query1)
hash1 := CalculateHash(ctx)
hash1 := CalculateHash(ctx, "user1", "admin")
ctx.Request().SetBody(query2)
hash2 := CalculateHash(ctx)
hash2 := CalculateHash(ctx, "user1", "admin")
assert.NotEqual(hash1, hash2, "Query with and without variables object should produce different cache keys")
})
// SECURITY TEST: Different users should get different cache keys
suite.Run("different users produce different cache keys", func() {
// Same query, same variables, but different users - CRITICAL SECURITY TEST
query := []byte(`{"query":"query GetMyProfile { me { id email } }"}`)
ctx.Request().SetBody(query)
hash1 := CalculateHash(ctx, "user1", "admin")
hash2 := CalculateHash(ctx, "user2", "user")
assert.NotEqual(hash1, hash2, "Different users MUST produce different cache keys to prevent data leakage")
})
// SECURITY TEST: Same user should get same cache key
suite.Run("same user produces same cache key", func() {
// Same query, same user
query := []byte(`{"query":"query GetMyProfile { me { id email } }"}`)
ctx.Request().SetBody(query)
hash1 := CalculateHash(ctx, "user1", "admin")
hash2 := CalculateHash(ctx, "user1", "admin")
assert.Equal(hash1, hash2, "Same user should get same cache key for cache effectiveness")
})
// SECURITY TEST: Different roles should get different cache keys
suite.Run("different roles produce different cache keys", func() {
// Same query, same user ID, but different roles
query := []byte(`{"query":"query GetData { data { value } }"}`)
ctx.Request().SetBody(query)
hash1 := CalculateHash(ctx, "user1", "admin")
hash2 := CalculateHash(ctx, "user1", "user")
assert.NotEqual(hash1, hash2, "Different roles MUST produce different cache keys to prevent privilege escalation")
})
// SECURITY TEST: Empty user context should be normalized
suite.Run("empty user context is normalized", func() {
query := []byte(`{"query":"query GetPublic { public { data } }"}`)
ctx.Request().SetBody(query)
// Empty strings should be normalized to "-"
hash1 := CalculateHash(ctx, "", "")
hash2 := CalculateHash(ctx, "-", "-")
assert.Equal(hash1, hash2, "Empty user context should be normalized to prevent cache key collisions")
})
// BACKWARD COMPATIBILITY TEST: Legacy mode without user context
suite.Run("legacy mode without user context", func() {
// Setup config with per-user cache disabled
oldConfig := config
config = &CacheConfig{
Logger: libpack_logger.New(),
Client: libpack_cache_memory.New(5 * time.Minute),
TTL: 60,
PerUserCacheDisabled: true, // Disable per-user caching
}
defer func() { config = oldConfig }()
query := []byte(`{"query":"query GetData { data { value } }"}`)
ctx.Request().SetBody(query)
// In legacy mode, different users should get the SAME cache key (backward compatibility)
hash1 := CalculateHash(ctx, "user1", "admin")
hash2 := CalculateHash(ctx, "user2", "user")
assert.Equal(hash1, hash2, "With per-user cache disabled, all users get same cache key (backward compatibility)")
})
}
func (suite *Tests) Test_CacheDelete() {
@@ -112,8 +182,6 @@ func (suite *Tests) Test_CacheDelete() {
suite.Run("uninitialized cache", func() {
// Save current config
oldConfig := config
// Set config to nil
config = nil
// This should not cause any errors
@@ -156,8 +224,6 @@ func (suite *Tests) Test_CacheStoreWithTTL() {
suite.Run("uninitialized cache", func() {
// Save current config
oldConfig := config
// Set config to nil
config = nil
// This should not cause any errors
@@ -194,8 +260,6 @@ func (suite *Tests) Test_CacheGetQueries() {
suite.Run("uninitialized cache", func() {
// Save current config
oldConfig := config
// Set config to nil
config = nil
// This should return 0
@@ -280,8 +344,6 @@ func (suite *Tests) Test_GetCacheStats() {
suite.Run("uninitialized cache", func() {
// Save current config
oldConfig := config
// Set config to nil
config = nil
// This should return empty stats