8e8e877533
When upstream is empty the cache used the client Host as the fetch URL, so any LAN client with a spoofed Steam User-Agent could proxy to literal IPs or arbitrary names. Reject those hosts, stop following upstream redirects, and keep path-only cache keys so real Steam CDNs still share entries.
208 lines
5.9 KiB
Go
208 lines
5.9 KiB
Go
// steamcache/service.go
|
|
package steamcache
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// ServiceConfig defines configuration for a cacheable service
|
|
type ServiceConfig struct {
|
|
Name string `json:"name"` // Service name (e.g., "steam", "epic", "origin")
|
|
Prefix string `json:"prefix"` // Cache key prefix (e.g., "steam", "epic")
|
|
UserAgents []string `json:"user_agents"` // User-Agent patterns to match
|
|
compiled []*regexp.Regexp // Compiled regex patterns (internal use)
|
|
}
|
|
|
|
// ServiceManager manages service configurations
|
|
type ServiceManager struct {
|
|
services map[string]*ServiceConfig
|
|
mutex sync.RWMutex
|
|
}
|
|
|
|
// NewServiceManager creates a new service manager with default Steam configuration
|
|
func NewServiceManager() *ServiceManager {
|
|
sm := &ServiceManager{
|
|
services: make(map[string]*ServiceConfig),
|
|
}
|
|
|
|
// Add default Steam service configuration
|
|
steamConfig := &ServiceConfig{
|
|
Name: "steam",
|
|
Prefix: "steam",
|
|
UserAgents: []string{
|
|
`Valve/Steam HTTP Client 1\.0`,
|
|
`SteamClient`,
|
|
`Steam`,
|
|
},
|
|
}
|
|
_ = sm.AddService(steamConfig) // error impossible: hardcoded patterns are valid regexes (user-provided services validated in AddService)
|
|
|
|
return sm
|
|
}
|
|
|
|
// AddService adds or updates a service configuration
|
|
func (sm *ServiceManager) AddService(config *ServiceConfig) error {
|
|
sm.mutex.Lock()
|
|
defer sm.mutex.Unlock()
|
|
|
|
// Compile regex patterns
|
|
compiled := make([]*regexp.Regexp, 0, len(config.UserAgents))
|
|
for _, pattern := range config.UserAgents {
|
|
regex, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid regex pattern %q for service %s: %w", pattern, config.Name, err)
|
|
}
|
|
compiled = append(compiled, regex)
|
|
}
|
|
|
|
config.compiled = compiled
|
|
sm.services[config.Name] = config
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetService returns a service configuration by name
|
|
func (sm *ServiceManager) GetService(name string) (*ServiceConfig, bool) {
|
|
sm.mutex.RLock()
|
|
defer sm.mutex.RUnlock()
|
|
|
|
service, exists := sm.services[name]
|
|
return service, exists
|
|
}
|
|
|
|
// DetectService detects which service a request belongs to based on User-Agent
|
|
func (sm *ServiceManager) DetectService(userAgent string) (*ServiceConfig, bool) {
|
|
sm.mutex.RLock()
|
|
defer sm.mutex.RUnlock()
|
|
|
|
for _, service := range sm.services {
|
|
for _, regex := range service.compiled {
|
|
if regex.MatchString(userAgent) {
|
|
return service, true
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, false
|
|
}
|
|
|
|
// ListServices returns all configured services
|
|
func (sm *ServiceManager) ListServices() []*ServiceConfig {
|
|
sm.mutex.RLock()
|
|
defer sm.mutex.RUnlock()
|
|
|
|
services := make([]*ServiceConfig, 0, len(sm.services))
|
|
for _, service := range sm.services {
|
|
services = append(services, service)
|
|
}
|
|
|
|
return services
|
|
}
|
|
|
|
// detectService is a one-line delegation to ServiceManager (per Phase 2).
|
|
// Empty User-Agent yields no match (identical to prior behavior).
|
|
func (sc *SteamCache) detectService(r *http.Request) (*ServiceConfig, bool) {
|
|
return sc.serviceManager.DetectService(r.Header.Get("User-Agent"))
|
|
}
|
|
|
|
// --- Cache key / hash helpers (service-related) ---
|
|
|
|
func generateURLHash(urlPath string) (string, error) {
|
|
if urlPath == "" {
|
|
return "", fmt.Errorf("empty URL path")
|
|
}
|
|
// Additional validation for suspicious patterns (kept for backward compat with prior behavior)
|
|
if strings.Contains(urlPath, "..") || strings.Contains(urlPath, "//") {
|
|
return "", fmt.Errorf("generateURLHash: invalid URL path")
|
|
}
|
|
hash := sha256.Sum256([]byte(urlPath))
|
|
return hex.EncodeToString(hash[:]), nil
|
|
}
|
|
|
|
func calculateSHA256(data []byte) string {
|
|
hasher := sha256.New()
|
|
hasher.Write(data)
|
|
return hex.EncodeToString(hasher.Sum(nil))
|
|
}
|
|
|
|
// validateURLPath validates URL path for security concerns (used early in request handling).
|
|
func validateURLPath(urlPath string) error {
|
|
if urlPath == "" {
|
|
return fmt.Errorf("validateURLPath: invalid URL path")
|
|
}
|
|
if strings.Contains(urlPath, "..") {
|
|
return fmt.Errorf("validateURLPath: invalid URL path")
|
|
}
|
|
if strings.Contains(urlPath, "//") {
|
|
return fmt.Errorf("validateURLPath: invalid URL path")
|
|
}
|
|
if strings.ContainsAny(urlPath, "<>\"'&") {
|
|
return fmt.Errorf("validateURLPath: invalid URL path")
|
|
}
|
|
if len(urlPath) > 2048 {
|
|
return fmt.Errorf("validateURLPath: invalid URL path")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// generateServiceCacheKey creates a cache key from the URL path using SHA256
|
|
// The prefix indicates which service the request came from (detected via User-Agent)
|
|
func generateServiceCacheKey(urlPath string, servicePrefix string) (string, error) {
|
|
if servicePrefix == "" {
|
|
return "", fmt.Errorf("generateServiceCacheKey: unsupported service")
|
|
}
|
|
hash, err := generateURLHash(urlPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return servicePrefix + "/" + hash, nil
|
|
}
|
|
|
|
// requestHostName strips a port and brackets from an HTTP Host header.
|
|
func requestHostName(host string) string {
|
|
host = strings.TrimSpace(host)
|
|
if host == "" {
|
|
return ""
|
|
}
|
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
|
host = h
|
|
}
|
|
return strings.Trim(host, "[]")
|
|
}
|
|
|
|
func hostIsLiteralIP(host string) bool {
|
|
return net.ParseIP(requestHostName(host)) != nil
|
|
}
|
|
|
|
// defaultDirectFetchSuffixes are CDN names Steam actually uses. Applied only when
|
|
// no configured upstream is set and the request Host is used as the fetch target.
|
|
var defaultDirectFetchSuffixes = []string{
|
|
"steamcontent.com",
|
|
"steampowered.com",
|
|
"steamstatic.com",
|
|
}
|
|
|
|
// hostAllowedForDirectFetch reports whether Host may be used as an origin when
|
|
// upstream is empty. Literal IPs are rejected (LAN/metadata SSRF). Names must
|
|
// be Steam CDN suffixes so a spoofed User-Agent cannot turn the cache into an
|
|
// open reverse proxy.
|
|
func hostAllowedForDirectFetch(host string) bool {
|
|
name := strings.ToLower(requestHostName(host))
|
|
if name == "" || hostIsLiteralIP(host) {
|
|
return false
|
|
}
|
|
for _, suf := range defaultDirectFetchSuffixes {
|
|
if name == suf || strings.HasSuffix(name, "."+suf) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|