<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Katia Belokon]]></title><description><![CDATA[Katia Belokon]]></description><link>https://katiabelokon.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 30 Oct 2026 17:19:56 GMT</lastBuildDate><atom:link href="https://katiabelokon.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Why Cloudflare Pages Stopped Reading My _redirects at About 120 Rules]]></title><description><![CDATA[The docs allow 2,000 static and 100 dynamic redirects. My file gave out near rule 120. The limit is documented; the way rules are counted is easy to miss…]]></description><link>https://katiabelokon.hashnode.dev/why-cloudflare-pages-stopped-reading-my-redirects-at-about-120-rules</link><guid isPermaLink="true">https://katiabelokon.hashnode.dev/why-cloudflare-pages-stopped-reading-my-redirects-at-about-120-rules</guid><category><![CDATA[cloudflare]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Devops]]></category><category><![CDATA[SEO]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Katia Belokon]]></dc:creator><pubDate>Tue, 27 Oct 2026 09:45:54 GMT</pubDate><content:encoded><![CDATA[<p><strong>TL;DR:</strong> On Cloudflare Pages, my <code>_redirects</code> file worked for roughly its first 120 rules. Everything after that returned 404, and 8 of my redirects stayed broken for two months. The docs promise 2,000 static and 100 dynamic rules. Cloudflare's open-source parser adds a detail the docs only hint at: once one rule with <code>*</code> or <code>:placeholder</code> appears, every rule after it counts toward the 100 dynamic slots, and when those run out the rest of the file is skipped. I can't prove that was my exact trigger, but it fits. Below: a local check, a counting script, a curl check for after deploy, and two ways out.</p>
<h2>Eight URLs that went nowhere</h2>
<p>Pages reads a plain-text <code>_redirects</code> file from the build output, one rule per line: source, destination, optional status code. Every moved or renamed page adds a line, and the file grows quietly.</p>
<p>Eight of my lines did nothing. The old URLs returned 404 instead of a 301 to the new page, and they kept doing that for two months.</p>
<p>That's the stake for anyone who uses <code>_redirects</code> for SEO housekeeping. A 301 tells a search engine where a page went. A 404 tells it the page is gone. Two months of 404 on URLs that already had links is two months of asking Google to drop them.</p>
<h2>Wrong guess: the file is too big</h2>
<p>The rules that failed were the ones past roughly the first 120, so my first theory was size. The file was 10,508 bytes. Maybe Pages read a fixed number of bytes and dropped the tail.</p>
<p>So I shrank it to 8,313 bytes, deployed and re-checked.</p>
<p>Nothing changed. The same rules still returned 404.</p>
<p>That experiment was useful because it ruled something out. If 2,195 fewer bytes didn't move the cutoff, bytes weren't the limit. What was left was the number of rules: roughly the first 120 worked and the rest didn't.</p>
<h2>What the docs say</h2>
<p>The <a href="https://developers.cloudflare.com/pages/configuration/redirects/">Pages redirects docs</a> have a per-file limit:</p>
<blockquote>
<p>A <code>_redirects</code> file is limited to 2,000 static redirects and 100 dynamic redirects</p>
</blockquote>
<p>That's 2,100 combined, and each declaration has a 1,000-character limit. The same section says "The order of your redirects matter." and "Static redirects should appear before dynamic redirects."</p>
<p>My file had nowhere near 2,000 rules. What the page doesn't do is define "static" and "dynamic", or say what happens past a limit. Splats (<code>*</code>) and placeholders (<code>:slug</code>) are described on the same page, so it's natural to assume only those rules are dynamic. It's also easy to read "should appear before" as style advice.</p>
<h2>What the parser says</h2>
<p>Cloudflare publishes a <code>_redirects</code> parser in <code>workers-sdk</code>: <a href="https://github.com/cloudflare/workers-sdk/blob/8d98a15a05fa1f4d27654168ed16df1ff4ff1173/packages/workers-shared/utils/configuration/parseRedirects.ts"><code>parseRedirects.ts</code></a>. Three things in it matter here.</p>
<p><strong>1. Classification is positional.</strong> A rule counts as static only if its source has no <code>*</code>, no <code>:</code> followed by a letter, and no dynamic rule appeared earlier (<a href="https://github.com/cloudflare/workers-sdk/blob/8d98a15a05fa1f4d27654168ed16df1ff4ff1173/packages/workers-shared/utils/configuration/parseRedirects.ts#L82-L86">L82-L86</a>). The first dynamic rule sets <code>canCreateStaticRule = false</code> (<a href="https://github.com/cloudflare/workers-sdk/blob/8d98a15a05fa1f4d27654168ed16df1ff4ff1173/packages/workers-shared/utils/configuration/parseRedirects.ts#L97">L97</a>), and nothing sets it back. Every rule after that counts as dynamic, even <code>/old-page /new-page 301</code>.</p>
<p><strong>2. The caps are the documented ones.</strong> <a href="https://github.com/cloudflare/workers-sdk/blob/8d98a15a05fa1f4d27654168ed16df1ff4ff1173/packages/workers-shared/utils/configuration/constants.ts#L8-L13"><code>constants.ts</code></a> sets <code>MAX_DYNAMIC_REDIRECT_RULES = 100</code> and <code>MAX_STATIC_REDIRECT_RULES = 2000</code>. The placeholder test is <code>/:[A-Za-z]\w*/g</code>, the splat test is <code>/\*/g</code>.</p>
<p><strong>3. The two caps fail differently.</strong> Past 2,000 static rules, the parser skips that one line and carries on. Past 100 dynamic rules, it records "Skipping remaining N lines of file" and <code>break</code>s out of the loop (<a href="https://github.com/cloudflare/workers-sdk/blob/8d98a15a05fa1f4d27654168ed16df1ff4ff1173/packages/workers-shared/utils/configuration/parseRedirects.ts#L99-L105">L99-L105</a>). Everything below that line is gone. It's a warning, not a failed build.</p>
<p>Put together, "about 120" stops looking random. If the first dynamic rule sits around line 20, you get about 20 static rules, then 100 rules counted as dynamic, then nothing. Bytes don't enter into it, which matches the shrink experiment.</p>
<p>What I can't tell you: which of my rules was the first dynamic one, or whether any of them was. This is the explanation that fits the code and my symptoms, not one I traced rule by rule. In <code>workers-sdk</code> this parser is used by <code>wrangler pages dev</code> and Miniflare, the local tools. I couldn't confirm that Git-connected production builds run the same code, and there is at least one visible difference: the parser's line cap is 2,000 characters, the docs say 1,000.</p>
<h2>Reproduce it locally</h2>
<p>The quickest check needs no script. Point <code>wrangler pages dev</code> at your build folder and read the startup log. I built a synthetic file: a comment, a blank line, 20 plain rules, one <code>/blog/* https://blog.example.com/:splat 301</code>, then 110 plain FAQ rules. Wrangler 4.153.0 printed:</p>
<pre><code class="language-plaintext">✨ Parsed 120 valid redirect rules.
▲ [WARNING] Found 1 invalid redirect rule:
  ▶︎ Maximum number of dynamic rules supported is 100. Skipping remaining 12 lines of file.
</code></pre>
<p>It also printed one hint per plain rule below the splat, saying it "could be made more performant by bringing it above any lines with splats or placeholders." That's 120 working rules and 11 dead ones (the 12 skipped lines include the trailing empty line), from a file with only one splat.</p>
<h2>Count your own file in CI</h2>
<p>If you'd rather not boot a dev server in CI, this script copies the parser's counting rules. It's an illustration, not an official tool: it skips blank and <code>#</code> lines, takes the first field as the source, and applies the same two regexes and the same one-way flag.</p>
<pre><code class="language-js">// count-redirects.mjs: illustration, not an official tool.
// Mirrors the counting logic in workers-sdk's parseRedirects.ts.
// Usage: node count-redirects.mjs path/to/_redirects
import { readFileSync } from "node:fs";

const MAX_STATIC = 2000, MAX_DYNAMIC = 100;
const SPLAT = /\*/, PLACEHOLDER = /:[A-Za-z]\w*/;

const lines = readFileSync(process.argv[2] || "_redirects", "utf8").split("\n");
let statics = 0, dynamics = 0, firstDynamic = null, cut = null;
const lateStatics = [];

lines.forEach((raw, i) =&gt; {
  const line = raw.trim();
  if (!line || line.startsWith("#") || cut) return;
  const from = line.split(/\s+/)[0];
  const looksDynamic = SPLAT.test(from) || PLACEHOLDER.test(from);
  if (!firstDynamic &amp;&amp; !looksDynamic) return void statics++;
  if (!firstDynamic) firstDynamic = { line: i + 1, from };
  if (++dynamics &gt; MAX_DYNAMIC) return void (cut = { line: i + 1, from });
  if (!looksDynamic) lateStatics.push(i + 1);
});

console.log(`static: ${statics}  counted as dynamic: ${Math.min(dynamics, MAX_DYNAMIC)}`);
if (firstDynamic)
  console.log(`first dynamic rule: line ${firstDynamic.line} (${firstDynamic.from})`);
if (lateStatics.length)
  console.log(`WARN ${lateStatics.length} plain rules after it are counted as dynamic: lines ${lateStatics.slice(0, 10).join(", ")}...`);
if (statics &gt; MAX_STATIC) console.log(`WARN more than ${MAX_STATIC} static rules`);
if (cut) console.log(`FAIL dynamic budget runs out at line ${cut.line} (${cut.from}); the rest of the file is skipped`);
</code></pre>
<p>On the same synthetic file it agrees with Wrangler:</p>
<pre><code class="language-plaintext">static: 20  counted as dynamic: 100
first dynamic rule: line 23 (/blog/*)
WARN 99 plain rules after it are counted as dynamic: lines 24, 25, 26, 27, 28, 29, 30, 31, 32, 33...
FAIL dynamic budget runs out at line 123 (/faq/old-99); the rest of the file is skipped
</code></pre>
<p>If the first dynamic rule sits near the top, the cheap fix is the one the docs already state: move every rule without <code>*</code> or <code>:name</code> above every rule with them. Order still matters within each group, because the top-most rule for a source wins, so check for duplicates when you reshuffle.</p>
<h2>Check every redirect after deploy</h2>
<p>Counting tells you what should happen. This loop tells you what did. It requests every rule without <code>*</code> or <code>:name</code> and compares the status code and <code>Location</code> header with the file. Also an illustration: it assumes 302 when no code is given (the documented default), skips 200 rewrites, doesn't handle inline <code># comments</code>, and leaves dynamic rules for you to test by hand.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
# check-redirects.sh: illustration. Usage: ./check-redirects.sh https://example.com _redirects
# Requests every rule without * or :placeholder and compares status + Location.
site="${1%/}"; file="${2:-_redirects}"; fail=0
while read -r from to code _; do
  [[ -z "$from" || "$from" == \#* ]] &amp;&amp; continue
  [[ "$from" == *'*'* || "$from" =~ :[A-Za-z] ]] &amp;&amp; continue   # dynamic: test by hand
  code="${code:-302}"
  [[ "$code" == 200 ]] &amp;&amp; continue                             # rewrites have no Location
  read -r got loc &lt; &lt;(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$site$from")
  want="$to"; [[ "$to" == /* ]] &amp;&amp; want="$site$to"
  if [[ "$got" != "$code" || "$loc" != "$want" ]]; then
    echo "FAIL $from -&gt; got $got ${loc:-&lt;no Location&gt;}, want $code $want"; fail=1
  fi
done &lt; "$file"
exit $fail
</code></pre>
<p><code>%{redirect_url}</code> is the <code>Location</code> curl would follow, already resolved to an absolute URL, which is why relative destinations get the site prefixed. Run it against a preview deploy before you promote it, and against production after. A failure looks like this:</p>
<pre><code class="language-plaintext">FAIL /faq/old-100 -&gt; got 404 &lt;no Location&gt;, want 301 https://example.com/faq/new-100
</code></pre>
<p>The exit code is non-zero on any failure, so in CI a silent 404 becomes a red build.</p>
<h2>When reordering isn't enough</h2>
<p>If you really have more than 100 rules that need splats or placeholders, reordering only delays the cutoff. The docs point to two other places to put them.</p>
<p><strong>Bulk Redirects.</strong> These live at the account level, and the docs name them as the way past the 2,100-rule limit of <code>_redirects</code>. In case of duplicates, Bulk Redirects "run in front of your Pages project". They suit long lists of fixed old-to-new pairs. Check your plan's list limits before moving a large file.</p>
<p><strong>A Pages Function.</strong> Code has no rule budget. One catch, straight from the docs: <code>_redirects</code> rules "are not applied to requests served by Pages Functions." A root <code>functions/_middleware.js</code> runs in front of every request, including static files, so expect it to take over your redirects. Either move all of them into it or scope the Function to the paths that need it.</p>
<pre><code class="language-js">// functions/_middleware.js: illustration. Replace the map with your own rules.
const MOVED = {
  "/old-page": "/new-page",
  "/tools/old-name": "/tools/new-name",
};

export async function onRequest({ request, next }) {
  const url = new URL(request.url);
  const to = MOVED[url.pathname];
  return to ? Response.redirect(new URL(to, url).toString(), 301) : next();
}
</code></pre>
<p>Static asset requests on Pages are free and unlimited, but requests that invoke Functions count toward your Workers quota, so check it first.</p>
<h2>Rules I'm keeping</h2>
<ol>
<li><strong>A limit is defined by how things are counted, not by the number.</strong> "100 dynamic" meant something different from what I assumed until I read the parser.</li>
<li><strong>Run the experiment that can rule a theory out.</strong> Cutting 2,195 bytes and seeing no change killed the size theory.</li>
<li><strong>Plain rules first, patterns last.</strong> The docs say so in one sentence. The code shows why.</li>
<li><strong>Test redirects from the outside, after every deploy.</strong> A green deploy says the build worked, not that <code>/old-page</code> answers 301.</li>
</ol>
<p>If you've hit this with a file that should have fit, I'd like to know whether your cutoff matched the first-dynamic-rule position. A few more data points would show whether this is the whole story.</p>
<p>I built <a href="https://myipscan.net/">MyIPScan</a>, a set of IP and browser leak checks on Cloudflare Pages, and this one cost me two months of 404s.</p>
]]></content:encoded></item><item><title><![CDATA[How a browser DNS leak test works, and why "unable to check" is not "clean"]]></title><description><![CDATA[A page can't see your DNS resolver. How a browser DNS leak test finds it via bash.ws, every timeout in the code, and why "unable to check" isn't clean.]]></description><link>https://katiabelokon.hashnode.dev/how-a-browser-dns-leak-test-works-and-why-unable-to-check-is-not-clean</link><guid isPermaLink="true">https://katiabelokon.hashnode.dev/how-a-browser-dns-leak-test-works-and-why-unable-to-check-is-not-clean</guid><category><![CDATA[dns]]></category><category><![CDATA[privacy]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[networking]]></category><dc:creator><![CDATA[Katia Belokon]]></dc:creator><pubDate>Sat, 24 Oct 2026 07:09:34 GMT</pubDate><content:encoded><![CDATA[<p>The first version of my DNS leak test asked Google which resolver I was using. Google answered: Google.</p>
<p>That was a bug in the question, not in Google. I had built the check on DNS-over-HTTPS lookups sent straight from the page, and a DoH lookup from the page goes to the DoH provider, not to the resolver your system uses. So the test reported the one server it was guaranteed to reach, and it would have done the same with a VPN that leaked every query to the ISP.</p>
<p>I built MyIPScan, the site this test runs on, so everything below is my own code and my own mistakes.</p>
<p>This post covers how a browser DNS leak test can work at all, every timeout in the current code, what I measured, and why "Unable to check" never falls through to "no leak".</p>
<h2>Why the page can't just ask</h2>
<p>JavaScript in a page gets answers from DNS, never the identity of whoever asked on its behalf. There is no <code>navigator.dnsResolver</code>.</p>
<p>So any browser DNS leak test has to work backwards. You can't read the DNS resolver from the client side, so you read it from the server side: make the browser resolve a name that only you control, and log who comes asking.</p>
<h2>What my first version did instead</h2>
<p>The old check sent two DoH queries from the page, through the providers' JSON APIs.</p>
<p>The first was Cloudflare's <code>whoami.cloudflare</code>, a debugging name meant to be asked of Cloudflare's resolver directly. Through DoH, the client Cloudflare sees is my page's DoH request, so at best it describes that, not your system resolver. In practice it returned NXDOMAIN to the DoH query my page sent.</p>
<p>The second was Google's <code>o-o.myaddr.l.google.com</code>, a TXT name that Google Public DNS answers with the address of the Google resolver that handled the query, plus the EDNS client subnet if one was sent. Through Google's DoH, that means you learn about Google's own servers and a slice of the visitor's network. Never the system resolver.</p>
<p>A test that shows your public DNS provider every time looks reassuring. That is the problem.</p>
<h2>The browser DNS leak test detour</h2>
<p>The current test uses bash.ws, which runs a DNS zone whose authoritative server records every resolver that queries it. The whole probe is three steps:</p>
<pre><code class="language-text">1. GET https://bash.ws/id                     -&gt; a one-time id
2. browser looks up N.&lt;id&gt;.bash.ws, N = 1..6  -&gt; your resolver asks bash.ws's nameserver
3. GET https://bash.ws/dnsleak/test/&lt;id&gt;?json -&gt; the resolvers that asked, plus the
                                                 public IP that fetched step 1
</code></pre>
<p>To make the browser resolve a name, I fetch it:</p>
<pre><code class="language-js">// FETCH_BASE = { cache: "no-store", credentials: "omit", referrerPolicy: "no-referrer" }
// timed(promise, ms, controller) races the promise against a timer that
// aborts the controller and rejects with "timeout".
function lookupOnce(url, outer) {
  const c = new AbortController();
  if (outer) outer.addEventListener("abort", () =&gt; c.abort(), { once: true });
  // The request is expected to fail. Only the DNS lookup before it matters.
  return timed(fetch(url, Object.assign({ mode: "no-cors", signal: c.signal }, FETCH_BASE)), LOOKUP_MS, c)
    .then(() =&gt; true, () =&gt; true);
}
</code></pre>
<p>I expect every one of these requests to fail, most likely at TLS, since <code>1.&lt;id&gt;.bash.ws</code> is not a site anyone serves. How it fails doesn't matter. A browser has to resolve the name before it can open any connection, and the resolution is what bash.ws logs. Both outcomes of the promise map to <code>true</code> on purpose: success or failure of the fetch tells me nothing.</p>
<p>The id makes each name new, so no cache can answer for it. Whoever resolves it has to ask bash.ws, and bash.ws writes down who that was.</p>
<h2>Every number in the probe</h2>
<p>These are the constants in the shipped file, <code>/assets/dns-resolver-probe.js</code>:</p>
<table>
<thead>
<tr>
<th>Constant</th>
<th>Value</th>
<th>What it bounds</th>
</tr>
</thead>
<tbody><tr>
<td><code>ID_MS</code></td>
<td>4000</td>
<td>getting the one-time id</td>
</tr>
<tr>
<td><code>LOOKUPS</code></td>
<td>6</td>
<td>names resolved per run</td>
</tr>
<tr>
<td><code>LOOKUP_MS</code></td>
<td>3000</td>
<td>each lookup</td>
</tr>
<tr>
<td><code>SETTLE_MS</code></td>
<td>700</td>
<td>pause before reading results</td>
</tr>
<tr>
<td><code>RESULT_MS</code></td>
<td>4000</td>
<td>each read of the result</td>
</tr>
<tr>
<td><code>RETRY_GAP_MS</code></td>
<td>1500</td>
<td>pause before the one retry</td>
</tr>
<tr>
<td><code>MAX_RESOLVERS</code></td>
<td>8</td>
<td>resolvers shown</td>
</tr>
<tr>
<td><code>BUDGET_MS</code></td>
<td>12000</td>
<td>the whole run</td>
</tr>
</tbody></table>
<p>The six lookups run in parallel (<code>Promise.allSettled</code>). After 700 ms the page reads the result. If no DNS rows have arrived yet, it waits 1.5 seconds and asks exactly once more. Resolvers are de-duplicated by IP and capped at eight.</p>
<p>Add up the worst case and you get 4 + 3 + 0.7 + 4 + 1.5 + 4 = 17.2 seconds. That exceeds the budget on purpose: per-step timeouts stop any one request hanging, and the 12-second budget aborts whatever step is running when it expires.</p>
<h2>What I actually measured</h2>
<p>On 24 October 2026 I loaded the probe module in my own Chrome on Windows, on my home ISP connection with no VPN, and ran it three times:</p>
<table>
<thead>
<tr>
<th>Run</th>
<th>Time</th>
<th>Resolvers seen</th>
<th>Label</th>
<th>Retry</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>1390 ms</td>
<td>1</td>
<td>same network</td>
<td>no</td>
</tr>
<tr>
<td>2</td>
<td>1135 ms</td>
<td>1</td>
<td>same network</td>
<td>no</td>
</tr>
<tr>
<td>3</td>
<td>1160 ms</td>
<td>1</td>
<td>same network</td>
<td>no</td>
</tr>
</tbody></table>
<p>In the plain case a run takes a little over a second and the retry never fired. One resolver, on my ISP's network, as expected with nothing in between.</p>
<p>What this does not show: I haven't measured a VPN run or a Secure DNS run yet, and six lookups is an untested choice. My reasoning is that some setups spread queries across several upstreams; one connection with one resolver neither confirms nor refutes it.</p>
<h2>Reading what comes back</h2>
<p>The result lists resolver IPs with network (ASN) and country, next to your public IP. Each resolver gets one of five labels:</p>
<ol>
<li><strong>Same network</strong>: the resolver's ASN matches your public IP's. Typical of ISP DNS without a VPN, or a VPN running its own resolver.</li>
<li><strong>Public DNS service</strong>: the ASN is on a fixed list (Google 15169, Cloudflare 13335, Quad9 19281 and 42, Cisco OpenDNS 36692, NextDNS 34939, AdGuard DNS 212772) or the organisation name matches one.</li>
<li><strong>Different network</strong>: neither of the above, and not caught by the country check below.</li>
<li><strong>Different country and different network</strong>: reviewed as a possible leak when you expect a VPN.</li>
<li><strong>Not compared</strong>: the page didn't have what it needed to compare that resolver.</li>
</ol>
<p>Label 4 is a prompt to look, not a verdict. Three things make honest setups look odd:</p>
<ul>
<li><strong>Forwarders.</strong> If a resolver forwards to another, the authoritative server sees only the last hop. Your VPN's resolver can show up as its upstream somewhere else.</li>
<li><strong>Browser Secure DNS.</strong> If Chrome or Firefox uses a DoH provider, the browser skips the system resolver and you see the DoH provider. Mozilla began turning DoH on by default for US Firefox users in February 2020, so many people have this without choosing it.</li>
<li><strong>Proxies.</strong> An HTTP proxy using CONNECT resolves the hostname itself, so you see the proxy's resolver. A SOCKS5 proxy only does that with remote DNS enabled (in curl, <code>socks5h://</code> instead of <code>socks5://</code>); otherwise your machine resolves locally.</li>
</ul>
<p>A note on what the page can't know. On a VPN, your public IP is the VPN's, so the page has no idea who your home provider is. The page can't flag "your ISP" for you. You can: if the network column names your home provider while the VPN is on, that query went around the tunnel. That is the VPN DNS leak the test exists to show.</p>
<h2>The result I refuse to fake</h2>
<p>Plenty can stop the probe: a content blocker, a captive portal, a corporate network that only allows its own DNS, or bash.ws being down. Each leaves the result list empty.</p>
<p>An empty list is the most dangerous output a leak test has, because the obvious code calls it good news:</p>
<pre><code class="language-js">if (!resolvers.length) showClean(); // "no leaking resolvers found"
</code></pre>
<p>That line turns "I couldn't look" into "nothing there", and the person behind the blocker stops checking.</p>
<p>My probe throws instead when it saw nothing, and the error says which kind of nothing:</p>
<pre><code class="language-js">if (!resolvers.length) throw new Error(rows.some((r) =&gt; r.type === "error")
  ? "test id not found"
  : "no resolver seen");
</code></pre>
<p>The result rows from bash.ws come typed: <code>ip</code> for the public IP that fetched the id, <code>dns</code> for each resolver, <code>conclusion</code>, or <code>error</code>. An <code>error</code> row means bash.ws didn't know the id; no rows of type <code>dns</code> means it knew the id but nobody resolved the names.</p>
<p>The caller catches the error, adds an entry to an <code>attempts</code> list (source, ok, error, time) and returns <code>null</code>, and the page shows <strong>Unable to check</strong>.</p>
<p>I could have added ip-api.com as a fallback, but its free service is licensed for non-commercial use only. One provider and an honest "unknown" beats two providers and a licence problem.</p>
<h2>What the probe sends, and what it keeps</h2>
<p>A privacy tool that contacts third parties on page load would undercut its own point, so the probe module is loaded with a dynamic import only when you press the button. Before that, nothing contacts bash.ws.</p>
<p>When you run it, bash.ws sees your public IP and the resolvers that ask on your behalf; that's inherent to the method. The one-time id and the test hostnames stay inside the module. The <code>attempts</code> list lives in the returned object in page memory and disappears with the tab; nothing is written to storage, and MyIPScan doesn't store the result on its side either.</p>
<p>The page also runs a control lookup through MyIPScan's own DNS Lookup API, server side via a free public DoH resolver. It only proves the API path is up and is never counted as evidence about your resolver.</p>
<h2>Rules I'd give anyone building one</h2>
<ul>
<li><strong>Observe from the side that can see.</strong> Make the client cause an event on a server you can read.</li>
<li><strong>Make every name unique.</strong> A cached answer tells you about the cache, not the resolver.</li>
<li><strong>Bound every step, then bound the whole run.</strong></li>
<li><strong>Keep "unknown" as a third state all the way to the UI.</strong> An empty list is not a pass.</li>
<li><strong>Pick fallbacks you're allowed to use.</strong> Read the terms first.</li>
</ul>
<p>To watch it run, open the network panel and press the button on the DNS leak test at <a href="https://myipscan.net/tools/dns-leak-test">MyIPScan</a>. You'll see the id request, six failing fetches to <code>N.&lt;id&gt;.bash.ws</code>, then the result read. The methodology page at myipscan.net/methodology describes the same flow and its limits.</p>
<p>One question for people who have tested this on more networks than I have: when you resolve several unique names through a VPN's DNS, how many distinct resolvers do you usually see, and is six enough to catch them?</p>
]]></content:encoded></item></channel></rss>