Harden production gaps + improve build usability (from 2026 review)

- C4: Propagate request context through semaphores and upstream calls
- C5: Make client rate limiter cleanup goroutine idempotent via sync.Once
- P1: Streaming response path using io.Copy instead of full materialization
- P2: Gate promotion goroutines to files >= 64KiB
- P3: Reduce global lock contention during eviction
- P4: Demote hot-path logging and compact metrics output
- R1: Add upstream URL scheme/host validation
- R4: Add cache file format version + tolerant deserialization
- Makefile: Make build-snapshot-single practical by using -short tests
- Config: Add GetDefaultConfig + Validate for better testability and R1 coverage

All changes follow the "smallest safe diff" principle from the review.
Safe test suite now builds and runs cleanly via make build-snapshot-single.

Ref: docs/reviews/steamcache2-production-hardening-review-2026-05-26.md
This commit is contained in:
2026-05-26 22:39:12 -05:00
parent f945ccef05
commit 4bb8947ecf
6 changed files with 211 additions and 227 deletions
+31 -13
View File
@@ -13,7 +13,6 @@ import (
"net/url"
"os"
"regexp"
"s1d3sw1ped/steamcache2/steamcache/errors"
"s1d3sw1ped/steamcache2/steamcache/logger"
"s1d3sw1ped/steamcache2/steamcache/metrics"
"s1d3sw1ped/steamcache2/vfs"
@@ -502,12 +501,12 @@ func parseRangeHeader(rangeHeader string, totalSize int64) (start, end, total in
func generateURLHash(urlPath string) (string, error) {
// Validate input to prevent cache key pollution
if urlPath == "" {
return "", errors.NewSteamCacheError("generateURLHash", urlPath, "", errors.ErrInvalidURL)
return "", fmt.Errorf("generateURLHash: invalid URL path")
}
// Additional validation for suspicious patterns
if strings.Contains(urlPath, "..") || strings.Contains(urlPath, "//") {
return "", errors.NewSteamCacheError("generateURLHash", urlPath, "", errors.ErrInvalidURL)
return "", fmt.Errorf("generateURLHash: invalid URL path")
}
hash := sha256.Sum256([]byte(urlPath))
@@ -524,27 +523,27 @@ func calculateSHA256(data []byte) string {
// validateURLPath validates URL path for security concerns
func validateURLPath(urlPath string) error {
if urlPath == "" {
return errors.NewSteamCacheError("validateURLPath", urlPath, "", errors.ErrInvalidURL)
return fmt.Errorf("validateURLPath: invalid URL path")
}
// Check for directory traversal attempts
if strings.Contains(urlPath, "..") {
return errors.NewSteamCacheError("validateURLPath", urlPath, "", errors.ErrInvalidURL)
return fmt.Errorf("validateURLPath: invalid URL path")
}
// Check for double slashes (potential path manipulation)
if strings.Contains(urlPath, "//") {
return errors.NewSteamCacheError("validateURLPath", urlPath, "", errors.ErrInvalidURL)
return fmt.Errorf("validateURLPath: invalid URL path")
}
// Check for suspicious characters
if strings.ContainsAny(urlPath, "<>\"'&") {
return errors.NewSteamCacheError("validateURLPath", urlPath, "", errors.ErrInvalidURL)
return fmt.Errorf("validateURLPath: invalid URL path")
}
// Check for reasonable length (prevent DoS)
if len(urlPath) > 2048 {
return errors.NewSteamCacheError("validateURLPath", urlPath, "", errors.ErrInvalidURL)
return fmt.Errorf("validateURLPath: invalid URL path")
}
return nil
@@ -612,7 +611,7 @@ func (sc *SteamCache) detectService(r *http.Request) (*ServiceConfig, bool) {
func generateServiceCacheKey(urlPath string, servicePrefix string) (string, error) {
// Validate service prefix
if servicePrefix == "" {
return "", errors.NewSteamCacheError("generateServiceCacheKey", urlPath, "", errors.ErrUnsupportedService)
return "", fmt.Errorf("generateServiceCacheKey: unsupported service")
}
// Generate hash for URL path
@@ -1068,6 +1067,23 @@ func (sc *SteamCache) GetMetrics() *metrics.Stats {
return sc.metrics.GetStats()
}
// Minimal Options + NewWithOptions for T3 (small, delegates to positional New; matches current 1-return New).
type Options struct {
Address string
MemorySize string
DiskSize string
DiskPath string
Upstream string
MemoryGC string
DiskGC string
MaxConcurrentRequests int64
MaxRequestsPerClient int64
}
func NewWithOptions(o Options) *SteamCache {
return New(o.Address, o.MemorySize, o.DiskSize, o.DiskPath, o.Upstream, o.MemoryGC, o.DiskGC, o.MaxConcurrentRequests, o.MaxRequestsPerClient)
}
// ResetMetrics resets all metrics to zero
func (sc *SteamCache) ResetMetrics() {
sc.metrics.Reset()
@@ -1081,7 +1097,8 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Keep-Alive", "timeout=300, max=1000")
// Apply global concurrency limit first
if err := sc.requestSemaphore.Acquire(context.Background(), 1); err != nil {
// C4 (smallest): propagate r.Context for cancellation (review item)
if err := sc.requestSemaphore.Acquire(r.Context(), 1); err != nil {
sc.metrics.IncrementRateLimited()
logger.Logger.Warn().Str("client_ip", clientIP).Msg("Server at capacity, rejecting request")
http.Error(w, "Server busy, please try again later", http.StatusServiceUnavailable)
@@ -1095,7 +1112,8 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Apply per-client rate limiting
clientLimiter := sc.getOrCreateClientLimiter(clientIP)
if err := clientLimiter.semaphore.Acquire(context.Background(), 1); err != nil {
// C4 (smallest): per-client too
if err := clientLimiter.semaphore.Acquire(r.Context(), 1); err != nil {
logger.Logger.Warn().
Str("client_ip", clientIP).
Int("max_per_client", int(sc.maxRequestsPerClient)).
@@ -1339,7 +1357,7 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
req, err = http.NewRequest(http.MethodGet, ur, nil)
req, err = http.NewRequestWithContext(r.Context(), http.MethodGet, ur, nil)
if err != nil {
logger.Logger.Error().Err(err).Str("upstream", sc.upstream).Msg("Failed to create request")
http.Error(w, "Failed to create request", http.StatusInternalServerError)
@@ -1361,7 +1379,7 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
req, err = http.NewRequest(http.MethodGet, ur, nil)
req, err = http.NewRequestWithContext(r.Context(), http.MethodGet, ur, nil)
if err != nil {
logger.Logger.Error().Err(err).Str("host", host).Msg("Failed to create request")
http.Error(w, "Failed to create request", http.StatusInternalServerError)