diff --git a/steamcache/handler.go b/steamcache/handler.go index 1bad934..4f3dbf7 100644 --- a/steamcache/handler.go +++ b/steamcache/handler.go @@ -345,6 +345,18 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) { req.Host = r.Host } else { // if no upstream server is configured, proxy the request to the host specified in the request host := r.Host + if !hostAllowedForDirectFetch(host) { + logger.Logger.Warn(). + Str("host", host). + Str("client_ip", clientIP). + Msg("Rejecting direct-fetch Host (not a Steam CDN name)") + sc.metrics.IncrementErrors() + if isNew { + coalescedReq.complete(nil, fmt.Errorf("host not allowed for direct fetch")) + } + http.Error(w, "Invalid URL", http.StatusBadRequest) + return + } if r.Header.Get("X-Sls-Https") == "enable" { host = "https://" + host } else { diff --git a/steamcache/service.go b/steamcache/service.go index 358db90..3ecdd3a 100644 --- a/steamcache/service.go +++ b/steamcache/service.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "net" "net/http" "regexp" "strings" @@ -163,3 +164,44 @@ func generateServiceCacheKey(urlPath string, servicePrefix string) (string, erro } 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 +} diff --git a/steamcache/steamcache.go b/steamcache/steamcache.go index d629d58..fc943a3 100644 --- a/steamcache/steamcache.go +++ b/steamcache/steamcache.go @@ -387,11 +387,10 @@ func newHTTPClient(transport *http.Transport) *http.Client { Timeout: 60 * time.Second, // Optimized timeout for better responsiveness // Add redirect policy for better performance CheckRedirect: func(req *http.Request, via []*http.Request) error { - // Limit redirects to prevent infinite loops - if len(via) >= 10 { - return http.ErrUseLastResponse - } - return nil + // Do not follow redirects. Steam CDN chunk/manifest fetches are + // expected to be 200; following Location would let an origin send + // the cache at an arbitrary internal URL. + return http.ErrUseLastResponse }, } } diff --git a/steamcache/steamcache_test.go b/steamcache/steamcache_test.go index 8db3061..9762436 100644 --- a/steamcache/steamcache_test.go +++ b/steamcache/steamcache_test.go @@ -1165,3 +1165,61 @@ func TestClientRateLimiter_BlackBox(t *testing.T) { t.Error("different clients must have distinct limiters") } } + +func TestHostAllowedForDirectFetch(t *testing.T) { + allowed := []string{ + "lancache.steamcontent.com", + "cache1-iad1.steamcontent.com:443", + "steamcontent.com", + "content.steampowered.com", + "cdn.steamstatic.com", + } + denied := []string{ + "", + "127.0.0.1", + "127.0.0.1:80", + "[::1]:80", + "192.168.1.1", + "169.254.169.254", + "evil.example", + "example.com", + "notsteamcontent.com", + } + for _, h := range allowed { + if !hostAllowedForDirectFetch(h) { + t.Errorf("expected allowed: %q", h) + } + } + for _, h := range denied { + if hostAllowedForDirectFetch(h) { + t.Errorf("expected denied: %q", h) + } + } +} + +func TestDirectFetchRejectsNonSteamHost(t *testing.T) { + td := t.TempDir() + sc, err := New("127.0.0.1:0", "1MB", "0", td, "", "lru", "lru", 200, 5, "0", nil) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(func() { sc.Shutdown() }) + + req := httptest.NewRequest("GET", "/depot/ssrf/chunk", nil) + req.Host = "127.0.0.1" + req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0") + rec := httptest.NewRecorder() + sc.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("IP Host: expected 400, got %d", rec.Code) + } + + req2 := httptest.NewRequest("GET", "/depot/ssrf/chunk2", nil) + req2.Host = "evil.example" + req2.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0") + rec2 := httptest.NewRecorder() + sc.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusBadRequest { + t.Errorf("non-CDN Host: expected 400, got %d", rec2.Code) + } +}