feat(theseus/error): CF Bot Fight 503 hint with .bch mirror suggestion
When a top-level HTTPS load returns 503, sniff the body for CF Bot Fight markers (cloudflare + blocked/challenge/attention-required, or cf-chl). If it matches, replace the CF interstitial with a branded error page carrying kind=cf-blocked. The page explains why Electron browsers get 503 (TLS ClientHello fingerprint below HTTP, no user- agent tweak fixes it) and offers next steps. If a .bch name has a `p` record proxying the same origin, the page surfaces it as a one-click retry — preserving the original path, so /faq becomes /faq on the mirror rather than the mirror's root. Mirror lookup walks the warm sharedIndex, so it's synchronous and works offline. If no mirror exists, links to silentmode.st/mirrors where users can mint one via Sirius. Guarded against races — the 250 ms delay before body sniff re-checks that the tab is still on the same URL and not destroyed, both before and after the executeJavaScript resolves, so a JS-redirect after the 503 doesn't cause misclassification.
This commit is contained in:
parent
2dfa9e9c3e
commit
faea4147d2
2 changed files with 119 additions and 0 deletions
44
error.html
44
error.html
|
|
@ -182,6 +182,50 @@
|
||||||
},
|
},
|
||||||
actions: ["retry", "home"],
|
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": {
|
"generic": {
|
||||||
icon: "✕", iconCls: "",
|
icon: "✕", iconCls: "",
|
||||||
title: "Couldn't load this page",
|
title: "Couldn't load this page",
|
||||||
|
|
|
||||||
75
main.js
75
main.js
|
|
@ -3715,6 +3715,37 @@ function loadHome(id) {
|
||||||
if (id === activeId) pushNav(t.prov);
|
if (id === activeId) pushNav(t.prov);
|
||||||
emitTabs();
|
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
|
// Errors we deliberately ignore (Chromium's own reasons that shouldn't show
|
||||||
// a user-facing error page):
|
// a user-facing error page):
|
||||||
// -3 ERR_ABORTED — navigation superseded by another / user pressed Stop
|
// -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);
|
if (tab.id === activeId) pushNav(tab.prov);
|
||||||
});
|
});
|
||||||
wc.on("did-navigate", () => { if (tab.id === activeId) { notifyTabChange(); emitPwAvailability(); } });
|
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(); });
|
wc.on("did-navigate-in-page", () => { if (tab.id === activeId) notifyTabChange(); });
|
||||||
// Translator state is per-document: a new navigation drops any "translated"
|
// Translator state is per-document: a new navigation drops any "translated"
|
||||||
// flag, the autoTried latch, and the cached page language. The chip then
|
// flag, the autoTried latch, and the cached page language. The chip then
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue