From 41a0ccebd582d2f7a580e02015657aec9357db14 Mon Sep 17 00:00:00 2001 From: Soxoj <31013580+soxoj@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:29:20 +0200 Subject: [PATCH] Stop reporting blocked LinkedIn responses as free usernames (#3173) LinkedIn's 999 and its reCAPTCHA interstitial both came out as confident results, one as a free name, the other as a claimed one. Both are errors now. --- maigret/error_detection.py | 11 ++++++----- maigret/errors.py | 6 ++++++ tests/test_checking.py | 10 +++++++--- tests/test_error_detection.py | 35 +++++++++++++++++++++++++++++++---- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/maigret/error_detection.py b/maigret/error_detection.py index d015495d..693570b5 100644 --- a/maigret/error_detection.py +++ b/maigret/error_detection.py @@ -40,11 +40,12 @@ def detect_error_page( return CheckError("Rate limited", "429 status code") if status_code == 999: # LinkedIn's "Request denied", served to blocked clients for existing - # and non-existing profiles alike, so it is not really a not-found. - # Classifying it as an error would flip LinkedIn to UNKNOWN for every - # blocked IP, which reads as "LinkedIn is broken" to users. Kept as a - # pass-through on purpose: the caller's checkType branch decides. - return None + # and non-existing profiles alike. Falling through to the checkType + # branch turned every profile into AVAILABLE, so a blocked client was + # told that williamhgates has no LinkedIn. "We could not check" is the + # honest answer here; the cost is that a blocked IP now sees an error + # instead of a confident wrong "Not found!". + return CheckError("Access denied", "999 status code") if status_code >= 500: return CheckError("Server", f"{status_code} status code") return None diff --git a/maigret/errors.py b/maigret/errors.py index 23861497..81e3f974 100644 --- a/maigret/errors.py +++ b/maigret/errors.py @@ -84,6 +84,12 @@ COMMON_ERRORS = { 'Captcha', 'Google rate-limit / captcha' ), 'id="gs_captcha_f"': CheckError('Captcha', 'Google rate-limit / captcha'), + # Google reCAPTCHA interstitial: answers HTTP 200 on every path, so on a + # status_code site every username reads as claimed (linkedin.com). Matches + # the challenge page itself, not a login form that merely embeds a widget. + 'google.com/recaptcha/challengepage/': CheckError( + 'Captcha', 'Google reCAPTCHA interstitial' + ), } PROXY_RECOMMENDATION = ( diff --git a/tests/test_checking.py b/tests/test_checking.py index 3272c475..950c87f5 100644 --- a/tests/test_checking.py +++ b/tests/test_checking.py @@ -149,9 +149,13 @@ def test_detect_error_page_403_ignored(): def test_detect_error_page_999_linkedin(): - # LinkedIn returns 999 on bot suspicion. Deliberately not an error: making - # it one turns LinkedIn UNKNOWN for every blocked IP. - assert detect_error_page("", 999, {}, ignore_403=False) is None + # LinkedIn returns 999 on bot suspicion, for real and made-up profiles + # alike. It has to be an error: passing it through made every profile + # look free to a blocked client. + err = detect_error_page("", 999, {}, ignore_403=False) + assert err is not None + assert err.type == "Access denied" + assert "999" in err.desc def test_detect_error_page_500(): diff --git a/tests/test_error_detection.py b/tests/test_error_detection.py index 310050ab..94278211 100644 --- a/tests/test_error_detection.py +++ b/tests/test_error_detection.py @@ -40,10 +40,6 @@ def test_http_429_is_rate_limit(): assert err.type == "Rate limited" -def test_ignore_linkedin_999_status(): - # 999 stays a pass-through on purpose, see detect_error_page. - assert detect_error_page("", 999, {}, ignore_403=False) is None - def test_pow_challenge_pages_are_bot_protection(): # Both serve a 2xx on every path, so without a marker every username # would read as claimed. @@ -59,3 +55,34 @@ def test_pow_challenge_pages_are_bot_protection(): assert anubis.type == "Bot protection" assert pow_js.type == "Bot protection" + + +def test_linkedin_999_is_an_error_not_an_absence(): + # LinkedIn serves 999 to blocked clients for existing and non-existing + # profiles alike. Passing it through to the status_code branch reported + # every profile as free, so a blocked user was told that a real account + # does not exist. + err = detect_error_page("", 999, {}, ignore_403=False) + assert err is not None + assert err.type == "Access denied" + + +def test_google_recaptcha_interstitial_is_a_captcha(): + # Answers 200 on every path, so without a marker every username reads as + # claimed on a status_code site. + err = detect_error_page( + 'Checking your browser - reCAPTCHA' + '', + 200, {}, ignore_403=False, + ) + assert err is not None + assert err.type == "Captcha" + + +def test_page_embedding_a_recaptcha_widget_is_not_an_error(): + # A login form that loads the reCAPTCHA script is an ordinary page; only + # the challenge interstitial counts as a block. + assert detect_error_page( + '
', + 200, {}, ignore_403=False, + ) is None