diff --git a/internal/http_server.go b/internal/http_server.go index 233d279..63a420c 100644 --- a/internal/http_server.go +++ b/internal/http_server.go @@ -347,10 +347,14 @@ func accessGranted( config *Config, decisionListResultString string, botScore float64, - botScoreTopFactor string) { + botScoreTopFactor string, + botFingerprint IntegrityCheckPayloadWrapper) { if botScore >= 0 { c.Header("X-Banjax-Bot-Score", fmt.Sprintf("%f", botScore)) c.Header("X-Banjax-Bot-Score-Top-Factor", botScoreTopFactor) + c.Header("X-Banjax-Bot-Fingerprint", botFingerprint.Hash) + jsonPayload, _ := json.Marshal(botFingerprint.Payload) + c.Header("X-Banjax-Bot-Fingerprint-Full", string(jsonPayload)) } c.Header("X-Banjax-Decision", decisionListResultString) c.Header("X-Accel-Redirect", "@access_granted") // nginx named location that proxy_passes to origin @@ -363,10 +367,14 @@ func accessDenied( config *Config, decisionListResultString string, botScore float64, - botScoreTopFactor string) { + botScoreTopFactor string, + botFingerprint IntegrityCheckPayloadWrapper) { if botScore >= 0 { c.Header("X-Banjax-Bot-Score", fmt.Sprintf("%f", botScore)) c.Header("X-Banjax-Bot-Score-Top-Factor", botScoreTopFactor) + c.Header("X-Banjax-Bot-Fingerprint", botFingerprint.Hash) + jsonPayload, _ := json.Marshal(botFingerprint.Payload) + c.Header("X-Banjax-Bot-Fingerprint-Full", string(jsonPayload)) } c.Header("X-Banjax-Decision", decisionListResultString) c.Header("Cache-Control", "no-cache,no-store") // XXX think about caching @@ -572,7 +580,7 @@ func sendOrValidateShaChallenge( clientUserAgent := c.Request.Header.Get("X-Client-User-Agent") challengeCookie, err := c.Cookie(ChallengeCookieName) integrityCheckCookie, _ := c.Cookie(IntegrityCheckCookieName) - botScore, botScoreTopFactor := integrityCheckCalcBotScoreWrapper(integrityCheckCookie) + botScore, botScoreTopFactor, botFingerprint := integrityCheckCalcBotScoreWrapper(integrityCheckCookie) requestedMethod := c.Request.Method if err == nil { err := ValidateShaInvCookie(config.HmacSecret, challengeCookie, time.Now(), getUserAgentOrIp(c, config), config.ShaInvExpectedZeroBits) @@ -581,7 +589,7 @@ func sendOrValidateShaChallenge( // log.Println(err) sendOrValidateShaChallengeResult.ShaChallengeResult = ShaChallengeFailedBadCookie } else { - accessGranted(c, config, ShaChallengeResultToString[ShaChallengePassed], botScore, botScoreTopFactor) + accessGranted(c, config, ShaChallengeResultToString[ShaChallengePassed], botScore, botScoreTopFactor, botFingerprint) ReportPassedFailedBannedMessage(config, "ip_passed_challenge", clientIp, requestedHost) // log.Println("Sha-inverse challenge passed") sendOrValidateShaChallengeResult.ShaChallengeResult = ShaChallengePassed @@ -607,7 +615,7 @@ func sendOrValidateShaChallenge( sendOrValidateShaChallengeResult.TooManyFailedChallengesResult = tooManyFailedChallengesResult if tooManyFailedChallengesResult.Exceeded { ReportPassedFailedBannedMessage(config, "ip_banned", clientIp, requestedHost) - accessDenied(c, config, "TooManyFailedChallenges", botScore, botScoreTopFactor) + accessDenied(c, config, "TooManyFailedChallenges", botScore, botScoreTopFactor, botFingerprint) return sendOrValidateShaChallengeResult } } @@ -690,7 +698,7 @@ func sendOrValidatePassword( err := ValidatePasswordCookie(config.HmacSecret, passwordCookie, time.Now(), getUserAgentOrIp(c, config), expectedHashedPassword2) if err == nil { // roaming password passed, we do not record fail specifically for roaming fail - accessGranted(c, config, PasswordChallengeResultToString[PasswordChallengeRoamingPassed], -1.0, "") + accessGranted(c, config, PasswordChallengeResultToString[PasswordChallengeRoamingPassed], -1.0, "", IntegrityCheckPayloadWrapper{}) ReportPassedFailedBannedMessage(config, "ip_passed_challenge", clientIp, requestedHost) sendOrValidatePasswordResult.PasswordChallengeResult = PasswordChallengeRoamingPassed return sendOrValidatePasswordResult @@ -699,7 +707,7 @@ func sendOrValidatePassword( sendOrValidatePasswordResult.PasswordChallengeResult = PasswordChallengeFailedBadCookie } } else { - accessGranted(c, config, PasswordChallengeResultToString[PasswordChallengePassed], -1.0, "") + accessGranted(c, config, PasswordChallengeResultToString[PasswordChallengePassed], -1.0, "", IntegrityCheckPayloadWrapper{}) ReportPassedFailedBannedMessage(config, "ip_passed_challenge", clientIp, requestedHost) // log.Println("Password challenge passed") sendOrValidatePasswordResult.PasswordChallengeResult = PasswordChallengePassed @@ -725,7 +733,7 @@ func sendOrValidatePassword( // log.Println(tooManyFailedChallengesResult) if tooManyFailedChallengesResult.Exceeded { ReportPassedFailedBannedMessage(config, "ip_banned", clientIp, requestedHost) - accessDenied(c, config, "TooManyFailedPassword", -1.0, "") + accessDenied(c, config, "TooManyFailedPassword", -1.0, "", IntegrityCheckPayloadWrapper{}) return sendOrValidatePasswordResult } _, allowRoaming := passwordProtectedPaths.GetExpandCookieDomain(requestedHost) @@ -876,7 +884,7 @@ func decisionForNginx2( } if grantPriorityPass { decisionForNginxResult.DecisionListResult = PasswordProtectedPriorityPass - accessGranted(c, config, DecisionListResultToString[PasswordProtectedPriorityPass], -1.0, "") + accessGranted(c, config, DecisionListResultToString[PasswordProtectedPriorityPass], -1.0, "", IntegrityCheckPayloadWrapper{}) return } } @@ -898,7 +906,7 @@ func decisionForNginx2( case PasswordProtectedException: decisionForNginxResult.DecisionListResult = PasswordProtectedPathException // FIXED: prevent password challenge exception path getting challenge - accessGranted(c, config, DecisionListResultToString[PasswordProtectedPathException], -1.0, "") + accessGranted(c, config, DecisionListResultToString[PasswordProtectedPathException], -1.0, "", IntegrityCheckPayloadWrapper{}) return case NotPasswordProtected: default: @@ -912,7 +920,7 @@ func decisionForNginx2( if foundInPerSiteList { switch decision { case Allow: - accessGranted(c, config, DecisionListResultToString[PerSiteAccessGranted], -1.0, "") + accessGranted(c, config, DecisionListResultToString[PerSiteAccessGranted], -1.0, "", IntegrityCheckPayloadWrapper{}) // log.Println("access granted from per-site lists") decisionForNginxResult.DecisionListResult = PerSiteAccessGranted return @@ -931,7 +939,7 @@ func decisionForNginx2( decisionForNginxResult.TooManyFailedChallengesResult = &sendOrValidateShaChallengeResult.TooManyFailedChallengesResult return case NginxBlock, IptablesBlock: - accessDenied(c, config, DecisionListResultToString[PerSiteBlock], -1.0, "") + accessDenied(c, config, DecisionListResultToString[PerSiteBlock], -1.0, "", IntegrityCheckPayloadWrapper{}) // log.Println("block from per-site lists") decisionForNginxResult.DecisionListResult = PerSiteBlock return @@ -942,7 +950,7 @@ func decisionForNginx2( if foundInGlobalList { switch decision { case Allow: - accessGranted(c, config, DecisionListResultToString[GlobalAccessGranted], -1.0, "") + accessGranted(c, config, DecisionListResultToString[GlobalAccessGranted], -1.0, "", IntegrityCheckPayloadWrapper{}) // log.Println("access granted from global lists") decisionForNginxResult.DecisionListResult = GlobalAccessGranted return @@ -961,7 +969,7 @@ func decisionForNginx2( decisionForNginxResult.TooManyFailedChallengesResult = &sendOrValidateShaChallengeResult.TooManyFailedChallengesResult return case NginxBlock, IptablesBlock: - accessDenied(c, config, DecisionListResultToString[GlobalBlock], -1.0, "") + accessDenied(c, config, DecisionListResultToString[GlobalBlock], -1.0, "", IntegrityCheckPayloadWrapper{}) // log.Println("access denied from global lists") decisionForNginxResult.DecisionListResult = GlobalBlock return @@ -978,14 +986,14 @@ func decisionForNginx2( } else { switch expiringDecision.Decision { case Allow: - accessGranted(c, config, DecisionListResultToString[ExpiringAccessGranted], -1.0, "") + accessGranted(c, config, DecisionListResultToString[ExpiringAccessGranted], -1.0, "", IntegrityCheckPayloadWrapper{}) // log.Println("access granted from expiring lists") decisionForNginxResult.DecisionListResult = ExpiringAccessGranted return case Challenge: // apply exception to both challenge from baskerville and regex banner if checkPerSiteShaInvPathExceptions(config, requestedHost, requestedPath) { - accessGranted(c, config, DecisionListResultToString[PerSiteShaInvPathException], -1.0, "") + accessGranted(c, config, DecisionListResultToString[PerSiteShaInvPathException], -1.0, "", IntegrityCheckPayloadWrapper{}) decisionForNginxResult.DecisionListResult = PerSiteShaInvPathException return } @@ -1009,7 +1017,7 @@ func decisionForNginx2( return } case NginxBlock, IptablesBlock: - accessDenied(c, config, DecisionListResultToString[ExpiringBlock], -1.0, "") + accessDenied(c, config, DecisionListResultToString[ExpiringBlock], -1.0, "", IntegrityCheckPayloadWrapper{}) // log.Println("access denied from expiring lists") decisionForNginxResult.DecisionListResult = ExpiringBlock return @@ -1026,7 +1034,7 @@ func decisionForNginx2( // Reuse the exception from password prot for site-wide sha inv exceptions path if passwordProtectedPaths.IsException(requestedHost, requestedProtectedPath) { decisionForNginxResult.DecisionListResult = SiteWideChallengeException - accessGranted(c, config, DecisionListResultToString[SiteWideChallengeException], -1.0, "") + accessGranted(c, config, DecisionListResultToString[SiteWideChallengeException], -1.0, "", IntegrityCheckPayloadWrapper{}) } else { sendOrValidateShaChallengeResult := sendOrValidateShaChallenge( config, @@ -1048,7 +1056,7 @@ func decisionForNginx2( if decisionForNginxResult.DecisionListResult == NotSet { decisionForNginxResult.DecisionListResult = NoMention } - accessGranted(c, config, DecisionListResultToString[decisionForNginxResult.DecisionListResult], -1.0, "") + accessGranted(c, config, DecisionListResultToString[decisionForNginxResult.DecisionListResult], -1.0, "", IntegrityCheckPayloadWrapper{}) return } diff --git a/internal/integrity_check.go b/internal/integrity_check.go index a5b5771..d405e61 100644 --- a/internal/integrity_check.go +++ b/internal/integrity_check.go @@ -1,8 +1,11 @@ package internal import ( + "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/json" + "fmt" "log" "math" "strings" @@ -28,10 +31,50 @@ type IntegrityCheckPayload struct { Window IntegrityCheckWindowSize `json:"window"` ColorDepth int `json:"colorDepth"` LangLength int `json:"langLength"` + Language string `json:"language"` + Languages []string `json:"languages"` + Timezone string `json:"timezone"` + Platform string `json:"platform"` + CanvasFp string `json:"canvasFp"` + WebglFp string `json:"webglFp"` + MathFp string `json:"mathFp"` + Webcam bool `json:"webcam"` +} + +type IntegrityCheckPayloadWrapper struct { + Payload IntegrityCheckPayload + Hash string +} + +func integrityCheckCalcFingerprint(p IntegrityCheckPayload) string { + languages := strings.Join(p.Languages, ",") + + raw := fmt.Sprintf( + "%s|%s|%s|%s|%d|%d|%d|%d|%dx%d|%s|%s|%s|%s|%t|%t|%t", + p.Platform, + p.Timezone, + p.Language, + languages, + p.CPU, + p.Memory, + p.ColorDepth, + p.LangLength, + p.Screen.Width, p.Screen.Height, + p.GPURenderer, + p.CanvasFp, + p.WebglFp, + p.MathFp, + p.Webdriver, + p.HasPlugins, + p.Webcam, + ) + + hash := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(hash[:]) } // calculateBotScore calculates a bot score based on various payload properties -func integrityCheckCalcBotScore(p IntegrityCheckPayload) (float64, string) { +func integrityCheckCalcBotScore(p IntegrityCheckPayload) (float64, string, IntegrityCheckPayloadWrapper) { factorWeights := map[string]int{ "webdriver": 10, "no_plugins": 3, @@ -121,17 +164,34 @@ func integrityCheckCalcBotScore(p IntegrityCheckPayload) (float64, string) { } normalized := math.Min(float64(score)/float64(maxScore), 1.0) - return normalized, topFactor + fingerprint := integrityCheckCalcFingerprint(p) + // log.Printf("Calculated bot score: %.2f, top factor: %s, fingerprint: %s", normalized, topFactor, fingerprint) + + // Create a wrapper to return the payload and its hash + payloadWrapper := IntegrityCheckPayloadWrapper{ + Payload: p, + Hash: fingerprint, + } + + return normalized, topFactor, payloadWrapper } -func integrityCheckCalcBotScoreWrapper(base64Payload string) (float64, string) { +func integrityCheckCalcBotScoreWrapper(base64Payload string) (float64, string, IntegrityCheckPayloadWrapper) { // check if base64Payload is empty if base64Payload == "" { - return 1.0, "no_payload" // return a high score if payload is empty + // return a high score if payload is empty + return 1.0, "no_payload", IntegrityCheckPayloadWrapper{ + Payload: IntegrityCheckPayload{}, + Hash: "", + } } payload, err := integrityCheckDecodePayload(base64Payload) if err != nil { - return 1.0, "err_payload" // return a high score if payload is invalid + // return a high score if payload is invalid + return 1.0, "err_payload", IntegrityCheckPayloadWrapper{ + Payload: IntegrityCheckPayload{}, + Hash: "", + } } return integrityCheckCalcBotScore(payload) } diff --git a/internal/integrity_check_test.go b/internal/integrity_check_test.go index ae33843..c7cfc68 100644 --- a/internal/integrity_check_test.go +++ b/internal/integrity_check_test.go @@ -49,13 +49,16 @@ func TestCalculateBotScore(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { - score, factor := integrityCheckCalcBotScore(c.payload) + score, factor, fingerprint := integrityCheckCalcBotScore(c.payload) if score != c.expected { t.Errorf("Test %s failed: expected score %.2f, got %.2f", c.name, c.expected, score) } if factor != c.expectedFactor { t.Errorf("Test %s failed: expected factor %s, got %s", c.name, c.expectedFactor, factor) } + if fingerprint.Hash == "" { + t.Errorf("Test %s failed: expected non-empty fingerprint, got empty", c.name) + } }) } } @@ -80,36 +83,43 @@ func TestCalculateBotScoreWrapper(t *testing.T) { base64Input string expectedScore float64 expectedFactor string + expectedFingerprint string }{ { name: "Valid payload", base64Input: encoded, expectedScore: 1.0, expectedFactor: "webdriver", + expectedFingerprint: integrityCheckCalcFingerprint(payload), }, { name: "Invalid base64", base64Input: "not a base64 string", expectedScore: 1.0, expectedFactor: "err_payload", + expectedFingerprint: "", }, { name: "No payload", base64Input: "", expectedScore: 1.0, expectedFactor: "no_payload", + expectedFingerprint: "", }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - score, factor := integrityCheckCalcBotScoreWrapper(c.base64Input) + score, factor, fingerprint := integrityCheckCalcBotScoreWrapper(c.base64Input) if score != c.expectedScore { t.Errorf("Test %s failed: expected score %.2f, got %.2f", c.name, c.expectedScore, score) } if factor != c.expectedFactor { t.Errorf("Test %s failed: expected factor %s, got %s", c.name, c.expectedFactor, factor) } + if fingerprint.Hash != c.expectedFingerprint { + t.Errorf("Test %s failed: expected fingerprint %s, got %s", c.name, c.expectedFingerprint, fingerprint.Hash) + } }) } } diff --git a/internal/sha-inverse-challenge.html b/internal/sha-inverse-challenge.html index 1e9954d..ff02531 100644 --- a/internal/sha-inverse-challenge.html +++ b/internal/sha-inverse-challenge.html @@ -214,7 +214,7 @@ getFavicon(document.getElementsByClassName("website-favicon")[0]) } - (function () { + (async () => { var payload = {}; // navigator.webdriver @@ -260,11 +260,89 @@ payload.colorDepth = window.screen.colorDepth; payload.langLength = 'languages' in navigator ? navigator.languages.length : 1; + payload.language = navigator.language || ''; + payload.languages = navigator.languages || []; + payload.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; + payload.platform = navigator.platform || ''; + payload.touchSupport = { + touchEvent: 'ontouchstart' in window, + maxTouchPoints: navigator.maxTouchPoints || 0 + }; + + //console.log(payload); + + function hashString(str) { + var hash = 5381; + for (var i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash) + str.charCodeAt(i); + } + return (hash >>> 0).toString(16); + } + + function canvasFingerprintHash() { + try { + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext('2d'); + ctx.textBaseline = 'top'; + ctx.font = '14px Arial'; + ctx.fillStyle = '#f60'; + ctx.fillRect(0, 0, 100, 100); + ctx.fillStyle = '#069'; + ctx.fillText('Baskerville', 10, 50); + return hashString(canvas.toDataURL()); + } catch { + return "0"; + } + } + + function webglFingerprintHash() { + try { + var canvas = document.createElement('canvas'); + var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); + var debugInfo = gl.getExtension('WEBGL_debug_renderer_info'); + var vendor = debugInfo ? gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) : 'unknown'; + var renderer = debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : 'unknown'; + return hashString(`${vendor}~${renderer}`); + } catch { + return "0"; + } + } + + function mathPrecisionQuirkHash() { + try { + var str = [ + Math.acos(0.123), + Math.tan(0.5), + Math.log(42), + Math.sin(Math.PI / 3), + ].map(x => x.toPrecision(15)).join(','); + return hashString(str); + } catch { + return "0"; + } + } + + payload.canvasFp = canvasFingerprintHash(); + payload.webglFp = webglFingerprintHash(); + payload.mathFp = mathPrecisionQuirkHash(); + if (navigator.mediaDevices?.enumerateDevices) { + try { + var devices = await navigator.mediaDevices.enumerateDevices(); + payload.webcam = devices.some(d => d.kind === "videoinput"); + } catch { + payload.webcam = false; + } + } + + //console.log(payload); var dataString = JSON.stringify(payload); + //console.log(dataString); var encodedData = window.btoa ? btoa(dataString) : Base64.encode(dataString); document.cookie = "deflect_integrity=" + encodedData + ";SameSite=Lax;path=/;"; - })(); + })().catch(err => { + console.error(err); + });