Kaynağa Gözat

feat(fetcher): detect Cloudflare bot challenge responses

Inspect response headers to recognize Cloudflare interstitial pages
(cf-mitigated: challenge, or 403/503 served by cloudflare with cf-ray
and an HTML body) and surface a dedicated localized error instead of a
generic HTTP failure.
Frédéric Guillot 2 hafta önce
ebeveyn
işleme
68bc5a92e2

+ 1 - 0
internal/locale/translations/ar_SA.json

@@ -119,6 +119,7 @@
     "error.http_bad_gateway": "الموقع غير متاح حالياً بسبب خطأ في البوابة (Bad Gateway). المشكلة ليست من جانب Miniflux. يرجى المحاولة لاحقاً.",
     "error.http_body_read": "تعذر قراءة محتوى استجابة HTTP: %v.",
     "error.http_client_error": "خطأ في عميل HTTP: %v.",
+    "error.http_cloudflare_challenge": "هذا الموقع محمي بآلية تحدي Cloudflare (اختبار CAPTCHA أو التحقق عبر JavaScript). لا يستطيع Miniflux حل هذا التحدي تلقائياً.",
     "error.http_empty_response": "استجابة HTTP فارغة. ربما يستخدم هذا الموقع آلية حماية ضد الروبوتات؟",
     "error.http_empty_response_body": "محتوى استجابة HTTP فارغ.",
     "error.http_forbidden": "الوصول إلى هذا الموقع ممنوع. ربما يوجد آلية حماية ضد الروبوتات؟",

+ 1 - 0
internal/locale/translations/de_DE.json

@@ -106,6 +106,7 @@
     "error.http_bad_gateway": "Die Webseite ist aufgrund eines Bad-Gateway-Fehlers derzeit nicht verfügbar. Das Problem liegt nicht bei Miniflux. Bitte versuchen Sie es später erneut.",
     "error.http_body_read": "Der HTTP-Inhalt kann nicht gelesen werden: %v",
     "error.http_client_error": "HTTP-Client-Fehler: %v.",
+    "error.http_cloudflare_challenge": "Diese Webseite ist durch eine Cloudflare-Bot-Abfrage (CAPTCHA oder JavaScript-Verifizierung) geschützt. Miniflux kann diese Abfrage nicht automatisch lösen.",
     "error.http_empty_response": "Die HTTP-Antwort ist leer. Vielleicht versucht die Webseite, sich vor Bots zu schützen?",
     "error.http_empty_response_body": "Der Inhalt der HTTP-Antwort ist leer.",
     "error.http_forbidden": "Der Zugriff auf diese Webseite ist verboten. Vielleicht versucht die Webseite, sich vor Bots zu schützen?",

+ 1 - 0
internal/locale/translations/el_EL.json

@@ -106,6 +106,7 @@
     "error.http_bad_gateway": "Ο ιστότοπος δεν είναι διαθέσιμος αυτήν τη στιγμή λόγω σφάλματος κακής πύλης. Το πρόβλημα δεν είναι στην πλευρά του Miniflux. Παρακαλώ δοκιμάστε ξανά αργότερα.",
     "error.http_body_read": "Δεν είναι δυνατή η ανάγνωση του σώματος HTTP: %v.",
     "error.http_client_error": "Σφάλμα πελάτη HTTP: %v.",
+    "error.http_cloudflare_challenge": "Αυτός ο ιστότοπος προστατεύεται από πρόκληση bot του Cloudflare (CAPTCHA ή επαλήθευση JavaScript). Το Miniflux δεν μπορεί να επιλύσει αυτήν την πρόκληση αυτόματα.",
     "error.http_empty_response": "Η απάντηση HTTP είναι κενή. Ίσως αυτός ο ιστότοπος χρησιμοποιεί μηχανισμό προστασίας από bot;",
     "error.http_empty_response_body": "Το σώμα απάντησης HTTP είναι κενό.",
     "error.http_forbidden": "Η πρόσβαση σε αυτόν τον ιστότοπο απαγορεύεται. Ίσως αυτός ο ιστότοπος διαθέτει μηχανισμό προστασίας από bot;",

+ 1 - 0
internal/locale/translations/en_US.json

@@ -107,6 +107,7 @@
     "error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
     "error.http_body_read": "Unable to read the HTTP body: %v.",
     "error.http_client_error": "HTTP client error: %v.",
+    "error.http_cloudflare_challenge": "This website is protected by a Cloudflare bot challenge (CAPTCHA or JavaScript verification). Miniflux cannot solve this challenge automatically.",
     "error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
     "error.http_empty_response_body": "The HTTP response body is empty.",
     "error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",

+ 1 - 0
internal/locale/translations/es_ES.json

@@ -106,6 +106,7 @@
     "error.http_bad_gateway": "El sitio web no está disponible en este momento debido a un error en la puerta de enlace. El problema no está en el lado de Miniflux. Por favor, inténtalo de nuevo más tarde.",
     "error.http_body_read": "Imposible leer el cuerpo HTTP: %v.",
     "error.http_client_error": "Error cliente HTTP: %v.",
+    "error.http_cloudflare_challenge": "Este sitio web está protegido por un desafío de bot de Cloudflare (CAPTCHA o verificación de JavaScript). Miniflux no puede resolver este desafío automáticamente.",
     "error.http_empty_response": "La respuesta HTTP está vacía. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?",
     "error.http_empty_response_body": "El cuerpo de la respuesta HTTP está vacío.",
     "error.http_forbidden": "El acceso a este sitio web está prohibido. ¿Quizás este sitio web tiene un mecanismo de protección contra bots?",

+ 1 - 0
internal/locale/translations/fi_FI.json

@@ -106,6 +106,7 @@
     "error.http_bad_gateway": "Verkkosivusto ei ole tällä hetkellä saatavilla huonon yhdyskäytävän virheen vuoksi. Ongelma ei ole Miniflux-puolella. Yritä uudelleen myöhemmin.",
     "error.http_body_read": "HTTP-rungon lukeminen epäonnistui: %v.",
     "error.http_client_error": "HTTP-asiakasvirhe: %v.",
+    "error.http_cloudflare_challenge": "Tämä sivusto on suojattu Cloudflaren bottihaasteella (CAPTCHA tai JavaScript-todennus). Miniflux ei voi ratkaista tätä haastetta automaattisesti.",
     "error.http_empty_response": "HTTP-vastaus on tyhjä. Sivusto saattaa käyttää bottisuojausta?",
     "error.http_empty_response_body": "HTTP-vastauksen runko on tyhjä.",
     "error.http_forbidden": "Pääsy tälle sivustolle on kielletty. Sivustolla saattaa olla bottisuojaus?",

+ 1 - 0
internal/locale/translations/fr_FR.json

@@ -106,6 +106,7 @@
     "error.http_bad_gateway": "Le site web n'est pas disponible pour le moment à cause d'une erreur de passerelle réseau. Le problème ne vient pas de Miniflux. Veuillez réessayer plus tard.",
     "error.http_body_read": "Impossible de lire le corps de la réponse HTTP : %v.",
     "error.http_client_error": "Erreur du client HTTP : %v.",
+    "error.http_cloudflare_challenge": "Ce site web est protégé par un défi anti-bot Cloudflare (CAPTCHA ou vérification JavaScript). Miniflux ne peut pas résoudre ce défi automatiquement.",
     "error.http_empty_response": "La réponse HTTP est vide. Peut-être que ce site web bloque Miniflux avec une protection anti-bot ?",
     "error.http_empty_response_body": "Le corps de la réponse HTTP est vide.",
     "error.http_forbidden": "Accès interdit à ce site web. Il se peut que ce site web bloque Miniflux avec une protection anti-bot.",

+ 1 - 0
internal/locale/translations/gl_ES.json

@@ -107,6 +107,7 @@
     "error.http_bad_gateway": "O sitio web non está dispoñible debido a un erro na pasarela. O problema non está en Miniflux. Por favor, inténtao máis tarde.",
     "error.http_body_read": "Non se pode ler o corpo HTTP: %v.",
     "error.http_client_error": "Erro HTTP no cliente: %v.",
+    "error.http_cloudflare_challenge": "Este sitio web está protexido por un desafío de bot de Cloudflare (CAPTCHA ou verificación de JavaScript). Miniflux non pode resolver este desafío automaticamente.",
     "error.http_empty_response": "A resposta HTTP está baleira. Podería o sitio web estar usando unha protección contra robots?",
     "error.http_empty_response_body": "O corpo da resposta HTTP está baleiro.",
     "error.http_forbidden": "Esta prohibido o acceso a esta páxina web. Podería estar usando unha protección contra robots?",

+ 1 - 0
internal/locale/translations/hi_IN.json

@@ -106,6 +106,7 @@
     "error.http_bad_gateway": "खराब गेटवे त्रुटि के कारण वेबसाइट फिलहाल उपलब्ध नहीं है। समस्या Miniflux की तरफ नहीं है। कृपया बाद में फिर से कोशिश करें।",
     "error.http_body_read": "HTTP बॉडी पढ़ने में असमर्थ: %v।",
     "error.http_client_error": "HTTP क्लाइंट त्रुटि: %v।",
+    "error.http_cloudflare_challenge": "यह वेबसाइट Cloudflare बॉट चैलेंज (CAPTCHA या JavaScript सत्यापन) द्वारा सुरक्षित है। Miniflux इस चैलेंज को स्वचालित रूप से हल नहीं कर सकता।",
     "error.http_empty_response": "HTTP प्रतिक्रिया खाली है। शायद यह वेबसाइट बॉट सुरक्षा तंत्र का उपयोग कर रही है?",
     "error.http_empty_response_body": "HTTP प्रतिक्रिया बॉडी खाली है।",
     "error.http_forbidden": "इस वेबसाइट तक पहुंच वर्जित है। शायद इस वेबसाइट में बॉट सुरक्षा तंत्र है?",

+ 1 - 0
internal/locale/translations/id_ID.json

@@ -103,6 +103,7 @@
     "error.http_bad_gateway": "Situs ini tidak tersedia saat ini karena kesalahan akses peladen situs. Masalah ini bukan pada sisi Miniflux. Coba lagi nanti.",
     "error.http_body_read": "Tidak dapat membaca badan HTTP: %v.",
     "error.http_client_error": "Galat klien HTTP: %v.",
+    "error.http_cloudflare_challenge": "Situs web ini dilindungi oleh tantangan bot Cloudflare (CAPTCHA atau verifikasi JavaScript). Miniflux tidak dapat menyelesaikan tantangan ini secara otomatis.",
     "error.http_empty_response": "Balasan HTTP kosong. Mungkin, situs ini menggunakan mekanisme perlindungan dari bot?",
     "error.http_empty_response_body": "Badan balasan HTTP kosong.",
     "error.http_forbidden": "Akses ke situs ini terlarang. Mungkin, situs ini menggunakan mekanisme perlindungan dari bot?",

+ 1 - 0
internal/locale/translations/it_IT.json

@@ -106,6 +106,7 @@
     "error.http_bad_gateway": "Il sito web non è disponibile al momento a causa di un errore di gateway. Il problema non è dal lato di Miniflux. Per favore, riprova più tardi.",
     "error.http_body_read": "Impossibile leggere il corpo HTTP: %v.",
     "error.http_client_error": "Errore del client HTTP: %v.",
+    "error.http_cloudflare_challenge": "Questo sito web è protetto da una sfida bot di Cloudflare (CAPTCHA o verifica JavaScript). Miniflux non può risolvere questa sfida automaticamente.",
     "error.http_empty_response": "La risposta HTTP è vuota. Forse questo sito web utilizza un meccanismo di protezione dai bot?",
     "error.http_empty_response_body": "Il corpo della risposta HTTP è vuoto.",
     "error.http_forbidden": "L'accesso a questo sito web è vietato. Forse questo sito web ha un meccanismo di protezione dai bot?",

+ 1 - 0
internal/locale/translations/ja_JP.json

@@ -103,6 +103,7 @@
     "error.http_bad_gateway": "ウェブサイトは、不正なゲートウェイエラーのため現在利用できません。問題はMiniflux側にはありません。後でもう一度お試しください。",
     "error.http_body_read": "HTTP本文を読み取れません: %v。",
     "error.http_client_error": "HTTPクライアントエラー: %v。",
+    "error.http_cloudflare_challenge": "このウェブサイトは Cloudflare のボットチャレンジ(CAPTCHA または JavaScript 検証)によって保護されています。Miniflux はこのチャレンジを自動的に解くことができません。",
     "error.http_empty_response": "HTTP応答が空です。おそらく、このウェブサイトはボット保護メカニズムを使用していますか?",
     "error.http_empty_response_body": "HTTP応答本文が空です。",
     "error.http_forbidden": "このウェブサイトへのアクセスは禁止されています。おそらく、このウェブサイトはボット保護メカニズムを持っていますか?",

+ 1 - 0
internal/locale/translations/nan_Latn_pehoeji.json

@@ -103,6 +103,7 @@
     "error.http_bad_gateway": "Chit ê bāng-chām chit-má in-ūi gateway ū būn-tôe bô-hoat-tō͘ iōng, m̄ sī Miniflux chia ê būn-tôe, chhiáⁿ tán--chi̍t-ē chiah koh chhì-khòaⁿ-māi.",
     "error.http_body_read": "Bô-hoat-tō͘ tha̍k HTTP body lōe-iông: %v。",
     "error.http_client_error": "HTTP kheh-hō͘ thâu ū m̄-tio̍h: %v.",
+    "error.http_cloudflare_challenge": "Chit ê bāng-chām hō͘ Cloudflare ê bot thiau-chiàn (CAPTCHA ah-sī JavaScript giām-chèng) pó-hō͘. Miniflux bô-hoat-tō͘ chū-tōng kái-koat chit ê thiau-chiàn.",
     "error.http_empty_response": "HTTP hôe-èng lōe-iông sī khang--ê, ū khó-lêng sī hit ê bāng-chām ū pó-hō͘ ki-chè.",
     "error.http_empty_response_body": "HTTP hôe-èng body sī khang--ê.",
     "error.http_forbidden": "Hō͘ kū-choa̍t chûn-chhú chit ê bāng-chām, ū khó-lêng chit ê bāng-chām ū pó-hō͘ ki-chè.",

+ 1 - 0
internal/locale/translations/nl_NL.json

@@ -106,6 +106,7 @@
     "error.http_bad_gateway": "De website is momenteel niet beschikbaar vanwege een slechte-gateway-fout. De oorzaak hiervan ligt niet bij Miniflux. Probeer het later nogmaals aub.",
     "error.http_body_read": "Kan de HTTP-body niet lezen: %v.",
     "error.http_client_error": "HTTP-client-fout: %v.",
+    "error.http_cloudflare_challenge": "Deze website wordt beschermd door een Cloudflare-botuitdaging (CAPTCHA of JavaScript-verificatie). Miniflux kan deze uitdaging niet automatisch oplossen.",
     "error.http_empty_response": "De HTTP-respons is leeg. Misschien gebruikt deze website een botbeveiligingsmechanisme?",
     "error.http_empty_response_body": "De HTTP-respons body is leeg.",
     "error.http_forbidden": "Toegang tot deze website is verboden. Misschien heeft deze website een botbeveiligingsmechanisme?",

+ 1 - 0
internal/locale/translations/pl_PL.json

@@ -109,6 +109,7 @@
     "error.http_bad_gateway": "Strona jest w tej chwili niedostępna z powodu błędu nieprawidłowej bramy. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
     "error.http_body_read": "Nie można odczytać treści HTTP: %v.",
     "error.http_client_error": "Błąd klienta HTTP: %v.",
+    "error.http_cloudflare_challenge": "Ta strona jest chroniona przez wyzwanie botowe Cloudflare (CAPTCHA lub weryfikacja JavaScript). Miniflux nie może rozwiązać tego wyzwania automatycznie.",
     "error.http_empty_response": "Odpowiedź HTTP jest pusta. Być może ta witryna korzysta z mechanizmu ochrony przed botami?",
     "error.http_empty_response_body": "Treść odpowiedzi HTTP jest pusta.",
     "error.http_forbidden": "Dostęp do tej strony jest zabroniony. Być może ta strona ma mechanizm zabezpieczający przed botami?",

+ 1 - 0
internal/locale/translations/pt_BR.json

@@ -106,6 +106,7 @@
     "error.http_bad_gateway": "O site não está disponível no momento devido a um erro de gateway. O problema não está no Miniflux. Por favor, tente novamente mais tarde.",
     "error.http_body_read": "Não foi possível ler o corpo HTTP: %v.",
     "error.http_client_error": "Erro do cliente HTTP: %v.",
+    "error.http_cloudflare_challenge": "Este site é protegido por um desafio de bot do Cloudflare (CAPTCHA ou verificação JavaScript). O Miniflux não consegue resolver este desafio automaticamente.",
     "error.http_empty_response": "A resposta HTTP está vazia. Talvez este site esteja usando um mecanismo de proteção contra bots?",
     "error.http_empty_response_body": "O corpo da resposta HTTP está vazio.",
     "error.http_forbidden": "O acesso a este site está proibido. Talvez este site tenha um mecanismo de proteção contra bots?",

+ 1 - 0
internal/locale/translations/ro_RO.json

@@ -109,6 +109,7 @@
     "error.http_bad_gateway": "Acest site web nu este disponibil momentan din cauza unei erori generată de gateway. Problema nu este de la Miniflux. Vă rugăm să reîncercați mai târziu.",
     "error.http_body_read": "Nu pot citi corpul HTTP: %v.",
     "error.http_client_error": "Eroare client HTTP: %v.",
+    "error.http_cloudflare_challenge": "Acest site web este protejat de o provocare bot Cloudflare (CAPTCHA sau verificare JavaScript). Miniflux nu poate rezolva această provocare în mod automat.",
     "error.http_empty_response": "Răspunsul HTTP este gol. Poate acest site web utilizează un mecanism împotriva boților?",
     "error.http_empty_response_body": "Corpul răspunsului HTTP este gol.",
     "error.http_forbidden": "Accesul la acest site web este interzis. Poate acesta utilizează un mecanism împotriva boților?",

+ 1 - 0
internal/locale/translations/ru_RU.json

@@ -109,6 +109,7 @@
     "error.http_bad_gateway": "В данный момент сайт недоступен из-за ошибки шлюза. Проблема не связана с Miniflux. Пожалуйста, попробуйте позже.",
     "error.http_body_read": "Невозможно прочитать тело HTTP-сообщения: %v.",
     "error.http_client_error": "Ошибка HTTP-клиента: %v.",
+    "error.http_cloudflare_challenge": "Этот сайт защищён проверкой Cloudflare на ботов (CAPTCHA или проверка JavaScript). Miniflux не может пройти эту проверку автоматически.",
     "error.http_empty_response": "Пустой ответ HTTP. Возможно этот сайт использует защиту от ботов?",
     "error.http_empty_response_body": "Пустое тело HTTP-ответа.",
     "error.http_forbidden": "Доступ к сайту запрещён. Возможно этот сайт использует защиту от ботов?",

+ 1 - 0
internal/locale/translations/tr_TR.json

@@ -106,6 +106,7 @@
     "error.http_bad_gateway": "Kötü ağ geçidi hatası nedeniyle bu website şu anda kullanılamıyor. Sorun Miniflux tarafında değil. Lütfen daha sonra tekrar deneyiniz.",
     "error.http_body_read": "HTTP gövdesi okunamıyor: %v.",
     "error.http_client_error": "HTTP istemci hatası: %v.",
+    "error.http_cloudflare_challenge": "Bu web sitesi bir Cloudflare bot doğrulaması (CAPTCHA veya JavaScript doğrulaması) ile korunmaktadır. Miniflux bu doğrulamayı otomatik olarak çözemez.",
     "error.http_empty_response": "HTTP yanıtı boş. Belki bu web sitesi bir bot koruma mekanizması kullanıyordur?",
     "error.http_empty_response_body": "HTTP yanıt gövdesi boş.",
     "error.http_forbidden": "Bu siteye erişim yasak. Belki bu web sitesinin bir bot koruma mekanizması vardır?",

+ 1 - 0
internal/locale/translations/uk_UA.json

@@ -109,6 +109,7 @@
     "error.http_bad_gateway": "Сайт наразі недоступний через помилку шлюзу. Проблема не на стороні Miniflux. Будь ласка, спробуйте пізніше.",
     "error.http_body_read": "Не вдалося прочитати HTTP-вміст: %v.",
     "error.http_client_error": "Помилка HTTP-клієнта: %v.",
+    "error.http_cloudflare_challenge": "Цей сайт захищено перевіркою Cloudflare на ботів (CAPTCHA або перевірка JavaScript). Miniflux не може пройти цю перевірку автоматично.",
     "error.http_empty_response": "Відповідь HTTP порожня. Можливо, цей сайт використовує захист від ботів?",
     "error.http_empty_response_body": "Тіло відповіді HTTP порожнє.",
     "error.http_forbidden": "Доступ до цього сайту заборонено. Можливо, сайт має захист від ботів?",

+ 1 - 0
internal/locale/translations/zh_CN.json

@@ -103,6 +103,7 @@
     "error.http_bad_gateway": "由于网关错误,网站暂不可用。这不是 Miniflux 的问题,请稍后重试。",
     "error.http_body_read": "无法读取 HTTP 正文:%v。",
     "error.http_client_error": "HTTP 客户端错误:%v。",
+    "error.http_cloudflare_challenge": "此网站受 Cloudflare 机器人验证(CAPTCHA 或 JavaScript 验证)保护。Miniflux 无法自动通过此验证。",
     "error.http_empty_response": "HTTP 响应为空,该网站可能使用了反爬虫机制。",
     "error.http_empty_response_body": "HTTP 响应正文为空。",
     "error.http_forbidden": "禁止访问该网站。可能该网站使用了反爬虫机制?",

+ 1 - 0
internal/locale/translations/zh_TW.json

@@ -103,6 +103,7 @@
     "error.http_bad_gateway": "此網站目前因閘道錯誤無法使用,問題不在 Miniflux,請稍後重試。",
     "error.http_body_read": "無法讀取 HTTP 本體內容:%v。",
     "error.http_client_error": "HTTP 客戶端錯誤:%v。",
+    "error.http_cloudflare_challenge": "此網站受 Cloudflare 機器人驗證(CAPTCHA 或 JavaScript 驗證)保護。Miniflux 無法自動通過此驗證。",
     "error.http_empty_response": "HTTP 回應內容為空,可能該網站有防護機制。",
     "error.http_empty_response_body": "HTTP 回應本體為空。",
     "error.http_forbidden": "拒絕存取此網站,可能該網站有防護機制。",

+ 18 - 0
internal/reader/fetcher/response_handler.go

@@ -189,6 +189,10 @@ func (r *ResponseHandler) LocalizedError() *locale.LocalizedErrorWrapper {
 		}
 	}
 
+	if r.isCloudflareChallenge() {
+		return locale.NewLocalizedErrorWrapper(fmt.Errorf("fetcher: blocked by Cloudflare challenge (%d status code)", r.httpResponse.StatusCode), "error.http_cloudflare_challenge")
+	}
+
 	switch r.httpResponse.StatusCode {
 	case http.StatusUnauthorized:
 		return locale.NewLocalizedErrorWrapper(errors.New("fetcher: access unauthorized (401 status code)"), "error.http_not_authorized")
@@ -224,6 +228,20 @@ func (r *ResponseHandler) LocalizedError() *locale.LocalizedErrorWrapper {
 	return nil
 }
 
+// isCloudflareChallenge reports whether the response looks like a Cloudflare
+// bot/captcha interstitial rather than a genuine error from the origin. It
+// relies on response headers only (no body read) to keep the check cheap and
+// to run before ReadBody is called.
+func (r *ResponseHandler) isCloudflareChallenge() bool {
+	if r.httpResponse == nil {
+		return false
+	}
+
+	return r.httpResponse.StatusCode == http.StatusForbidden &&
+		strings.EqualFold(r.httpResponse.Header.Get("cf-mitigated"), "challenge") &&
+		strings.HasPrefix(strings.ToLower(r.ContentType()), "text/html")
+}
+
 func isNetworkError(err error) bool {
 	if _, ok := err.(*url.Error); ok {
 		return true

+ 89 - 0
internal/reader/fetcher/response_handler_test.go

@@ -190,6 +190,95 @@ func TestCacheControlMaxAgeInMinutes(t *testing.T) {
 	}
 }
 
+func TestIsCloudflareChallenge(t *testing.T) {
+	makeResp := func(status int, headers map[string]string) *http.Response {
+		h := http.Header{}
+		for k, v := range headers {
+			h.Set(k, v)
+		}
+		return &http.Response{StatusCode: status, Header: h}
+	}
+
+	cases := map[string]struct {
+		response *http.Response
+		expected bool
+	}{
+		"403 with cf-mitigated challenge and html": {
+			response: makeResp(http.StatusForbidden, map[string]string{
+				"Cf-Mitigated": "challenge",
+				"Content-Type": "text/html; charset=UTF-8",
+			}),
+			expected: true,
+		},
+		"cf-mitigated challenge header on 200": {
+			response: makeResp(http.StatusOK, map[string]string{
+				"Cf-Mitigated": "challenge",
+				"Content-Type": "text/html",
+			}),
+			expected: false,
+		},
+		"403 cf-mitigated challenge without html": {
+			response: makeResp(http.StatusForbidden, map[string]string{
+				"Cf-Mitigated": "challenge",
+				"Content-Type": "application/json",
+			}),
+			expected: false,
+		},
+		"403 from cloudflare with html but no challenge signal": {
+			response: makeResp(http.StatusForbidden, map[string]string{
+				"Server":       "cloudflare",
+				"Cf-Ray":       "8abc123def456-IAD",
+				"Content-Type": "text/html; charset=UTF-8",
+			}),
+			expected: false,
+		},
+		"503 from cloudflare with html but no challenge signal": {
+			response: makeResp(http.StatusServiceUnavailable, map[string]string{
+				"Server":       "cloudflare",
+				"Cf-Ray":       "8abc123def456-IAD",
+				"Content-Type": "text/html",
+			}),
+			expected: false,
+		},
+		"403 from non-cloudflare server": {
+			response: makeResp(http.StatusForbidden, map[string]string{
+				"Server":       "nginx",
+				"Content-Type": "text/html",
+			}),
+			expected: false,
+		},
+		"500 from cloudflare with html": {
+			response: makeResp(http.StatusInternalServerError, map[string]string{
+				"Server":       "cloudflare",
+				"Cf-Ray":       "8abc123def456-IAD",
+				"Content-Type": "text/html",
+			}),
+			expected: false,
+		},
+		"200 OK from cloudflare": {
+			response: makeResp(http.StatusOK, map[string]string{
+				"Server":       "cloudflare",
+				"Cf-Ray":       "8abc123def456-IAD",
+				"Content-Type": "application/rss+xml",
+			}),
+			expected: false,
+		},
+		"nil response": {
+			response: nil,
+			expected: false,
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			rh := &ResponseHandler{httpResponse: tc.response}
+			if got := rh.isCloudflareChallenge(); got != tc.expected {
+				t.Errorf("isCloudflareChallenge() = %v, want %v", got, tc.expected)
+			}
+		})
+	}
+}
+
 func TestResponseHandlerCloseClosesBodyOnClientError(t *testing.T) {
 	body := &testReadCloser{}
 	rh := ResponseHandler{