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 d45a9270..5b7e1b1c 100644
--- a/main.js
+++ b/main.js
@@ -3715,6 +3715,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
@@ -3877,6 +3908,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