diff --git a/error.html b/error.html index b46c056c..219fe8a2 100644 --- a/error.html +++ b/error.html @@ -182,6 +182,50 @@ }, actions: ["retry", "home"], }, + "cf-blocked": { + icon: "⛔", iconCls: "warn", + title: "This site blocks Electron browsers", + sub: host ? (host + " returned 503 — Cloudflare Bot Fight Mode") : "Cloudflare Bot Fight Mode", + body: (b) => { + b.append("The server at "); + b.append(hostSpan(host)); + b.append(" is behind Cloudflare's Bot Fight Mode. Cloudflare fingerprints the browser's TLS handshake below HTTP and refuses Electron-based browsers like Theseus, no matter what user-agent is sent."); + b.append(document.createElement("br")); + b.append(document.createElement("br")); + const mirror = q.get("mirror"); + if (mirror) { + b.append("A "); + const s = el("b", null, ".bch mirror"); + b.append(s); + b.append(" is registered on chain for this site: "); + const a = el("a", "host", mirror); + a.href = "#"; + a.style.cursor = "pointer"; + a.addEventListener("click", (ev) => { + ev.preventDefault(); + // Preserve the original path/search/hash so /faq on the upstream + // becomes /faq on the mirror, not the mirror's root. + let origPath = "/"; + try { const u = new URL(url); origPath = u.pathname + u.search + u.hash; } catch {} + window.errorpage.retry("https://" + mirror + origPath); + }); + b.append(a); + b.append(". Theseus proxies the request through Node's HTTP stack, which Cloudflare doesn't block — the page will load."); + } else { + b.append("No .bch mirror is registered for this site yet. See "); + const a = el("a", "host", "silentmode.st/mirrors"); + a.href = "#"; + a.style.cursor = "pointer"; + a.addEventListener("click", (ev) => { + ev.preventDefault(); + window.errorpage.openExternal("https://silentmode.st/mirrors/"); + }); + b.append(a); + b.append(" — it explains why this happens and how to mint one."); + } + }, + actions: ["retry", "home"], + }, "generic": { icon: "✕", iconCls: "", title: "Couldn't load this page", diff --git a/main.js b/main.js index 7719145c..33601ba6 100644 --- a/main.js +++ b/main.js @@ -3874,6 +3874,37 @@ function loadHome(id) { if (id === activeId) pushNav(t.prov); emitTabs(); } +// Scan the warm BNS index for a name whose `p` record proxies the given +// origin. Only exact-host matches — a `p` value of "https://x.example/a" only +// mirrors x.example, not sub.x.example. Returns the first match, or null. +// Cheap: sharedIndex is in-memory and typically < 10k entries. +function findMirrorFor(originHost) { + if (!sharedIndex || typeof sharedIndex.values !== "function") return null; + const wanted = String(originHost || "").toLowerCase(); + if (!wanted) return null; + for (const entry of sharedIndex.values()) { + const p = entry?.records?.p; + if (!p || typeof p !== "string") continue; + try { + if (new URL(p).hostname.toLowerCase() === wanted) return entry.name; + } catch {} + } + return null; +} +// CF Bot Fight Mode returns 503 with a short body carrying either the +// `cf-mitigated: block` header or "Just a moment" / "cloudflare" in the +// visible text. We can't see headers from `did-navigate`, so we sniff the +// page body via executeJavaScript once loading finishes. Keep the string +// short — the CF page is a few KB, real 503s from origin servers can be +// large HTML with different content. +function looksLikeCloudflareBlock(bodyText) { + if (typeof bodyText !== "string" || !bodyText) return false; + if (bodyText.length > 8_000) return false; + const s = bodyText.toLowerCase(); + return (s.includes("cloudflare") && (s.includes("blocked") || s.includes("just a moment") || s.includes("attention required"))) + || s.includes("cf-chl") + || s.includes("cf_chl"); +} // Errors we deliberately ignore (Chromium's own reasons that shouldn't show // a user-facing error page): // -3 ERR_ABORTED — navigation superseded by another / user pressed Stop @@ -4038,6 +4069,50 @@ function createTab(initial, opts = {}) { if (tab.id === activeId) pushNav(tab.prov); }); wc.on("did-navigate", () => { if (tab.id === activeId) { notifyTabChange(); emitPwAvailability(); } }); + // Cloudflare Bot Fight Mode fingerprints Electron's TLS ClientHello and + // returns 503 to Theseus regardless of user-agent. When we see 503 on a + // top-level main-frame HTTPS load, sniff the body for the CF signature + // and — if it matches — replace the tab with our branded error page, + // pre-filled with a `.bch` mirror suggestion if one exists on chain. + // See site/mirrors/ for the user-facing docs on the workaround. + wc.on("did-navigate", (_e, url, httpResponseCode) => { + if (httpResponseCode !== 503 || tab.internalNav) return; + let parsed; try { parsed = new URL(url); } catch { return; } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return; + const host = parsed.hostname; + // Small delay so the body is present. did-navigate fires early in some + // renders (before all DOM text is materialized); dom-ready is more + // reliable but harder to plumb per-navigation, so a short setTimeout on + // the executeJavaScript call is a workable compromise. + setTimeout(() => { + // The user may have navigated away in the 250 ms window (a JS redirect + // after the 503, or a manual click). Skip if the tab is now on a + // different URL — otherwise we'd sniff a different page's body and + // misidentify it as a CF block. + try { if (wc.isDestroyed() || wc.getURL() !== url) return; } catch { return; } + if (tab.internalNav) return; + wc.executeJavaScript("(document.body && document.body.innerText || '').slice(0, 4000)", true) + .then((text) => { + if (!looksLikeCloudflareBlock(text)) return; + try { if (wc.isDestroyed() || wc.getURL() !== url) return; } catch { return; } + const mirror = findMirrorFor(host); + const q = new URLSearchParams({ + kind: "cf-blocked", host, url, + code: "503", desc: "Cloudflare Bot Fight Mode", + }); + if (mirror) q.set("mirror", mirror); + tab.internalNav = true; + tab.title = "Error — " + host; + tab.prov = { host, kind: "error", code: 503, desc: "cloudflare-block" }; + wc.loadFile(path.join(__dirname, "error.html"), { search: q.toString() }) + .catch((e) => console.warn("cf-block error page load failed:", e?.message)) + .finally(() => { tab.internalNav = false; }); + if (tab.id === activeId) pushNav(tab.prov); + emitTabs(); + }) + .catch(() => {}); + }, 250); + }); wc.on("did-navigate-in-page", () => { if (tab.id === activeId) notifyTabChange(); }); // Translator state is per-document: a new navigation drops any "translated" // flag, the autoTried latch, and the cached page language. The chip then @@ -4099,13 +4174,18 @@ function createTab(initial, opts = {}) { wc.on("did-finish-load", () => { if (!tab.settings && !tab.addonId) webapps.probeTab(tab).then(() => { if (tab.id === activeId) emitTabs(); }).catch(() => {}); }); // Failed loads: NAME_NOT_RESOLVED, CONNECTION_REFUSED, cert errors, etc. // Show the branded error page instead of Chromium's default "This site - // can't be reached". Skip subframe errors, our own programmatic loads, - // and the couple of Chromium codes that fire on normal user actions - // (Stop / superseded nav / extension cancel). + // can't be reached". Skip subframe errors and the couple of Chromium + // codes that fire on normal user actions (Stop / superseded nav / + // extension cancel). Also skip failures of our own file:// loads + // (error.html / home.html) to avoid recursion — but DO handle failures + // of programmatic loadURL calls to clearnet (BNS fallbackToWeb sets + // internalNav to block will-navigate re-entry, not to swallow errors; + // without this, a cert-expired or unreachable upstream after a failed + // BNS lookup leaves the tab blank). wc.on("did-fail-load", (_e, code, desc, validatedURL, isMainFrame) => { if (!isMainFrame) return; - if (tab.internalNav) return; if (ERROR_CODE_IGNORE.has(code)) return; + if (typeof validatedURL === "string" && validatedURL.startsWith("file://")) return; loadErrorPage(tab, tab.id, { url: validatedURL || tab.url, code, desc }); }); // Firefox / Chrome-style bottom-left link preview: fires with the href