Compare commits

..
2 Commits
Author SHA1 Message Date
CaliBrain 9452ebc70d fix(bypass): stop handing solvers DDoS-Guard's ?check=1 probe URL (#1300)
html_get_page follows Anna's Archive redirects by hand, and DDoS-Guard's
gate
answers /search with a 302 to the same path plus `check=1`. The follower
walks
that handshake by reassigning `current_url`, so every downstream handoff
- the
403 branch, the 503-challenge branch, both redirect-loop rescues -
passed the
*probe* URL to the bypasser rather than the page we actually wanted.

A solver opens that in a fresh browser holding none of the cookies the
probe
exists to collect, so DDoS-Guard cannot verify it automatically and
serves the
manual CAPTCHA page that nothing can solve. The #1292 log is exactly
that: a 403
handed off on `&check=1`, FlareSolverr answering "Challenge solved!",
and a
4721-byte DDOS-GUARD captcha page coming back.

- `_solvable_url()` strips the probe parameter, applied at the single
choke point
in `_run_bypasser` so all four handoffs are covered. Scoped to the hosts
whose
redirects we follow manually; a URL without the parameter is returned by
  identity, so nothing else is re-encoded.

The same reports showed three further defects, all of which stand
whatever the
host was reacting to:

- The external bypasser logged that the solve had not cleared the
protection and
then returned the challenge page as a success. That skipped the one
recovery
left - get_bypassed_page's retry-and-rotate loop, where the next mirror
is a
different DDoS-Guard host - and filed the captcha page's own __ddg
cookies as
that host's clearance, to be replayed on every later request. It now
raises
  ChallengeNotSolvedError before storing anything.

- "Check that the bypasser is reachable and working" was the one piece
of advice
guaranteed to waste the reporter's time: it was reachable, it ran a full
solve,
and it returned a captcha. ChallengeNotSolvedError carries the marker so
the
  search layer can name the host as the cause instead of the bypasser.

- The untabled-page fingerprint logged `attempt_url`, which
html_get_page has
since rotated past. The #1298 bundle reported the page against
annas-archive.gl
when the body had come from .pk - the triage cost #1289 added the line
to
remove. The search now asks for the response URL and logs that. Its
give-up
shape is the tuple ("", url), which is truthy, so the exhaustion check
reads
  the body rather than the response.

Regression fixtures are built from the pages in the reports. The two
behavioural
handoff tests were checked against the unfixed code: both fail there,
reproducing
the reporter's log line verbatim.

Refs #1292
Refs #1298
2026-09-02 16:12:52 -04:00
CaliBrain d3f4ccd79a seleniumbase==4.53.5 (#1299)
replaces #1296
2026-09-02 15:48:48 -04:00
10 changed files with 623 additions and 31 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ dependencies = [
browser = [
"pyvirtualdisplay",
"pyautogui",
"seleniumbase==4.52.4",
"seleniumbase==4.53.5",
"python-xlib",
]
+11
View File
@@ -3,3 +3,14 @@
class BypassCancelledError(Exception):
"""Raised when a bypass operation is cancelled."""
class ChallengeNotSolvedError(Exception):
"""Raised when a bypasser ran but the site still answered with a challenge.
Distinct from a bypasser that is broken or unreachable, which is what every
"the bypass failed" message used to say. A solver can do its job perfectly and
still be handed something it cannot clear - DDoS-Guard's manual CAPTCHA page is
the case from #1292 - and telling the user to go check that FlareSolverr is
reachable sends them to fix a service that is working.
"""
+41 -4
View File
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any
import requests
from shelfmark.bypass import BypassCancelledError
from shelfmark.bypass import BypassCancelledError, ChallengeNotSolvedError
from shelfmark.bypass.challenge import challenge_marker
from shelfmark.bypass.cookie_store import store_extracted_cookies
from shelfmark.core.config import config
@@ -92,7 +92,13 @@ def _store_solution_clearance(target_url: str, solution: Mapping[str, Any]) -> N
def _fetch_via_bypasser(target_url: str) -> str | None:
"""Make a single request to the external bypasser service. Returns HTML or None."""
"""Make a single request to the external bypasser service. Returns HTML or None.
Raises:
ChallengeNotSolvedError: the service answered with a page that is still a
challenge, whatever verdict it reported on itself.
"""
raw_bypasser_url = _coerce_config_str(
config.get("EXT_BYPASSER_URL", "http://flaresolverr:8191"),
"http://flaresolverr:8191",
@@ -155,6 +161,12 @@ def _fetch_via_bypasser(target_url: str) -> str | None:
marker,
)
if marker:
# The solver's verdict is not evidence; the page is. Returning this one as a
# success is what made #1292 unrecoverable: the retry-and-rotate loop that
# could still have saved the search - the next mirror is a different
# DDoS-Guard host, in its own state - was never entered, and the challenge
# page's own __ddg cookies were filed as this host's clearance and replayed
# on every later request.
logger.warning(
"External bypasser reported success but returned a challenge page for "
"'%s' (%d bytes, marker=%r) - the solve did not clear the protection",
@@ -162,6 +174,7 @@ def _fetch_via_bypasser(target_url: str) -> str | None:
len(html),
marker,
)
raise ChallengeNotSolvedError(marker)
try:
_store_solution_clearance(target_url, solution)
@@ -212,16 +225,33 @@ def get_bypassed_page(
selector: network.AAMirrorSelector | None = None,
cancel_flag: Event | None = None,
) -> str | None:
"""Fetch HTML via external bypasser with retries and mirror rotation."""
"""Fetch HTML via external bypasser with retries and mirror rotation.
Raises:
ChallengeNotSolvedError: every attempt came back still carrying a challenge.
Reported apart from returning None because the two ask the user for
opposite things: None means go and check the bypasser, this means the
bypasser is fine and the host is the one refusing.
BypassCancelledError: the caller's cancel flag was set.
"""
from shelfmark.download import network as network_module
sel = selector or network_module.AAMirrorSelector()
unsolved_marker: str | None = None
for attempt in range(1, MAX_RETRY + 1):
_check_cancelled(cancel_flag, "by user")
attempt_url = sel.rewrite(url)
result = _fetch_via_bypasser(attempt_url)
try:
result = _fetch_via_bypasser(attempt_url)
except ChallengeNotSolvedError as e:
# Worth the remaining attempts rather than an immediate give-up: the retry
# rotates onto the next mirror, and that is a different DDoS-Guard host with
# its own idea of whether this caller needs a CAPTCHA.
unsolved_marker = str(e) or unsolved_marker
result = None
if result:
return result
@@ -242,4 +272,11 @@ def get_bypassed_page(
if action in ("mirror", "dns") and new_base:
logger.info("Rotated %s for retry", action)
if unsolved_marker:
msg = (
"The bypasser ran, but the site kept answering with a protection challenge "
f"(marker={unsolved_marker!r}). That is usually a manual CAPTCHA, which no "
"bypasser can answer - the bypasser itself is working. Try again shortly."
)
raise ChallengeNotSolvedError(msg)
return None
+49 -2
View File
@@ -5,12 +5,12 @@ import time
from http import HTTPStatus
from io import BytesIO
from typing import TYPE_CHECKING, NoReturn
from urllib.parse import urljoin, urlparse
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
import requests
from tqdm import tqdm
from shelfmark.bypass import BypassCancelledError, cookie_store
from shelfmark.bypass import BypassCancelledError, ChallengeNotSolvedError, cookie_store
from shelfmark.bypass.challenge import challenge_marker
from shelfmark.core import search_deadline
from shelfmark.core.config import config as app_config
@@ -29,6 +29,10 @@ logger = setup_logger(__name__)
_RNG = random.SystemRandom()
_MAX_REDIRECTS = 5
# DDoS-Guard's re-check probe. Its 302 to `?check=1` is one hop of a handshake rather
# than a page: the parameter asserts the caller already holds the cookies that hop
# issued.
_DDG_CHECK_PARAM = "check"
# Z-Library answers the first hit with a 503 whose only real payload is a Set-Cookie; echoing
# that cookie back returns the 302 to the real page. Two attempts cover the handshake without
# letting a server that keeps re-issuing cookies hold us in the loop.
@@ -48,6 +52,7 @@ _BYPASS_GRACE_SLACK_SECONDS = 30.0
_BYPASSER_ERRORS = (
AttributeError,
BypassCancelledError,
ChallengeNotSolvedError,
KeyError,
OSError,
RuntimeError,
@@ -252,6 +257,33 @@ def _response_challenge_marker(response: requests.Response) -> str | None:
return None
def _solvable_url(url: str) -> str:
"""The URL a solver should open, given one we may be mid-handshake on.
The manual AA redirect follower in `html_get_page` walks DDoS-Guard's handshake by
reassigning `current_url`, so by the time a 403, a 503 challenge or a redirect loop
hands that URL to a bypasser it is often the `?check=1` probe rather than the page
we actually wanted. A solver opens it in a fresh browser holding none of the cookies
the probe exists to collect, so DDoS-Guard cannot verify it automatically and answers
with the manual CAPTCHA page that nothing can solve - the failure in #1292, where
FlareSolverr reported "Challenge solved!" over a 4.7 KB DDOS-GUARD interstitial.
Handing over the pre-probe URL instead lets the solver's browser run the whole
handshake itself, which is what a real browser does and what the solver is for.
Scoped to the hosts whose redirects we follow manually: everywhere else `check` is
an ordinary query parameter and none of our business.
"""
if not network.should_rotate_dns_for_url(url):
return url
parsed = urlparse(url)
params = parse_qsl(parsed.query, keep_blank_values=True)
kept = [(key, value) for key, value in params if key != _DDG_CHECK_PARAM]
if len(kept) == len(params):
return url
return urlunparse(parsed._replace(query=urlencode(kept)))
def _fatal_mirror_reason(e: Exception) -> str | None:
"""Return why ``e`` proves the mirror is unusable, or None if it may recover.
@@ -371,6 +403,9 @@ def html_get_page(
retry-loop branch above with `continue`, and with MAX_RETRY=1 there is no
later attempt for that branch to run on either.
"""
# Every handoff reaches the solver through here, so this is the one place the
# mid-handshake `?check=1` URL has to be unwound. See _solvable_url.
bypass_url = _solvable_url(bypass_url)
# Never start a minutes-long browser solve on a budget that has already run out:
# nothing downstream would get to report the real reason before the caller's
# deadline (or its reverse proxy) cut the request off.
@@ -405,6 +440,18 @@ def html_get_page(
except _STATUS_CALLBACK_ERRORS:
logger.debug("Rate-limit status callback failed", exc_info=True)
return _fail(str(e), bypass_url)
except ChallengeNotSolvedError as e:
# Not a bypasser malfunction: it ran, and the host answered with something it
# cannot clear - DDoS-Guard's manual CAPTCHA, typically. Must precede the
# generic handler below, whose "the protection bypasser failed" is what sent
# #1292 off to fix a FlareSolverr that was working perfectly.
logger.info("Bypass ran but did not clear the protection: %s", e)
if status_callback:
try:
status_callback("error", str(e))
except _STATUS_CALLBACK_ERRORS:
logger.debug("Unsolved-challenge status callback failed", exc_info=True)
return _fail(str(e), bypass_url)
except _BYPASSER_ERRORS as e:
logger.warning("Bypasser error: %s: %s", type(e).__name__, e)
# Surface the real reason. Without this the caller only sees an empty
+36 -6
View File
@@ -115,6 +115,16 @@ def _html_response_text(response: str | tuple[str, str]) -> str:
return response
def _html_response_url(response: str | tuple[str, str]) -> str | None:
"""The URL that actually answered, when the downloader was asked to report it.
None for the plain-string shape, so a caller can fall back to what it requested.
"""
if isinstance(response, tuple):
return response[1] or None
return None
def _attr_to_str(value: object) -> str | None:
"""Convert a BeautifulSoup attribute value to a plain string."""
if isinstance(value, str):
@@ -696,10 +706,21 @@ def _fetch_search_table_uncached(
if search_deadline.expired():
raise SearchUnavailableError(search_deadline.deadline_message())
# include_response_url is what makes the diagnostics below name the mirror that
# actually answered. html_get_page rotates mirrors and follows redirects on its
# own, so `attempt_url` is only where this iteration started: #1298's bundle
# reported the untabled page against annas-archive.gl when the body had come
# from .pk, which is precisely the triage cost #1289 added the line to remove.
response = downloader.html_get_page(
attempt_url, selector=selector, allow_bypasser_fallback=True
attempt_url,
selector=selector,
allow_bypasser_fallback=True,
include_response_url=True,
)
if not response:
html = _html_response_text(response)
# Checked on the body, not on `response`: with include_response_url the give-up
# shape is the tuple ("", url), and a tuple is truthy.
if not html:
# Network/mirror exhaustion path bubbles up so API can notify clients.
# html_get_page records the concrete give-up reason on the selector; fall
# back to the generic line only if nothing was recorded.
@@ -708,7 +729,7 @@ def _fetch_search_table_uncached(
)
raise SearchUnavailableError(f"Unable to reach download source. {detail}")
html = _html_response_text(response)
answered_url = _html_response_url(response) or attempt_url
soup = BeautifulSoup(html, "html.parser")
table = soup.find("table")
if isinstance(table, Tag):
@@ -724,7 +745,7 @@ def _fetch_search_table_uncached(
# alone, and the response body is not in the debug bundle. Fingerprint it here
# so the next report says which branch fired and why, rather than costing
# another round of guesswork - see #1289.
_log_untabled_search_page(attempt_url, html)
_log_untabled_search_page(answered_url, html)
if _looks_like_aa_page(html):
# A real AA response in a shape the caller should report as drift. Checked
@@ -737,9 +758,18 @@ def _fetch_search_table_uncached(
# what came back. Rotating is pointless (every mirror shares the same
# protection) and reporting it as an empty result is worse: the user is
# told their query found nothing when the search never ran.
#
# The wording no longer blames the bypasser outright. In #1292 it was
# reachable and working, and the page it was handed was DDoS-Guard's manual
# CAPTCHA - so "check that the bypasser is working" was the one piece of
# advice guaranteed to waste the reporter's time. Name the marker instead
# and let the two causes be told apart.
msg = (
"Anna's Archive answered with an unsolved protection challenge. "
"Check that the bypasser is reachable and working."
"Anna's Archive answered with a protection challenge that was not "
f"cleared (marker={challenge_marker(html)!r}). If the bypasser reports "
"solving it, the host is serving a manual CAPTCHA that no bypasser can "
"answer - try again shortly. Otherwise check that the bypasser is "
"reachable and working."
)
raise SearchUnavailableError(msg)
+214
View File
@@ -0,0 +1,214 @@
"""A challenge page is a failed solve, whatever verdict the solver reports on itself.
Regression for #1292. FlareSolverr answers "Challenge solved!" for anything it does not
recognise as a Cloudflare challenge, and DDoS-Guard's manual CAPTCHA page is one such
thing. The external bypasser logged a warning that the solve had not cleared the
protection and then returned the page as a success anyway, which had three consequences:
the retry-and-rotate loop that could still have reached a working mirror was never
entered, the CAPTCHA page's own __ddg cookies were filed as that host's clearance, and
the user was told to go and check a bypasser that was working perfectly.
"""
import pytest
from shelfmark.bypass import ChallengeNotSolvedError
# Verbatim from the annas-archive.pk page in #1292, trimmed to the markers. This is the
# *manual* CAPTCHA - "could not verify your browser automatically" - not the ~900 byte
# JS interstitial that a browser clears on its own.
DDOS_GUARD_CAPTCHA = (
'<html><head><title>DDOS-GUARD</title><meta charset="utf-8">'
'<link rel="stylesheet" href="/.well-known/ddos-guard/ddg-captcha-page/index.css">'
'<script defer="defer" src="/.well-known/ddos-guard/ddg-captcha-page/index.js"></script>'
'</head><body><div class="container"><h1 id="title">Checking your browser before '
'accessing annas-archive.pk</h1><p id="description">Sorry, we could not verify your '
"browser automatically. Complete the manual check to continue</p>"
'<div id="ddg-captcha"></div></div></body></html>'
)
class _FakeResponse:
def __init__(self, payload: dict) -> None:
self._payload = payload
def raise_for_status(self) -> None:
return None
def json(self) -> dict:
return self._payload
def _stub_solution(monkeypatch, external_bypasser, solution: dict) -> None:
"""Answer every bypass with `solution`, with config and SSL stubbed out."""
def fake_get(key, default=""):
values = {
"EXT_BYPASSER_URL": "https://bypass.example",
"EXT_BYPASSER_PATH": "/v1",
"EXT_BYPASSER_TIMEOUT": 60000,
}
return values.get(key, default)
monkeypatch.setattr(external_bypasser.config, "get", fake_get)
monkeypatch.setattr(
external_bypasser.requests,
"post",
# "Challenge solved!" is the solver's verdict; the page is the evidence.
lambda *_a, **_k: _FakeResponse(
{"status": "ok", "message": "Challenge solved!", "solution": solution}
),
)
monkeypatch.setattr(external_bypasser, "get_ssl_verify", lambda _url: False)
def test_a_captcha_page_is_reported_as_unsolved_not_returned(monkeypatch):
import shelfmark.bypass.external_bypasser as external_bypasser
_stub_solution(monkeypatch, external_bypasser, {"response": DDOS_GUARD_CAPTCHA})
with pytest.raises(ChallengeNotSolvedError) as excinfo:
external_bypasser._fetch_via_bypasser("https://annas-archive.pk/search?q=dune")
# The marker travels with the failure so the user-facing message can name it.
assert str(excinfo.value) == "/.well-known/ddos-guard/"
def test_cookies_from_a_captcha_page_are_never_filed_as_clearance(monkeypatch):
"""They belong to an unsolved check, so replaying them only re-arms the gate."""
import shelfmark.bypass.cookie_store as cookie_store
import shelfmark.bypass.external_bypasser as external_bypasser
monkeypatch.setattr(cookie_store, "_cf_cookies", {})
monkeypatch.setattr(cookie_store, "_cf_user_agents", {})
_stub_solution(
monkeypatch,
external_bypasser,
{
"response": DDOS_GUARD_CAPTCHA,
"userAgent": "Mozilla/5.0 (solver)",
"cookies": [{"name": "__ddg1_", "value": "from-a-captcha"}],
},
)
with pytest.raises(ChallengeNotSolvedError):
external_bypasser._fetch_via_bypasser("https://annas-archive.pk/search?q=dune")
assert cookie_store.get_cf_cookies_for_domain("annas-archive.pk") == {}
assert cookie_store.get_cf_user_agent_for_domain("annas-archive.pk") is None
class _FakeSelector:
"""Two mirrors, rotated on demand - each is its own DDoS-Guard host."""
def __init__(self) -> None:
self.current_base = "https://mirror-one.example"
self.rotate_calls = 0
def rewrite(self, url: str) -> str:
return url.replace("https://orig.example", self.current_base, 1)
def next_mirror_or_rotate_dns(self) -> tuple[str | None, str]:
self.rotate_calls += 1
self.current_base = "https://mirror-two.example"
return self.current_base, "mirror"
def _no_sleeping(monkeypatch, external_bypasser) -> None:
monkeypatch.setattr(external_bypasser, "_sleep_with_cancellation", lambda _seconds, _flag: None)
def test_an_unsolved_challenge_rotates_to_the_next_mirror(monkeypatch):
"""The recovery the old code skipped by calling the CAPTCHA page a success."""
import shelfmark.bypass.external_bypasser as external_bypasser
_no_sleeping(monkeypatch, external_bypasser)
fetched: list[str] = []
def fake_fetch(url: str) -> str | None:
fetched.append(url)
if "mirror-one" in url:
raise ChallengeNotSolvedError("/.well-known/ddos-guard/")
return "<html>real page</html>"
monkeypatch.setattr(external_bypasser, "_fetch_via_bypasser", fake_fetch)
selector = _FakeSelector()
result = external_bypasser.get_bypassed_page("https://orig.example/search", selector=selector)
assert result == "<html>real page</html>"
assert fetched == [
"https://mirror-one.example/search",
"https://mirror-two.example/search",
]
assert selector.rotate_calls == 1
def test_every_attempt_challenged_blames_the_host_not_the_bypasser(monkeypatch):
import shelfmark.bypass.external_bypasser as external_bypasser
_no_sleeping(monkeypatch, external_bypasser)
def always_challenged(_url: str) -> str | None:
raise ChallengeNotSolvedError("/.well-known/ddos-guard/")
monkeypatch.setattr(external_bypasser, "_fetch_via_bypasser", always_challenged)
with pytest.raises(ChallengeNotSolvedError) as excinfo:
external_bypasser.get_bypassed_page("https://orig.example/search", selector=_FakeSelector())
message = str(excinfo.value)
assert "manual CAPTCHA" in message
assert "the bypasser itself is working" in message
def test_an_unreachable_bypasser_still_reports_as_such(monkeypatch):
"""The other cause must stay distinguishable: None, not an unsolved challenge."""
import shelfmark.bypass.external_bypasser as external_bypasser
_no_sleeping(monkeypatch, external_bypasser)
monkeypatch.setattr(external_bypasser, "_fetch_via_bypasser", lambda _url: None)
assert (
external_bypasser.get_bypassed_page("https://orig.example/search", selector=_FakeSelector())
is None
)
def test_html_get_page_surfaces_the_host_as_the_cause(monkeypatch):
"""The message the user actually reads must not send them to fix FlareSolverr.
`_run_bypasser`'s generic handler says "the protection bypasser failed", and the
search layer's give-up used to add "check that the bypasser is reachable and
working" - which is what #1292 spent its investigation doing.
"""
import shelfmark.download.http as http
import shelfmark.download.network as network
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: True)
def challenged(*_args, **_kwargs):
msg = "the site kept answering with a protection challenge - manual CAPTCHA"
raise ChallengeNotSolvedError(msg)
monkeypatch.setattr(http, "get_bypassed_page", challenged)
statuses: list[tuple[str, str | None]] = []
selector = network.AAMirrorSelector()
html = http.html_get_page(
"https://annas-archive.pk/search?q=dune",
retry=1,
selector=selector,
status_callback=lambda stage, detail: statuses.append((stage, detail)),
use_bypasser=True,
success_delay=0,
)
assert html == ""
assert selector.last_failure is not None
assert "manual CAPTCHA" in selector.last_failure
assert "reachable" not in selector.last_failure
assert ("error", "the site kept answering with a protection challenge - manual CAPTCHA") in (
statuses
)
+2 -2
View File
@@ -306,8 +306,8 @@ def test_search_books_filters_locally_when_path_language_enabled(monkeypatch):
captured_url: dict[str, str] = {}
def _fake_html_get_page(url: str, selector, allow_bypasser_fallback=False):
del selector, allow_bypasser_fallback
def _fake_html_get_page(url: str, selector, **_kwargs):
del selector
captured_url["url"] = url
return r"""
<table>
@@ -0,0 +1,114 @@
"""The untabled-page diagnostic must name the mirror that actually answered.
Regression for #1298. `html_get_page` rotates mirrors and follows redirects internally,
so the URL the search layer passed in is only where the attempt started. Logging that
one made the debug bundle report the untabled page against annas-archive.gl when the
body had come from .pk - the exact triage cost the #1289 diagnostics were added to
remove, reintroduced by reading the wrong variable.
"""
import logging
import pytest
# A protection challenge, so the fingerprint line fires without looking like AA.
CHALLENGE_PAGE = (
"<html><head><title>DDOS-GUARD</title>"
'<link rel="stylesheet" href="/.well-known/ddos-guard/ddg-captcha-page/index.css">'
"</head><body>Complete the manual check to continue</body></html>"
)
@pytest.fixture
def search_logs():
"""Collect this module's log messages.
setup_logger builds loggers outside the standard hierarchy, so their records never
reach the root handler caplog installs - see tests/bypass/test_ddg_cookie_reuse.py.
"""
import shelfmark.release_sources.direct_download as dd
messages: list[str] = []
class _Capture(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
messages.append(record.getMessage())
handler = _Capture()
dd.logger.addHandler(handler)
previous = dd.logger.level
dd.logger.setLevel(logging.DEBUG)
dd.logger._cache.clear()
try:
yield messages
finally:
dd.logger.removeHandler(handler)
dd.logger.setLevel(previous)
class _Selector:
current_base = "https://annas-archive.gl"
last_failure = None
def rewrite(self, url: str) -> str:
return url
def next_mirror_or_rotate_dns(self, *, fatal: bool = False, reason: str = ""):
return None, "exhausted"
REQUESTED = "https://annas-archive.gl/search?q=Ken+follett"
ANSWERED = "https://annas-archive.pk/search?q=Ken+follett"
def test_the_fingerprint_names_the_mirror_that_answered(monkeypatch, search_logs):
import shelfmark.release_sources.direct_download as dd
def fake_get(url, **kwargs):
# The caller must ask for it, or there is nothing to report.
assert kwargs["include_response_url"] is True
assert url == REQUESTED
# What an internal rotation looks like from the outside: a different host.
return CHALLENGE_PAGE, ANSWERED
monkeypatch.setattr(dd.downloader, "html_get_page", fake_get)
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["a"])
with pytest.raises(dd.SearchUnavailableError):
dd._fetch_search_table_uncached(REQUESTED, _Selector())
fingerprint = [m for m in search_logs if m.startswith("Search page has no results table")]
assert len(fingerprint) == 1
assert ANSWERED in fingerprint[0]
assert "annas-archive.gl" not in fingerprint[0]
def test_a_downloader_that_reports_no_url_falls_back_to_the_request(monkeypatch, search_logs):
"""The plain-string shape stays supported; the line is still worth having."""
import shelfmark.release_sources.direct_download as dd
monkeypatch.setattr(dd.downloader, "html_get_page", lambda _url, **_k: CHALLENGE_PAGE)
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["a"])
with pytest.raises(dd.SearchUnavailableError):
dd._fetch_search_table_uncached(REQUESTED, _Selector())
fingerprint = [m for m in search_logs if m.startswith("Search page has no results table")]
assert len(fingerprint) == 1
assert REQUESTED in fingerprint[0]
def test_the_empty_body_give_up_survives_the_tuple_shape(monkeypatch):
"""`("", url)` is truthy, so the exhaustion check has to read the body."""
import shelfmark.release_sources.direct_download as dd
monkeypatch.setattr(dd.downloader, "html_get_page", lambda _url, **_k: ("", REQUESTED))
monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["a"])
selector = _Selector()
selector.last_failure = "Every mirror refused the connection."
with pytest.raises(dd.SearchUnavailableError) as excinfo:
dd._fetch_search_table_uncached(REQUESTED, selector)
assert "Every mirror refused the connection." in str(excinfo.value)
@@ -0,0 +1,139 @@
"""A solver must never be handed DDoS-Guard's ?check=1 probe URL.
Regression for #1292. `html_get_page` follows Anna's Archive redirects by hand, and
DDoS-Guard's gate answers /search with a 302 to the same path plus `check=1`. Because
the follower walks that handshake by reassigning `current_url`, every downstream handoff
- the 403 branch, the 503-challenge branch, the redirect-loop rescues - passed the
*probe* URL to the bypasser rather than the page we wanted.
A solver opens that in a fresh browser holding none of the cookies the probe exists to
collect, so DDoS-Guard cannot verify it automatically and serves the manual CAPTCHA page
that nothing can solve. The reporter's log is exactly that: a 403 handed off on a
`&check=1` URL, FlareSolverr answering "Challenge solved!", and a 4.7 KB DDOS-GUARD
CAPTCHA page coming back.
"""
import requests
class _FakeResponse:
"""Minimal stand-in for requests.Response covering what html_get_page touches."""
def __init__(
self,
status_code: int,
*,
url: str,
text: str = "",
headers: dict[str, str] | None = None,
cookies: dict[str, str] | None = None,
) -> None:
self.status_code = status_code
self.url = url
self.text = text
self.cookies = cookies or {}
self.headers = {"Content-Type": "text/html;charset=utf-8", **(headers or {})}
self.is_redirect = 300 <= status_code < 400
def raise_for_status(self) -> None:
if self.status_code >= 400:
error = requests.exceptions.HTTPError(f"{self.status_code} Error")
error.response = self
raise error
def _aa_http(monkeypatch):
"""Import http with the network stubbed out and AA treated as an AA host."""
import shelfmark.download.http as http
monkeypatch.setattr(http, "_apply_cf_bypass", lambda _url, _headers: {})
monkeypatch.setattr(http, "get_proxies", lambda _url: {})
monkeypatch.setattr(http, "get_ssl_verify", lambda _url: True)
monkeypatch.setattr(http.time, "sleep", lambda _seconds: None)
monkeypatch.setattr(http, "_is_cf_bypass_enabled", lambda: True)
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: True)
monkeypatch.setattr(http.network, "is_aa_auto_mode", lambda: True)
return http
SEARCH_URL = "https://annas-archive.pk/search?index=&display=table&q=Ken+follett"
PROBE_URL = f"{SEARCH_URL}&check=1"
def test_403_on_the_check_probe_hands_over_the_pre_probe_url(monkeypatch):
"""The reporter's exact sequence: 302 to ?check=1, then 403 on the probe."""
http = _aa_http(monkeypatch)
bypassed: list[str] = []
def fake_get(url: str, **_kwargs):
if "check=1" not in url:
return _FakeResponse(302, url=url, headers={"Location": PROBE_URL})
return _FakeResponse(403, url=url)
monkeypatch.setattr(http.requests, "get", fake_get)
monkeypatch.setattr(
http, "get_bypassed_page", lambda url, *_a, **_k: bypassed.append(url) or "<html>ok</html>"
)
html = http.html_get_page(SEARCH_URL, retry=1, success_delay=0)
assert html == "<html>ok</html>"
# The page we wanted, not the handshake hop we happened to be standing on.
assert bypassed == [SEARCH_URL]
def test_redirect_loop_hands_over_the_pre_probe_url(monkeypatch):
"""Stale clearance turns the gate into an endless ?check=1 bounce."""
http = _aa_http(monkeypatch)
bypassed: list[str] = []
monkeypatch.setattr(
http.requests,
"get",
lambda url, **_kwargs: _FakeResponse(302, url=url, headers={"Location": PROBE_URL}),
)
monkeypatch.setattr(
http, "get_bypassed_page", lambda url, *_a, **_k: bypassed.append(url) or "<html>ok</html>"
)
html = http.html_get_page(SEARCH_URL, retry=1, success_delay=0)
assert html == "<html>ok</html>"
assert bypassed == [SEARCH_URL]
def test_only_the_check_parameter_is_dropped(monkeypatch):
"""Everything else about the URL survives - it is still the search we asked for."""
http = _aa_http(monkeypatch)
url = (
"https://annas-archive.pk/search?index=&page=1&display=table&acc=aa_download"
"&acc=external_download&ext=epub&q=Ken+follett&check=1"
)
assert http._solvable_url(url) == (
"https://annas-archive.pk/search?index=&page=1&display=table&acc=aa_download"
"&acc=external_download&ext=epub&q=Ken+follett"
)
def test_a_url_without_the_probe_is_returned_untouched(monkeypatch):
"""No rewriting, no re-encoding: an unrelated URL must come back identical."""
http = _aa_http(monkeypatch)
url = "https://annas-archive.pk/md5/abc?q=a%20b&empty="
assert http._solvable_url(url) is url
def test_non_aa_hosts_keep_their_check_parameter(monkeypatch):
"""Elsewhere `check` is an ordinary query parameter and none of our business."""
import shelfmark.download.http as http
monkeypatch.setattr(http.network, "should_rotate_dns_for_url", lambda _url: False)
url = "https://example.com/api?check=1"
assert http._solvable_url(url) == url
Generated
+16 -16
View File
@@ -451,11 +451,11 @@ wheels = [
[[package]]
name = "filelock"
version = "3.32.4"
version = "3.32.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/30/03b03951873a1a0ffc7e8ca0e10c15597b59e8d0e39260704cd2ea087bc4/filelock-3.32.4.tar.gz", hash = "sha256:2bde2e4cf732e0153406d8a7bc80620ecf5e621fe0d25e41143c4e3b4733ff30", size = 222126, upload-time = "2026-08-23T17:37:55.363Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0a/a0/50c2c0ce5e74d7721bbb1b19a26ebd339aac5878553a6e35308c2f31f935/filelock-3.32.5.tar.gz", hash = "sha256:f6a6a28f743f9b95ce19db5abe0f376f75eb56517dff21e1a4751e2657d3e83d", size = 222838, upload-time = "2026-08-31T18:56:34.729Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/a4/9b63d595d748e3aff8812b65eacc1a2c4bd90b7c2012e08e72373b4835eb/filelock-3.32.4-py3-none-any.whl", hash = "sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd", size = 99864, upload-time = "2026-08-23T17:37:53.913Z" },
{ url = "https://files.pythonhosted.org/packages/36/d2/b70a31e13d04456d28493f31d2aa087e99eeb2767ef0293b2625727ccb8c/filelock-3.32.5-py3-none-any.whl", hash = "sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2", size = 100003, upload-time = "2026-08-31T18:56:33.078Z" },
]
[[package]]
@@ -911,11 +911,11 @@ wheels = [
[[package]]
name = "platformdirs"
version = "4.11.5"
version = "4.11.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ea/06/cf1564dcc2e2261c8c8c6c05628dc8b418943bdae2a4e58640ceb2f770fa/platformdirs-4.11.5.tar.gz", hash = "sha256:e8b31f4f8bcbbedef91a6b57a706255e4f148d2a4e01648382a0a47342539173", size = 34823, upload-time = "2026-08-27T21:36:37.46Z" }
sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/12/6f3fcd5067a9cbf4f8664b32957973498da8b083455203c8d9cab83a725c/platformdirs-4.11.5-py3-none-any.whl", hash = "sha256:89f8d42695853b89c7170bd49bc3dc593f98a71e695ede88e06a3b247bc4563b", size = 23900, upload-time = "2026-08-27T21:36:36.227Z" },
{ url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" },
]
[[package]]
@@ -1411,7 +1411,7 @@ wheels = [
[[package]]
name = "selenium"
version = "4.47.0"
version = "4.48.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -1421,14 +1421,14 @@ dependencies = [
{ name = "urllib3", extra = ["socks"] },
{ name = "websocket-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/a2/213190a606bc036b4db1b8129f399964988872a555b50dfbfddf612d333c/selenium-4.47.0.tar.gz", hash = "sha256:4f6667c23080646e045fb91d2039687e88f549d667961f6ce85832b17384b68e", size = 1014095, upload-time = "2026-08-10T17:54:11.99Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7f/8c/db97bdc1a8b41e7b6bf9d3099722ed8e7ac61328af8637f20004015c642b/selenium-4.48.0.tar.gz", hash = "sha256:045c1ec054c94e3be6c10febc509aa513b4c05e9146d1a9cf3de5375ec6ca2a1", size = 1055269, upload-time = "2026-08-27T20:05:51.235Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/0b/652575986d2ed03d29103d8574580a03aefe50d243d225b441e2375bd0f6/selenium-4.47.0-py3-none-any.whl", hash = "sha256:2eac6b8e7c017f57ecc40820383da8881a6fd7a90ea555c1b0af322f2344b347", size = 9511195, upload-time = "2026-08-10T17:54:09.369Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ee/5d1b0e9cb43965902f0a9f7add527134db71cabf2f26766ae2fdb7774b9a/selenium-4.48.0-py3-none-any.whl", hash = "sha256:b2a1d77019db92513e59aa2376710fe3d42b65a4d493dccd0c799c1a0d574d93", size = 9561411, upload-time = "2026-08-27T20:05:48.778Z" },
]
[[package]]
name = "seleniumbase"
version = "4.52.4"
version = "4.53.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
@@ -1492,9 +1492,9 @@ dependencies = [
{ name = "wheel" },
{ name = "wsproto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/97/d8/ffc8d0090c678d200b2d6828c12d96127485b5fcd005e50c8e1057f1583b/seleniumbase-4.52.4.tar.gz", hash = "sha256:93bc048d63634c416f0e9a9326950f8c4b753fecea16fb95dd72f17217e4f22b", size = 678429, upload-time = "2026-08-25T18:25:34.2Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/da/6521c25ce5498853a69cf01e98dd46ed9330b28be5a7b47d2757ea512129/seleniumbase-4.53.5.tar.gz", hash = "sha256:500c94bc86fb1c0f285aadaadf0332397eb4886e7b4fecc6808e8bbd02c4ab54", size = 690221, upload-time = "2026-09-02T05:34:51.789Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/05/4fd503779d32495215d57ca2b1a41caa2e0078af4978504a7e5daeda7281/seleniumbase-4.52.4-py3-none-any.whl", hash = "sha256:1cde15f47bd71deefe6d8ea6318a7864ebda1f970861c736211c830aa3f28d20", size = 683214, upload-time = "2026-08-25T18:25:31.277Z" },
{ url = "https://files.pythonhosted.org/packages/1b/03/cc31c8fb3cf096548c7716cfa6870481d7b85c4672f63b55ea301211acec/seleniumbase-4.53.5-py3-none-any.whl", hash = "sha256:0c0cdb16d56eb1f95ea73bd7428d0cdd8721f152e28a5aee164ef4b7c522e599", size = 695247, upload-time = "2026-09-02T05:34:49.366Z" },
]
[[package]]
@@ -1575,7 +1575,7 @@ requires-dist = [
{ name = "qbittorrent-api", specifier = ">=2026.8.1" },
{ name = "rarfile" },
{ name = "requests", extras = ["socks"] },
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.52.4" },
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.53.5" },
{ name = "tqdm" },
{ name = "transmission-rpc" },
]
@@ -1751,11 +1751,11 @@ wheels = [
[[package]]
name = "websocket-client"
version = "1.9.0"
version = "1.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393827c650c6296660ce40374ac22d99ab9aea41f9d2a2/websocket_client-1.9.2.tar.gz", hash = "sha256:0fcb57545848be86992e128218fd96dd87a6769ffdb1a968dff79632b85604d0", size = 84110, upload-time = "2026-08-31T14:08:40.964Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
{ url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" },
]
[[package]]