Restrict Host-based origin fetches to Steam CDN names.
CI / vulncheck (pull_request) Successful in 7s
CI / check-and-test (pull_request) Successful in 28s

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.
This commit is contained in:
s1d3sw1ped_bot
2026-08-31 23:56:03 +00:00
parent d63d7b4d3c
commit 8e8e877533
4 changed files with 116 additions and 5 deletions
+12
View File
@@ -345,6 +345,18 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
req.Host = r.Host req.Host = r.Host
} else { // if no upstream server is configured, proxy the request to the host specified in the request } else { // if no upstream server is configured, proxy the request to the host specified in the request
host := r.Host 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" { if r.Header.Get("X-Sls-Https") == "enable" {
host = "https://" + host host = "https://" + host
} else { } else {
+42
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net"
"net/http" "net/http"
"regexp" "regexp"
"strings" "strings"
@@ -163,3 +164,44 @@ func generateServiceCacheKey(urlPath string, servicePrefix string) (string, erro
} }
return servicePrefix + "/" + hash, nil 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
}
+4 -5
View File
@@ -387,11 +387,10 @@ func newHTTPClient(transport *http.Transport) *http.Client {
Timeout: 60 * time.Second, // Optimized timeout for better responsiveness Timeout: 60 * time.Second, // Optimized timeout for better responsiveness
// Add redirect policy for better performance // Add redirect policy for better performance
CheckRedirect: func(req *http.Request, via []*http.Request) error { CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Limit redirects to prevent infinite loops // Do not follow redirects. Steam CDN chunk/manifest fetches are
if len(via) >= 10 { // expected to be 200; following Location would let an origin send
return http.ErrUseLastResponse // the cache at an arbitrary internal URL.
} return http.ErrUseLastResponse
return nil
}, },
} }
} }
+58
View File
@@ -1165,3 +1165,61 @@ func TestClientRateLimiter_BlackBox(t *testing.T) {
t.Error("different clients must have distinct limiters") 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)
}
}