diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index 247f025..9d82ed5 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -107,4 +107,3 @@ func TestHandlers(t *testing.T) { } }) } - diff --git a/internal/security/security.go b/internal/security/security.go index cd2607b..6170759 100644 --- a/internal/security/security.go +++ b/internal/security/security.go @@ -3,6 +3,7 @@ package security import ( "bytes" "context" + "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" @@ -92,13 +93,29 @@ func GetRequestFingerprint(r *http.Request, s *stats.Service) string { ua := r.Header.Get("User-Agent") chUA := r.Header.Get("Sec-CH-UA") + chPlatform := r.Header.Get("Sec-CH-UA-Platform") + chMobile := r.Header.Get("Sec-CH-UA-Mobile") hash := sha256.New() + hash.Write([]byte("v2|")) hash.Write([]byte(ipStr)) hash.Write([]byte("|")) hash.Write([]byte(ua)) hash.Write([]byte("|")) hash.Write([]byte(chUA)) + hash.Write([]byte("|")) + hash.Write([]byte(chPlatform)) + hash.Write([]byte("|")) + hash.Write([]byte(chMobile)) + + if r.TLS != nil { + hash.Write([]byte(fmt.Sprintf("|%d|%d", r.TLS.Version, r.TLS.CipherSuite))) + } + + if cookie, err := r.Cookie("_ss_uid"); err == nil { + hash.Write([]byte("|")) + hash.Write([]byte(cookie.Value)) + } fingerprint := hex.EncodeToString(hash.Sum(nil)) @@ -182,6 +199,24 @@ func SecurityMiddleware(s *stats.Service, bb *BotBlocker) func(http.Handler) htt start := time.Now() path := strings.ToLower(r.URL.Path) ua := r.UserAgent() + + if _, err := r.Cookie("_ss_uid"); err != nil { + uid := make([]byte, 16) + if _, err := rand.Read(uid); err == nil { + uidStr := hex.EncodeToString(uid) + http.SetCookie(w, &http.Cookie{ + Name: "_ss_uid", + Value: uidStr, + Path: "/", + Expires: time.Now().Add(365 * 24 * time.Hour), + HttpOnly: true, + Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", + SameSite: http.SameSiteLaxMode, + }) + r.AddCookie(&http.Cookie{Name: "_ss_uid", Value: uidStr}) + } + } + fingerprint := GetRequestFingerprint(r, s) ctx := context.WithValue(r.Context(), FingerprintKey, fingerprint) diff --git a/internal/security/security_test.go b/internal/security/security_test.go index 69af34c..640cf4f 100644 --- a/internal/security/security_test.go +++ b/internal/security/security_test.go @@ -59,36 +59,41 @@ func TestThrottledReader(t *testing.T) { func TestGetRequestFingerprint(t *testing.T) { statsService := stats.NewService("test-hashes.json") - // IPv4 + // Same IP, same headers req1 := httptest.NewRequest("GET", "/", nil) req1.RemoteAddr = "1.2.3.4:1234" + req1.Header.Set("User-Agent", "Mozilla/5.0") + req1.Header.Set("Sec-CH-UA", `"Google Chrome";v="123"`) f1 := GetRequestFingerprint(req1, statsService) req2 := httptest.NewRequest("GET", "/", nil) req2.RemoteAddr = "1.2.3.4:5678" + req2.Header.Set("User-Agent", "Mozilla/5.0") + req2.Header.Set("Sec-CH-UA", `"Google Chrome";v="123"`) f2 := GetRequestFingerprint(req2, statsService) if f1 != f2 { - t.Error("fingerprints should match for same IPv4") + t.Error("fingerprints should match for same parameters") } - // X-Forwarded-For + // Different UID cookie req3 := httptest.NewRequest("GET", "/", nil) - req3.Header.Set("X-Forwarded-For", "5.6.7.8, 1.2.3.4") + req3.RemoteAddr = "1.2.3.4:1234" + req3.Header.Set("User-Agent", "Mozilla/5.0") + req3.Header.Set("Sec-CH-UA", `"Google Chrome";v="123"`) + req3.AddCookie(&http.Cookie{Name: "_ss_uid", Value: "uid1"}) f3 := GetRequestFingerprint(req3, statsService) if f1 == f3 { - t.Error("fingerprints should differ for different IPs") + t.Error("fingerprints should differ with different UID cookies") } - // IPv6 masking + // Different Client Hint req4 := httptest.NewRequest("GET", "/", nil) - req4.RemoteAddr = "[2001:db8::1]:1234" + req4.RemoteAddr = "1.2.3.4:1234" + req4.Header.Set("User-Agent", "Mozilla/5.0") + req4.Header.Set("Sec-CH-UA", `"Brave";v="123"`) f4 := GetRequestFingerprint(req4, statsService) - - req5 := httptest.NewRequest("GET", "/", nil) - req5.RemoteAddr = "[2001:db8::2]:1234" - f5 := GetRequestFingerprint(req5, statsService) - if f4 != f5 { - t.Error("fingerprints should match for same IPv6 /64 prefix") + if f1 == f4 { + t.Error("fingerprints should differ with different Client Hints") } } @@ -116,13 +121,23 @@ func TestSecurityMiddleware(t *testing.T) { t.Errorf("expected 403 for forbidden pattern, got %d", rr.Code) } - // Test normal request + // Test normal request and cookie setting req = httptest.NewRequest("GET", "/api/software", nil) rr = httptest.NewRecorder() handler.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Errorf("expected 200 for normal request, got %d", rr.Code) } + cookieFound := false + for _, c := range rr.Result().Cookies() { + if c.Name == "_ss_uid" { + cookieFound = true + break + } + } + if !cookieFound { + t.Error("expected _ss_uid cookie to be set") + } } func TestIsPrivateIP_Extended(t *testing.T) {