From 633004ecf0d429615abc66e11f4789201ee059d6 Mon Sep 17 00:00:00 2001 From: CaliBrain Date: Tue, 1 Sep 2026 11:19:01 -0400 Subject: [PATCH] fix(search): stop reading real Anna's Archive pages as unsolved challenges (#1294) `_looks_like_challenge_page` substring-matched "ddos-guard"/"cloudflare" over the whole document. DDoS-Guard-fronted sites carry those strings on their own pages - Anna's Archive ships a `DDOS-GUARD` comment in the inline JS it serves on every page - so every real AA response that was not a results table was reported as an unsolved protection challenge, sending users off to fix a bypasser that had just succeeded. Measured against live pages: a served AA page (HTTP 200) is 182,685 bytes and matched the old detector; the real interstitial is 902 bytes. - `_looks_like_challenge_page` now delegates to the shared `challenge_marker()`, whose 64 KB cap is what separates a few-KB interstitial from the page behind it. `download/http.py` already used it; this module carried an unguarded private copy. - `_looks_like_aa_page` is checked ahead of the challenge branch. A genuine interstitial carries no AA markers, so nothing actually blocked leaks through. Also adds the diagnostics whose absence made #1289 guesswork: the debug bundle carries no response bodies, so "unsolved protection challenge" and FlareSolverr's "Challenge solved!" were indistinguishable after the fact. - `_log_untabled_search_page()` fingerprints the one ambiguous shape at INFO - size, size-cap verdict, AA markers, challenge marker - with a bounded 700-char head at DEBUG. Best-effort: it swallows its own errors. - The external bypasser records what it actually returned, and warns when it reports success while handing back a challenge page. Regression tests use fixtures built from the live pages rather than invented ones; the previous fixtures were two-line synthetic pages with no "ddos-guard" substring, which is why nothing caught this. Closes #1289 Closes #1292 --- shelfmark/bypass/external_bypasser.py | 20 ++ shelfmark/release_sources/direct_download.py | 83 +++++-- .../test_search_challenge_false_positive.py | 217 ++++++++++++++++++ 3 files changed, 306 insertions(+), 14 deletions(-) create mode 100644 tests/direct_download/test_search_challenge_false_positive.py diff --git a/shelfmark/bypass/external_bypasser.py b/shelfmark/bypass/external_bypasser.py index 39018741..53dad163 100644 --- a/shelfmark/bypass/external_bypasser.py +++ b/shelfmark/bypass/external_bypasser.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any import requests from shelfmark.bypass import BypassCancelledError +from shelfmark.bypass.challenge import challenge_marker from shelfmark.bypass.cookie_store import store_extracted_cookies from shelfmark.core.config import config from shelfmark.core.logger import setup_logger @@ -143,6 +144,25 @@ def _fetch_via_bypasser(target_url: str) -> str | None: logger.warning("External bypasser returned empty response for '%s'", target_url) return None + # "Challenge solved!" is the solver's verdict on its own work, and #1289 showed + # it can be reported alongside a page the caller then rejects. Say what actually + # came back, so a later report does not have to infer it from downstream errors. + marker = challenge_marker(html) + logger.debug( + "External bypasser page for '%s': %d bytes, challenge_marker=%r", + target_url, + len(html), + marker, + ) + if marker: + logger.warning( + "External bypasser reported success but returned a challenge page for " + "'%s' (%d bytes, marker=%r) - the solve did not clear the protection", + target_url, + len(html), + marker, + ) + try: _store_solution_clearance(target_url, solution) except AttributeError, KeyError, TypeError, ValueError: diff --git a/shelfmark/release_sources/direct_download.py b/shelfmark/release_sources/direct_download.py index f481599d..83836f7f 100644 --- a/shelfmark/release_sources/direct_download.py +++ b/shelfmark/release_sources/direct_download.py @@ -16,6 +16,7 @@ import requests from bs4 import BeautifulSoup, Tag from bs4.element import NavigableString +from shelfmark.bypass.challenge import MAX_CHALLENGE_HTML_CHARS, challenge_marker from shelfmark.config.env import DEBUG_SKIP_SOURCES, TMP_DIR from shelfmark.core import search_deadline from shelfmark.core.config import config @@ -556,13 +557,6 @@ _AA_PAGE_MARKERS = ( "/fast_download", "/slow_download", ) -_CHALLENGE_MARKERS = ( - "ddos-guard", - "just a moment", - "cloudflare", - "checking your browser", - "cf-browser-verification", -) def _looks_like_aa_page(html: str) -> bool: @@ -572,9 +566,61 @@ def _looks_like_aa_page(html: str) -> bool: def _looks_like_challenge_page(html: str) -> bool: - """Whether ``html`` is a protection interstitial rather than the site behind it.""" - lowered = html.lower() - return any(marker in lowered for marker in _CHALLENGE_MARKERS) + """Whether ``html`` is a protection interstitial rather than the site behind it. + + Delegates to the shared detector rather than substring-matching here. A bare + "ddos-guard"/"cloudflare" scan flags the protected site's *own* pages: DDoS-Guard + links its endpoints on everything it fronts, and AA ships a `DDOS-GUARD` comment in + the inline JS on every page it serves. That misread every real AA response that was + not a results table as an unsolved challenge, and sent users off to fix a bypasser + that had just succeeded - see #1289/#1292. `challenge_marker` caps its scan at + 64 KB, which is what separates a few-KB interstitial from the page behind it. + """ + return challenge_marker(html) is not None + + +# How much of an unreadable search page to quote in the debug log. Enough to carry the +# - title, injected challenge scripts - without pasting a 180 KB page into a log +# file that ships inside the debug bundle. +_PAGE_FINGERPRINT_CHARS = 700 +_TITLE_RE = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) + + +def _log_untabled_search_page(url: str, html: str) -> None: + """Record why a search page with no results table is about to be classified. + + #1289 cost a full investigation because the log said only "unsolved protection + challenge" while FlareSolverr said "Challenge solved!", and the debug bundle carries + no response bodies - there was no way to tell a real AA page from an interstitial + after the fact. These are the facts that would have settled it in one line: the size + (the 64 KB cap is what separates the two), which markers matched, and the head of + the document. + + Diagnostics must never be the reason a search fails, so this swallows its own errors. + """ + try: + title_match = _TITLE_RE.search(html[: _PAGE_FINGERPRINT_CHARS * 4]) + title = " ".join(title_match.group(1).split())[:120] if title_match else "" + lowered = html.lower() + aa_markers = [marker for marker in _AA_PAGE_MARKERS if marker in lowered] + logger.info( + "Search page has no results table: %s (bytes=%d, title=%r, aa_markers=%s, " + "challenge_marker=%r, over_challenge_size_cap=%s)", + url, + len(html), + title, + aa_markers or "none", + challenge_marker(html), + len(html) > MAX_CHALLENGE_HTML_CHARS, + ) + logger.debug( + "Untabled search page head (%d of %d bytes): %s", + min(len(html), _PAGE_FINGERPRINT_CHARS), + len(html), + html[:_PAGE_FINGERPRINT_CHARS], + ) + except Exception: + logger.debug("Could not fingerprint the untabled search page", exc_info=True) def _fetch_search_table(url: str, selector: network.AAMirrorSelector) -> tuple[str, Tag | None]: @@ -615,6 +661,19 @@ def _fetch_search_table(url: str, selector: network.AAMirrorSelector) -> tuple[s if "No files found." in html: # A real, genuinely empty answer from a healthy mirror. return html, None + + # A search page with no table is the one shape we cannot read off the response + # 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) + + if _looks_like_aa_page(html): + # A real AA response in a shape the caller should report as drift. Checked + # ahead of the challenge branch: AA's own pages carry the protection's + # markers, so an interstitial is only the better explanation once the page + # has nothing of AA's about it. A genuine interstitial has no AA markers. + return html, None if _looks_like_challenge_page(html): # The bypass did not actually clear the protection - the interstitial is # what came back. Rotating is pointless (every mirror shares the same @@ -625,10 +684,6 @@ def _fetch_search_table(url: str, selector: network.AAMirrorSelector) -> tuple[s "Check that the bypasser is reachable and working." ) raise SearchUnavailableError(msg) - if _looks_like_aa_page(html): - # A real AA response in a shape the caller should report as drift. - # Not the mirror's fault. - return html, None new_base, action = selector.next_mirror_or_rotate_dns( fatal=True, reason="responded without an Anna's Archive page" diff --git a/tests/direct_download/test_search_challenge_false_positive.py b/tests/direct_download/test_search_challenge_false_positive.py new file mode 100644 index 00000000..9a0f8ce1 --- /dev/null +++ b/tests/direct_download/test_search_challenge_false_positive.py @@ -0,0 +1,217 @@ +"""A real Anna's Archive page must not be mistaken for a protection interstitial. + +Regression for #1289/#1292. `_looks_like_challenge_page` substring-matched "ddos-guard" +over the whole document, and DDoS-Guard-fronted sites carry that string on their *own* +pages - Anna's Archive ships a `DDOS-GUARD` comment in the inline JS it serves on every +page. Any real AA response that was not a results table was therefore reported as an +unsolved challenge, telling users to go fix a bypasser that had just succeeded. +""" + +import pytest +from bs4 import Tag + +# Verbatim from a live annas-archive.pk 403, trimmed of nothing that matters: this is +# what an interstitial actually looks like, and it is under a kilobyte. +DDOS_GUARD_INTERSTITIAL = ( + 'DDoS-Guard' + '' + '' + '' + '

' + 'Checking your browser before accessing

' + "

Please wait a few seconds.

" +) + +# The marker that made every real AA page look like a challenge, quoted from the live +# site's inline JS, plus enough real page to clear the 64 KB size guard. +_AA_DDG_COMMENT = '// "text/css" for DDOS-GUARD caching.' +AA_PAGE_WITHOUT_TABLE = ( + "Anna’s Archive" + f"" + '
a record' + + ("

real page body content

" * 3000) + + "
" +) + + +class _Selector: + def __init__(self, bases: list[str]) -> None: + self._bases = bases + self._index = 0 + self.current_base = bases[0] + self.quarantined: list[str] = [] + + def rewrite(self, url: str) -> str: + for base in self._bases: + if url.startswith(base): + return url.replace(base, self.current_base, 1) + return url + + def next_mirror_or_rotate_dns(self, *, fatal: bool = False, reason: str = ""): + if fatal: + self.quarantined.append(self.current_base) + self._index += 1 + if self._index >= len(self._bases): + return None, "exhausted" + self.current_base = self._bases[self._index] + return self.current_base, "mirror" + + +def _patch_pages(monkeypatch, pages: list[str]): + import shelfmark.release_sources.direct_download as dd + + calls: list[str] = [] + + def fake_get(url, **_kwargs): + calls.append(url) + return pages[len(calls) - 1] if len(calls) <= len(pages) else "" + + monkeypatch.setattr(dd.downloader, "html_get_page", fake_get) + monkeypatch.setattr(dd.network, "get_available_aa_urls", lambda: ["a", "b"]) + return dd, calls + + +@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 logging + + 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) + # Logger.setLevel only invalidates the is-enabled cache through the manager, which + # these loggers are not registered with; without this the DEBUG line stays filtered. + dd.logger._cache.clear() + try: + yield messages + finally: + dd.logger.removeHandler(handler) + dd.logger.setLevel(previous) + + +def test_the_size_guard_is_what_separates_a_real_page_from_an_interstitial(): + """The two inputs this bug turned on, checked directly.""" + import shelfmark.release_sources.direct_download as dd + from shelfmark.bypass.challenge import MAX_CHALLENGE_HTML_CHARS + + assert len(DDOS_GUARD_INTERSTITIAL) < MAX_CHALLENGE_HTML_CHARS + assert len(AA_PAGE_WITHOUT_TABLE) > MAX_CHALLENGE_HTML_CHARS + # Both contain "ddos-guard"; only one is a challenge. + assert "ddos-guard" in AA_PAGE_WITHOUT_TABLE.lower() + assert dd._looks_like_challenge_page(DDOS_GUARD_INTERSTITIAL) + assert not dd._looks_like_challenge_page(AA_PAGE_WITHOUT_TABLE) + + +def test_real_aa_page_without_a_table_is_not_reported_as_a_challenge(monkeypatch): + """The #1289 failure: a served AA page raised "unsolved protection challenge".""" + dd, calls = _patch_pages(monkeypatch, [AA_PAGE_WITHOUT_TABLE]) + selector = _Selector(["https://real.test", "https://other.test"]) + + html, table = dd._fetch_search_table("https://real.test/search?q=malice", selector) + + assert table is None + assert html == AA_PAGE_WITHOUT_TABLE + # A live mirror holding our clearance: neither quarantined nor rotated away from. + assert selector.quarantined == [] + assert len(calls) == 1 + + +def test_aa_markers_win_over_challenge_markers_on_the_same_page(monkeypatch): + """Ordering, not just the size guard, keeps a marker-carrying AA page readable.""" + dd, _calls = _patch_pages(monkeypatch, [AA_PAGE_WITHOUT_TABLE]) + monkeypatch.setattr(dd, "_looks_like_challenge_page", lambda _html: True) + selector = _Selector(["https://real.test", "https://other.test"]) + + _html, table = dd._fetch_search_table("https://real.test/search?q=malice", selector) + + assert table is None + + +def test_genuine_interstitial_still_raises(monkeypatch): + """The behaviour the check exists for is untouched.""" + dd, _calls = _patch_pages(monkeypatch, [DDOS_GUARD_INTERSTITIAL]) + selector = _Selector(["https://real.test", "https://other.test"]) + + with pytest.raises(dd.SearchUnavailableError, match="protection challenge"): + dd._fetch_search_table("https://real.test/search?q=malice", selector) + + assert selector.quarantined == [] + + +def test_results_table_is_still_returned(monkeypatch): + """A page carrying the marker and a table is read as results, as before.""" + page = AA_PAGE_WITHOUT_TABLE.replace( + "
", "
Malice
" + ) + dd, _calls = _patch_pages(monkeypatch, [page]) + selector = _Selector(["https://real.test"]) + + _html, table = dd._fetch_search_table("https://real.test/search?q=malice", selector) + + assert isinstance(table, Tag) + + +def test_untabled_page_is_fingerprinted_in_the_log(monkeypatch, search_logs): + """#1289 was unsolvable from the bundle because no line said what came back. + + The log must carry the facts that separate the two cases, so the next report is read + rather than reverse-engineered: size, whether the size guard applied, the AA markers + found, and the challenge marker (or its absence). + """ + dd, _calls = _patch_pages(monkeypatch, [AA_PAGE_WITHOUT_TABLE]) + selector = _Selector(["https://real.test", "https://other.test"]) + + dd._fetch_search_table("https://real.test/search?q=malice", selector) + + verdict = next(m for m in search_logs if "no results table" in m) + assert f"bytes={len(AA_PAGE_WITHOUT_TABLE)}" in verdict + assert "over_challenge_size_cap=True" in verdict + assert "challenge_marker=None" in verdict + assert "/md5/" in verdict + # The head of the document is quoted too, bounded so a 180 KB page cannot flood the + # log file that ships inside the debug bundle. + head = next(m for m in search_logs if "Untabled search page head" in m) + assert AA_PAGE_WITHOUT_TABLE[:200] in head + assert len(head) < 2000 + + +def test_interstitial_fingerprint_names_the_marker_that_proved_it(monkeypatch, search_logs): + """The same line must also settle the opposite case, without needing the body.""" + dd, _calls = _patch_pages(monkeypatch, [DDOS_GUARD_INTERSTITIAL]) + selector = _Selector(["https://real.test", "https://other.test"]) + + with pytest.raises(dd.SearchUnavailableError): + dd._fetch_search_table("https://real.test/search?q=malice", selector) + + verdict = next(m for m in search_logs if "no results table" in m) + assert "over_challenge_size_cap=False" in verdict + assert "challenge_marker='/.well-known/ddos-guard/'" in verdict + assert "aa_markers=none" in verdict + + +def test_fingerprint_failure_never_breaks_a_search(monkeypatch): + """Diagnostics are best-effort; a bug in them must not cost the user their search.""" + dd, _calls = _patch_pages(monkeypatch, [AA_PAGE_WITHOUT_TABLE]) + + def boom(_html): + raise RuntimeError("marker scan blew up") + + monkeypatch.setattr(dd, "challenge_marker", boom) + selector = _Selector(["https://real.test", "https://other.test"]) + + _html, table = dd._fetch_search_table("https://real.test/search?q=malice", selector) + + assert table is None