Restrict Host-based fetches to Steam CDN names (#13)
Allowlist steamcontent/steampowered/steamstatic Hosts; do not follow upstream redirects.
This commit was merged in pull request #13.
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user