<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Katia Belokon</title>
    <description>The latest articles on DEV Community by Katia Belokon (@katiabelokon).</description>
    <link>https://dev.to/katiabelokon</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4050525%2F4b438e02-7dad-47c9-8e57-e11456ca570f.png</url>
      <title>DEV Community: Katia Belokon</title>
      <link>https://dev.to/katiabelokon</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/katiabelokon"/>
    <language>en</language>
    <item>
      <title>One Space Broke the JSON-LD Headline on 62 Pages</title>
      <dc:creator>Katia Belokon</dc:creator>
      <pubDate>Mon, 09 Nov 2026 06:59:15 +0000</pubDate>
      <link>https://dev.to/katiabelokon/one-space-broke-the-json-ld-headline-on-62-pages-3c5m</link>
      <guid>https://dev.to/katiabelokon/one-space-broke-the-json-ld-headline-on-62-pages-3c5m</guid>
      <description>&lt;p&gt;One of my page headings, &lt;code&gt;Your &amp;lt;em&amp;gt;IP&amp;lt;/em&amp;gt;, plainly&lt;/code&gt;, showed up in the JSON-LD as &lt;code&gt;"Your IP , plainly"&lt;/code&gt;. The same bug was in the structured data of 62 pages. On screen, every one of them looked right.&lt;/p&gt;

&lt;p&gt;The site is MyIPScan, which I built. This post is about the bug class, how it happens, and a test that catches it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you derive a JSON-LD headline from heading HTML, inline tags like &lt;code&gt;&amp;lt;em&amp;gt;&lt;/code&gt; can leave a space before punctuation: &lt;code&gt;Your &amp;lt;em&amp;gt;IP&amp;lt;/em&amp;gt;, plainly&lt;/code&gt; → &lt;code&gt;Your IP , plainly&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Fix: delete inline tags without replacing them, and turn only block boundaries into spaces.&lt;/li&gt;
&lt;li&gt;Catch it at build time: the JSON-LD &lt;code&gt;headline&lt;/code&gt; must equal the visible heading's normalized text, and no generated text field may match &lt;code&gt;/ [,.;:!?)]/&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why one space matters
&lt;/h2&gt;

&lt;p&gt;JSON-LD is a copy of your page written for machines, and no human reads it. Google recommends JSON-LD for structured data when your setup allows it (&lt;a href="https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data" rel="noopener noreferrer"&gt;Search Central intro&lt;/a&gt;), and its guidelines say: "Your structured data must be a true representation of the page content" (&lt;a href="https://developers.google.com/search/docs/appearance/structured-data/sd-policies" rel="noopener noreferrer"&gt;General Structured Data Guidelines&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The headline was still true, just sloppy. But a template stamps the same mistake onto every page it builds, and nothing on screen tells you. The visible heading was correct on all 62 pages.&lt;/p&gt;

&lt;p&gt;Checking the page in a browser is real QA. For this bug it checked the wrong output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two explanations that don't fit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The source text.&lt;/strong&gt; If the space were in the CMS, the visible heading would show it too. It didn't. A bug in the source shows up in both copies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JSON escaping.&lt;/strong&gt; &lt;code&gt;JSON.stringify&lt;/code&gt; escapes quotes, backslashes and control characters. It does not add spaces.&lt;/p&gt;

&lt;p&gt;That leaves the step that turns heading HTML into a string. I can't show you my original generator, but the output has the fingerprint of one specific mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this class of bug happens
&lt;/h2&gt;

&lt;p&gt;The code below is an illustration of the bug class, not my generator. It reproduces the exact output, which is enough to reason about it.&lt;/p&gt;

&lt;p&gt;A common way to strip HTML tags in JavaScript replaces every tag with a space, so that &lt;code&gt;&amp;lt;p&amp;gt;one&amp;lt;/p&amp;gt;&amp;lt;p&amp;gt;two&amp;lt;/p&amp;gt;&lt;/code&gt; doesn't become &lt;code&gt;onetwo&lt;/code&gt;. Then it squeezes whitespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Illustration: a naive stripper that turns every tag into a space&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;naiveStrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;naiveStrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Your &amp;lt;em&amp;gt;IP&amp;lt;/em&amp;gt;, plainly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// "Your IP , plainly"&lt;/span&gt;

&lt;span class="nf"&gt;naiveStrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;What is my &amp;lt;em&amp;gt;IP address&amp;lt;/em&amp;gt;?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// "What is my IP address ?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step by step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;Your &amp;lt;em&amp;gt;IP&amp;lt;/em&amp;gt;, plainly&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Tags → spaces: &lt;code&gt;Your  IP , plainly&lt;/code&gt; (two spaces after "Your", one before the comma)&lt;/li&gt;
&lt;li&gt;Collapse whitespace: &lt;code&gt;Your IP , plainly&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The collapse hides half the damage. The double space after "Your" merges into one, so the result looks almost right. The space before the comma has nothing to merge with, so it stays.&lt;/p&gt;

&lt;p&gt;The rule the stripper breaks: &lt;strong&gt;inline elements do not create word boundaries; block elements do.&lt;/strong&gt; &lt;code&gt;&amp;lt;em&amp;gt;&lt;/code&gt; sits inside a run of text. &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;li&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;h2&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;br&amp;gt;&lt;/code&gt; end one.&lt;/p&gt;

&lt;h3&gt;
  
  
  textContent and innerText
&lt;/h3&gt;

&lt;p&gt;In a DOM, &lt;code&gt;textContent&lt;/code&gt; "returns the concatenation of the &lt;code&gt;textContent&lt;/code&gt; of every child node" (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent" rel="noopener noreferrer"&gt;MDN&lt;/a&gt;), so &lt;code&gt;&amp;lt;em&amp;gt;&lt;/code&gt; is harmless. It also includes &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; content, and it ignores &lt;code&gt;&amp;lt;br&amp;gt;&lt;/code&gt;: in Chromium, &lt;code&gt;Line one&amp;lt;br&amp;gt;line two&lt;/code&gt; gives &lt;code&gt;"Line oneline two"&lt;/code&gt;. &lt;code&gt;innerText&lt;/code&gt; handles &lt;code&gt;&amp;lt;br&amp;gt;&lt;/code&gt;, but for an element that isn't rendered, such as one in a detached tree, it returns the same as &lt;code&gt;textContent&lt;/code&gt; (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/innerText" rel="noopener noreferrer"&gt;MDN&lt;/a&gt;). In a static build step with no browser, that is the case you are in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, as an illustration
&lt;/h2&gt;

&lt;p&gt;For a heading, which is short and mostly inline, a small explicit function is enough. It deletes 26 inline tags and turns everything else into a space:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;INLINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/?(&lt;/span&gt;&lt;span class="sr"&gt;a|abbr|b|bdi|bdo|cite|code|data|dfn|em|i|kbd|mark|q|s|samp|small|span|strong|sub|sup|time|u|var|wbr&lt;/span&gt;&lt;span class="se"&gt;)\b[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;/gi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;headingText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;INLINE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// inline tags: no boundary&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;br&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\/?&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// line break: boundary&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;// any other tag: boundary&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;nbsp;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;headingText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Your &amp;lt;em&amp;gt;IP&amp;lt;/em&amp;gt;, plainly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// "Your IP, plainly"&lt;/span&gt;
&lt;span class="nf"&gt;headingText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;What is my &amp;lt;em&amp;gt;IP address&amp;lt;/em&amp;gt;?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// "What is my IP address?"&lt;/span&gt;
&lt;span class="nf"&gt;headingText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Line one&amp;lt;br&amp;gt;line two&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;             &lt;span class="c1"&gt;// "Line one line two"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comments are real output from Node 22. This version decodes only &lt;code&gt;&amp;amp;nbsp;&lt;/code&gt;. If your headings contain &lt;code&gt;&amp;amp;amp;&lt;/code&gt; or &lt;code&gt;&amp;amp;#8217;&lt;/code&gt;, decode them properly (the test below shows a minimal decoder). If your build already has an HTML parser, use it instead of regex. The point is the boundary rule.&lt;/p&gt;

&lt;p&gt;One design choice helps more than the function: build the visible heading and the JSON-LD &lt;code&gt;headline&lt;/code&gt; from the same string, not from two code paths. Then they can't drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Node test for the JSON-LD headline
&lt;/h2&gt;

&lt;p&gt;The function fixes one build. A test keeps the next refactor from bringing the bug back. This one reads the built HTML, so it checks what a crawler fetches, not the template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// jsonld.test.mjs  (run: node --test jsonld.test.mjs)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:assert/strict&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readdirSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;join&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;INLINE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/?(&lt;/span&gt;&lt;span class="sr"&gt;a|abbr|b|bdi|bdo|cite|code|data|dfn|em|i|kbd|mark|q|s|samp|small|span|strong|sub|sup|time|u|var|wbr&lt;/span&gt;&lt;span class="se"&gt;)\b[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;/gi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;amp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;quot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;apos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;nbsp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;#x&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+|#&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+|&lt;/span&gt;&lt;span class="se"&gt;\w&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;;/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ENT&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCodePoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;INLINE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;withFileTypes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isDirectory&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// every JSON-LD block, any attribute order&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="sr"&gt;/&amp;lt;script&lt;/span&gt;&lt;span class="se"&gt;\b[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*type=&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;application&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;ld&lt;/span&gt;&lt;span class="se"&gt;\+&lt;/span&gt;&lt;span class="sr"&gt;json&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;][^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;([\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?)&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;script&amp;gt;/gi&lt;/span&gt;
&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;

&lt;span class="c1"&gt;// walk objects, arrays and @graph; yield [key, string] for the fields we check&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FIELDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;headline&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;description&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FIELDS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
      &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SPACE_BEFORE_PUNCT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/ &lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;,.;:!?)&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DIST&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dist&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nf"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;))];&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;found&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;doesNotMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SPACE_BEFORE_PUNCT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;h1&lt;/span&gt;&lt;span class="se"&gt;\b[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;([\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?)&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;h1&amp;gt;/i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;found&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;headline&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;headline&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;headline&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran it against four small fixture pages: a correct article, the broken headline, a page with &lt;code&gt;&amp;amp;amp;&lt;/code&gt;, &lt;code&gt;&amp;amp;#39;&lt;/code&gt; and an &lt;code&gt;@graph&lt;/code&gt;, and a FAQPage with no heading and a bad question &lt;code&gt;name&lt;/code&gt;. Excerpt of the spec reporter output (&lt;code&gt;node --test --test-reporter=spec&lt;/code&gt;, Node 22):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✖ dist/bad/index.html (2.665891ms)
✔ dist/entity/index.html (1.36117ms)
✖ dist/faq/index.html (1.048332ms)
✔ dist/good/index.html (0.35228ms)
...
  AssertionError [ERR_ASSERTION]: headline: "Your IP , plainly"
...
  AssertionError [ERR_ASSERTION]: name: "Can someone see my &amp;lt;em&amp;gt;IP&amp;lt;/em&amp;gt; ?"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two assertions catch different things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Equality&lt;/strong&gt; catches any drift between the visible heading and the &lt;code&gt;headline&lt;/code&gt;, whatever the cause. The entity fixture passes because &lt;code&gt;text()&lt;/code&gt; decodes &lt;code&gt;&amp;amp;amp;&lt;/code&gt; and &lt;code&gt;&amp;amp;#39;&lt;/code&gt; before comparing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The punctuation regex&lt;/strong&gt; runs on every &lt;code&gt;headline&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;description&lt;/code&gt; in every block, including &lt;code&gt;@graph&lt;/code&gt; and nested FAQ questions. That covers pages with no heading to compare against.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The regex has false positives. I checked these in Node and all three match: &lt;code&gt;Learn .NET in a weekend&lt;/code&gt;, &lt;code&gt;Wait ... what&lt;/code&gt;, and French spacing like &lt;code&gt;Bonjour !&lt;/code&gt;. Add exceptions if your copy has them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking it by hand
&lt;/h2&gt;

&lt;p&gt;Two free tools show you the parsed JSON-LD for a URL or a pasted snippet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Google's &lt;a href="https://search.google.com/test/rich-results" rel="noopener noreferrer"&gt;Rich Results Test&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://validator.schema.org/" rel="noopener noreferrer"&gt;Schema Markup Validator&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A stray space is valid Text, so there is nothing for them to flag. They show you the string, and you have to read it. That's why the automated test matters more than the manual check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://myipscan.net/" rel="noopener noreferrer"&gt;MyIPScan&lt;/a&gt;, a network privacy toolkit, and this bug sat in its structured data on 62 pages while every page looked fine.&lt;/p&gt;

&lt;p&gt;Test the copy you don't look at. Visual QA only checks the page humans see.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>seo</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My SSL expiry checker was six weeks off on github.com</title>
      <dc:creator>Katia Belokon</dc:creator>
      <pubDate>Mon, 02 Nov 2026 09:14:14 +0000</pubDate>
      <link>https://dev.to/katiabelokon/my-ssl-expiry-checker-was-six-weeks-off-on-githubcom-5h52</link>
      <guid>https://dev.to/katiabelokon/my-ssl-expiry-checker-was-six-weeks-off-on-githubcom-5h52</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; My SSL certificate expiry check said github.com was covered until 11 Jan 2027. The ECDSA certificate github.com was actually serving expires on 29 Nov 2026, about six weeks earlier, and it also serves an RSA certificate that expires on 30 Dec 2026. My tool reads Certificate Transparency logs, and the logic picked the newest certificate in the log, one that was issued but not being served. A CT-based check should report the range of currently valid certificates and alarm on the earliest date, not the newest.&lt;/p&gt;




&lt;p&gt;My SSL checker said github.com had a certificate valid until 11 Jan 2027. The certificate github.com was handing out expires on 29 Nov 2026.&lt;/p&gt;

&lt;p&gt;Same hostname, about six weeks apart. One of those dates was wrong, and it was mine.&lt;/p&gt;

&lt;p&gt;An SSL certificate expiry check that reports more time than you have fails quietly. You find out when the certificate does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where my dates come from
&lt;/h2&gt;

&lt;p&gt;I built MyIPScan, a set of browser-based network and privacy tools. The SSL checker does not open a TLS connection to your server. Direct TLS inspection is not available in the runtime it runs in, so it reads public Certificate Transparency data instead: crt.sh first, Cert Spotter as a fallback.&lt;/p&gt;

&lt;p&gt;CT logs are a public record of TLS certificates "as they are issued or observed" (&lt;a href="https://www.rfc-editor.org/rfc/rfc6962" rel="noopener noreferrer"&gt;RFC 6962&lt;/a&gt;, formally obsoleted by &lt;a href="https://datatracker.ietf.org/doc/html/rfc9162" rel="noopener noreferrer"&gt;RFC 9162&lt;/a&gt;, which keeps the same wording). A CA may even submit a precertificate before the final certificate is issued (RFC 6962, Section 3.1). That is useful for auditing CAs. It says nothing about which certificate a server presents today.&lt;/p&gt;

&lt;p&gt;I knew that. The logic still picked the newest certificate for the hostname and called it the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two explanations that don't fit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Stale CT data.&lt;/strong&gt; CT can lag behind a fresh deployment, and the checker's own page says so. But lag would make the tool report an older certificate than the server's. Here the tool was ahead of the server: the log already held a certificate valid to 11 Jan 2027 that github.com was not serving.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One certificate, read wrong.&lt;/strong&gt; If there were a single live certificate, there would be a single date to get wrong. There were two live ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cause: two served certificates and a third in the log
&lt;/h2&gt;

&lt;p&gt;These are the three certificates in the case:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Certificate&lt;/th&gt;
&lt;th&gt;Valid to&lt;/th&gt;
&lt;th&gt;Days left (as of 22 Oct 2026)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ECDSA, served&lt;/td&gt;
&lt;td&gt;2026-11-29&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RSA, served&lt;/td&gt;
&lt;td&gt;2026-12-30&lt;/td&gt;
&lt;td&gt;69&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Newest in CT logs, not served&lt;/td&gt;
&lt;td&gt;2027-01-11&lt;/td&gt;
&lt;td&gt;81&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Serving two certificates is a normal setup. A server keeps an RSA and an ECDSA certificate and picks one per connection. nginx, for example, lets &lt;code&gt;ssl_certificate&lt;/code&gt; be specified multiple times "to load certificates of different types, for example, RSA and ECDSA" since version 1.11.0 (&lt;a href="https://nginx.org/en/docs/http/ngx_http_ssl_module.html" rel="noopener noreferrer"&gt;nginx docs&lt;/a&gt;). I am not claiming GitHub runs nginx; it is just the clearest documentation of the mechanism.&lt;/p&gt;

&lt;p&gt;In TLS 1.3 the client sends a &lt;code&gt;signature_algorithms&lt;/code&gt; extension (&lt;a href="https://www.rfc-editor.org/rfc/rfc8446#section-4.2.3" rel="noopener noreferrer"&gt;RFC 8446, Section 4.2.3&lt;/a&gt;), and the server chooses a certificate that fits it. Browsers offer both RSA and ECDSA, so which one you get is the server's preference. A client that only offers RSA signatures gets the RSA certificate.&lt;/p&gt;

&lt;p&gt;The two served certificates were issued at different times, so they expire a month apart. A renewal was already in the logs. My tool skipped both served certificates and reported the one nobody was receiving.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check this yourself
&lt;/h2&gt;

&lt;p&gt;You can ask for one key type at a time. These are the commands to run; I am not pasting output, because the dates will have moved by the time you read this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Offer only ECDSA signatures: expect the ECDSA certificate&lt;/span&gt;
openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; github.com:443 &lt;span class="nt"&gt;-servername&lt;/span&gt; github.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-tls1_3&lt;/span&gt; &lt;span class="nt"&gt;-sigalgs&lt;/span&gt; &lt;span class="s2"&gt;"ecdsa_secp256r1_sha256:ecdsa_secp384r1_sha384"&lt;/span&gt; &amp;lt;/dev/null 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-enddate&lt;/span&gt; &lt;span class="nt"&gt;-issuer&lt;/span&gt;

&lt;span class="c"&gt;# Offer only RSA-PSS signatures: expect the RSA certificate&lt;/span&gt;
openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; github.com:443 &lt;span class="nt"&gt;-servername&lt;/span&gt; github.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-tls1_3&lt;/span&gt; &lt;span class="nt"&gt;-sigalgs&lt;/span&gt; &lt;span class="s2"&gt;"rsa_pss_rsae_sha256:rsa_pss_rsae_sha384"&lt;/span&gt; &amp;lt;/dev/null 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-enddate&lt;/span&gt; &lt;span class="nt"&gt;-issuer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-sigalgs&lt;/code&gt; sets the list the client sends, and "the server selects one entry in the list based on its preferences" (&lt;a href="https://docs.openssl.org/3.0/man1/openssl-s_client/" rel="noopener noreferrer"&gt;openssl s_client docs&lt;/a&gt;). If the server holds two certificates, the two commands should print two different &lt;code&gt;notAfter&lt;/code&gt; lines for one hostname.&lt;/p&gt;

&lt;p&gt;Then compare them with the currently valid entries for the same name on &lt;a href="https://crt.sh/?q=github.com" rel="noopener noreferrer"&gt;crt.sh&lt;/a&gt;. It is common to find more valid certificates in the log than the server is using.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an SSL certificate expiry check should report
&lt;/h2&gt;

&lt;p&gt;There is no single expiry date for a hostname. There is a set of currently valid certificates, and the server serves some subset of them. Without a handshake you cannot know which subset.&lt;/p&gt;

&lt;p&gt;So a CT-based check should do three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Report a range.&lt;/strong&gt; Take every certificate for the exact hostname that is valid now and show the earliest and latest &lt;code&gt;not_after&lt;/code&gt;. For github.com on 22 Oct 2026 that is 29 Nov 2026 to 11 Jan 2027, not a confident 11 Jan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alarm on the earliest date.&lt;/strong&gt; A newer certificate in the log only proves a renewal was issued, not that it was deployed. Until something confirms deployment, the earliest date is the one that can break you. The latest date goes next to it as "renewal logged".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count each certificate once.&lt;/strong&gt; A precertificate and its final certificate are separate log entries with the same issuer and serial number.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A sketch of that logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DAY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;86400000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;expiryReport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;certs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;certs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;names&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;               &lt;span class="c1"&gt;// exact match: *.github.com is not github.com&lt;/span&gt;
    &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;not_before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;not_after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// precertificate + final certificate = one certificate&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unique&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serial&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;unique&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;not_after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;DAY&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;earliest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;latest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;earliest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                               &lt;span class="c1"&gt;// headline date&lt;/span&gt;
    &lt;span class="nx"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                 &lt;span class="c1"&gt;// shown as "renewal logged"&lt;/span&gt;
    &lt;span class="na"&gt;renewalLogged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;latest&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;earliest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;earliest&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alarm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This trades some false alarms for no silent misses. An old certificate that was replaced early but has not expired will keep the earliest date low. That is annoying, and it is the right direction to be wrong in. If you can open a TLS connection, use the handshake result to drop certificates the server no longer serves.&lt;/p&gt;

&lt;p&gt;The range has a limit too. If the server serves a currently valid, logged certificate for this exact name, its date is inside the range. A misconfigured server serving something else is outside what CT can tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;p&gt;The checker's page now says it plainly: the records describe certificates that were logged, "not necessarily the one the server is serving right now", and nothing is read from a live handshake. It also tells you to re-check a few days after a renewal. The range view described above is the approach I am working toward; it is not on the page yet.&lt;/p&gt;

&lt;p&gt;A range is less satisfying than one number. It is also not wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule to take away
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A certificate log tells you what was issued. Only a handshake tells you what is served.&lt;/strong&gt; If your SSL certificate expiry check reads CT data, report the range across all valid certificates and alarm on the earliest one. If you can open a TLS connection, do it once per key type, because one hostname can have two expiry dates at the same time.&lt;/p&gt;

&lt;p&gt;If you want to see what CT logs know about your own domain, the checker is on &lt;a href="https://myipscan.net/tools/ssl-certificate-checker/" rel="noopener noreferrer"&gt;MyIPScan&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>devops</category>
      <category>ssl</category>
    </item>
    <item>
      <title>How to detect a CDN when your scanner runs on Cloudflare (the Server header won't tell you)</title>
      <dc:creator>Katia Belokon</dc:creator>
      <pubDate>Fri, 30 Oct 2026 05:42:05 +0000</pubDate>
      <link>https://dev.to/katiabelokon/how-to-detect-a-cdn-when-your-scanner-runs-on-cloudflare-the-server-header-wont-tell-you-4mb7</link>
      <guid>https://dev.to/katiabelokon/how-to-detect-a-cdn-when-your-scanner-runs-on-cloudflare-the-server-header-wont-tell-you-4mb7</guid>
      <description>&lt;p&gt;My CDN detector said Cloudflare. Then Cloudflare. Then Cloudflare. Three sites, three identical answers, and the reason was not the sites: the detector trusted the &lt;code&gt;Server&lt;/code&gt; header, and it was reading that header from inside Cloudflare.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The scanner runs on Cloudflare. From there, every site it fetched came back with &lt;code&gt;server: cloudflare&lt;/code&gt;, so it reported Cloudflare on 3 sites out of 3.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl&lt;/code&gt; from an ordinary machine shows something else: github.com answers &lt;code&gt;Server: github.com&lt;/code&gt;, BBC answers &lt;code&gt;Server: BBC-GTM&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Server&lt;/code&gt; is not a CDN field. To detect a CDN, use DNS (NS and CNAME) plus headers the CDN documents as its own, and count those headers only when you are not fetching from inside that CDN.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Three for three
&lt;/h2&gt;

&lt;p&gt;I built MyIPScan, a site with free network and privacy checks. One of them takes a domain and reports what it can see from the outside: HTTPS, redirects, security headers, DNS, and a line called "CDN".&lt;/p&gt;

&lt;p&gt;The scanner found Cloudflare on three sites out of three.&lt;/p&gt;

&lt;p&gt;Cloudflare is big. Three out of three should still make you suspect the detector before the sites.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a wrong CDN label matters
&lt;/h2&gt;

&lt;p&gt;People use that label to answer practical questions. Who terminates my TLS? Whose IP ranges do I allow in the firewall? Who do I contact about a block page? Is my origin hidden?&lt;/p&gt;

&lt;p&gt;If the tool says "Cloudflare" for everyone, every one of those answers is wrong. A detector that never says "no" is worse than no detector.&lt;/p&gt;

&lt;h2&gt;
  
  
  The usual suspects, and why they don't fit
&lt;/h2&gt;

&lt;p&gt;The first things you'd suspect are the boring ones: the scan followed a redirect to some third-party host that really is on Cloudflare, or the code parsed the header wrong (case, merged values, a default leaking in).&lt;/p&gt;

&lt;p&gt;Neither explains a result that is identical for unrelated sites. The value was the plain string &lt;code&gt;cloudflare&lt;/code&gt;, the same every time. That pattern points at the place the request was made from, not at the sites.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cloudflare server header, seen from two places
&lt;/h2&gt;

&lt;p&gt;So ask the same question from a machine that is not on Cloudflare. Plain &lt;code&gt;curl&lt;/code&gt;, only the line that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://github.com | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'^server'&lt;/span&gt;
Server: github.com

&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://www.bbc.com | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'^server'&lt;/span&gt;
Server: BBC-GTM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same header name, different answers. From the scanner, both said &lt;code&gt;cloudflare&lt;/code&gt;. From outside, neither did.&lt;/p&gt;

&lt;p&gt;The scanner runs on Cloudflare, the same platform that serves the rest of the site. I have not found a Cloudflare document that describes what the &lt;code&gt;Server&lt;/code&gt; header looks like on responses fetched from there, so I won't guess at the mechanism. The observed part is enough: from inside Cloudflare, &lt;code&gt;server: cloudflare&lt;/code&gt; appeared on responses from sites whose &lt;code&gt;Server&lt;/code&gt; header, fetched from outside, says something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  BBC: a CDN that Server never mentions
&lt;/h2&gt;

&lt;p&gt;BBC shows the other half of the problem. A public urlscan.io scan of a &lt;a href="http://www.bbc.com" rel="noopener noreferrer"&gt;www.bbc.com&lt;/a&gt; page from 17 July 2026 shows the main response with &lt;code&gt;server: BBC-GTM&lt;/code&gt; and &lt;code&gt;via: 1.1 BBC-GTM, 1.1 Belfrage, 1.1 varnish&lt;/code&gt;, served from an IP in AS54113, which urlscan labels Fastly.&lt;/p&gt;

&lt;p&gt;So that response came through Fastly, and &lt;code&gt;Server&lt;/code&gt; did not say Fastly. That is not the header misbehaving. &lt;code&gt;Server&lt;/code&gt; names server software, and it never promised to name a CDN. It just can't be used to detect one, in either direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to detect a CDN reliably
&lt;/h2&gt;

&lt;p&gt;A CDN has to be wired into a domain somewhere public. There are two such places: DNS, and the headers the CDN documents as its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NS records.&lt;/strong&gt; Cloudflare's docs say standard nameservers "follow the pattern &lt;code&gt;&amp;lt;proper_name&amp;gt;.ns.cloudflare.com&lt;/code&gt;", and advanced nameservers are hosted on &lt;code&gt;foundationdns.com&lt;/code&gt;, &lt;code&gt;foundationdns.net&lt;/code&gt; and &lt;code&gt;foundationdns.org&lt;/code&gt;. If a zone's NS records point there, Cloudflare runs that zone's DNS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CNAME records.&lt;/strong&gt; A hostname put behind a CDN often has a CNAME to the CDN. Fastly's docs list targets such as &lt;code&gt;&amp;lt;something&amp;gt;.customer.fastly.net&lt;/code&gt;, &lt;code&gt;&amp;lt;something&amp;gt;.map.fastly.net&lt;/code&gt; and &lt;code&gt;dualstack.e.sni.global.fastly.net&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CDN-owned headers.&lt;/strong&gt; Cloudflare says it adds &lt;code&gt;Cf-Ray&lt;/code&gt; and &lt;code&gt;Cf-Cache-Status&lt;/code&gt; to the response sent to the visitor. Fastly documents &lt;code&gt;X-Served-By&lt;/code&gt;, "set by Fastly by default on all responses that we process", in the format &lt;code&gt;cache-{datacenter}{nodeid}-{datacenter}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;None of these is perfect alone. A Cloudflare zone can proxy only some hostnames. An apex served by A records has no CNAME. Headers can be stripped. And a CDN-owned header read from inside that same CDN has the same weakness as &lt;code&gt;Server&lt;/code&gt;: it may describe your own path, not the site.&lt;/p&gt;

&lt;p&gt;That gives the rule I adopted after this bug: detect a CDN by DNS (NS, CNAME) and the CDN's own headers, never by &lt;code&gt;Server&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A sketch in code
&lt;/h2&gt;

&lt;p&gt;This is an illustration, not the production code, and I have not run it against live DNS. It uses Cloudflare's DNS-over-HTTPS JSON API and returns evidence rather than a verdict:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NS_RULES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cdn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cloudflare&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;re&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;ns&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;cloudflare&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;com&lt;/span&gt;&lt;span class="se"&gt;\.?&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cdn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cloudflare&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;re&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;foundationdns&lt;/span&gt;&lt;span class="se"&gt;\.(&lt;/span&gt;&lt;span class="sr"&gt;com|net|org&lt;/span&gt;&lt;span class="se"&gt;)\.?&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CNAME_RULES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cdn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Fastly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;re&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;fastly&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;net&lt;/span&gt;&lt;span class="se"&gt;\.?&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HEADER_RULES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cdn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cloudflare&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cf-ray&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cdn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Fastly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sr"&gt;/^cache-/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-served-by&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;doh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`https://cloudflare-dns.com/dns-query?name=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;type=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/dns-json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Answer&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Naive: right for www.bbc.com -&amp;gt; bbc.com, wrong for www.example.co.uk&lt;/span&gt;
&lt;span class="c1"&gt;// (it returns co.uk). Use a Public Suffix List library in real code.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;apexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// vantage: the CDN your scanner itself runs on, e.g. 'Cloudflare', or null.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;detectCdn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vantage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;evidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;doh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;apexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;NS_RULES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;cdn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cdn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;via&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`NS &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;doh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CNAME&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;CNAME_RULES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;cdn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cdn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;via&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`CNAME &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;HEADER_RULES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cdn&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;vantage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// our own CDN's headers prove nothing here&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;cdn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cdn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;via&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CDN header&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Deliberately no rule for the Server header.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter more than the regexes.&lt;/p&gt;

&lt;p&gt;First, there is no rule for &lt;code&gt;server&lt;/code&gt;. It can go into a report as raw data, but it never produces a CDN label.&lt;/p&gt;

&lt;p&gt;Second, the &lt;code&gt;vantage&lt;/code&gt; check. If the scanner runs on Cloudflare, a &lt;code&gt;cf-ray&lt;/code&gt; on a fetched response is skipped, and a Cloudflare label has to come from DNS. NS answers do not have this problem: a zone's nameservers are the same whoever asks. (CNAME and A answers can vary by resolver location, so treat them as evidence, not proof.)&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check a detector like this
&lt;/h2&gt;

&lt;p&gt;Use the same comparison that exposed the bug: run the detector, then run &lt;code&gt;curl -sI&lt;/code&gt; and &lt;code&gt;dig NS&lt;/code&gt; / &lt;code&gt;dig CNAME&lt;/code&gt; from a machine outside the CDN your scanner lives on. Every label should be backed by something you can reproduce from there. If the only support is a header, and your scanner sits on that CDN, the label should not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;Before you trust what a response says about where it came from, ask where you are standing when you read it. A header describes the path, and your own infrastructure is part of the path. DNS describes the domain.&lt;/p&gt;

&lt;p&gt;The CDN and origin hints in the Website Exposure Scanner on &lt;a href="https://myipscan.net/tools/website-exposure-scanner/" rel="noopener noreferrer"&gt;MyIPScan&lt;/a&gt; are best-effort signals, not proof, and the page says so. (I built MyIPScan.)&lt;/p&gt;

&lt;p&gt;Has your monitoring ever reported something that turned out to be a fact about the machine it ran on? What gave it away?&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://developers.cloudflare.com/fundamentals/reference/http-headers/" rel="noopener noreferrer"&gt;Cloudflare HTTP headers&lt;/a&gt;, &lt;a href="https://developers.cloudflare.com/dns/nameservers/" rel="noopener noreferrer"&gt;Cloudflare nameservers&lt;/a&gt;, &lt;a href="https://www.fastly.com/documentation/reference/http/http-headers/X-Served-By/" rel="noopener noreferrer"&gt;Fastly X-Served-By&lt;/a&gt;, &lt;a href="https://www.fastly.com/documentation/guides/concepts/routing-traffic-to-fastly" rel="noopener noreferrer"&gt;Fastly: routing traffic&lt;/a&gt;, &lt;a href="https://urlscan.io/result/019f7066-cd73-772b-a12d-11bbf6510a97" rel="noopener noreferrer"&gt;urlscan.io scan of a www.bbc.com page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>cloudflare</category>
      <category>dns</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Two invisible bytes wiped 130 CSS colour tokens on 462 pages, and every check passed</title>
      <dc:creator>Katia Belokon</dc:creator>
      <pubDate>Tue, 27 Oct 2026 09:42:19 +0000</pubDate>
      <link>https://dev.to/katiabelokon/two-invisible-bytes-wiped-130-css-colour-tokens-on-462-pages-and-every-check-passed-20f4</link>
      <guid>https://dev.to/katiabelokon/two-invisible-bytes-wiped-130-css-colour-tokens-on-462-pages-and-every-check-passed-20f4</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;My stylesheet started with two UTF-8 byte order marks instead of one. The decoding algorithm browsers use strips only the first, so the second became part of the first selector, and the &lt;code&gt;:root&lt;/code&gt; rule holding 130 colour tokens stopped matching on 462 pages.&lt;/li&gt;
&lt;li&gt;All checks and the deploy passed. Only a live measurement of the rendered page caught it: background &lt;code&gt;#ffffff&lt;/code&gt; where it should have been &lt;code&gt;#f5f0e8&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Check for a BOM after byte 0 in CI, and assert computed styles on the live page, not just files.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The background that came back white
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://myipscan.net/" rel="noopener noreferrer"&gt;MyIPScan&lt;/a&gt;, a set of free network-privacy checks served as a static site. The first rule in its stylesheet is a &lt;code&gt;:root&lt;/code&gt; block with 130 colour tokens as CSS custom properties.&lt;/p&gt;

&lt;p&gt;After a deploy, a live measurement of the page background came back &lt;code&gt;#ffffff&lt;/code&gt;. It should have been &lt;code&gt;#f5f0e8&lt;/code&gt;, a warm off-white.&lt;/p&gt;

&lt;p&gt;Nothing else flagged it. Every check passed, the deploy went through, and the 130 tokens were undefined on all 462 pages that load that stylesheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing went red
&lt;/h2&gt;

&lt;p&gt;If your design system lives in custom properties, your whole visual identity hangs off one selector. When that selector stops matching, nothing throws. No parse error, no failed step, no console warning. The pages still load, and they just look wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two suspects that don't fit
&lt;/h2&gt;

&lt;p&gt;A stale cached stylesheet is the obvious first suspect. It explains a page that looks old, not one that has lost every token at once.&lt;/p&gt;

&lt;p&gt;A cascade problem, some later rule overriding the tokens, is the second. One line in the console separates the two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;getComputedStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getPropertyValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--bg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An overridden property still has a value. An empty string means the property was never declared for that element, so the cascade is not the problem. If the declarations are in the file and still don't apply, look at the selector.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cause, in six bytes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 6 styles.css | &lt;span class="nb"&gt;od&lt;/span&gt; &lt;span class="nt"&gt;-An&lt;/span&gt; &lt;span class="nt"&gt;-tx1&lt;/span&gt;
 ef bb bf ef bb bf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;EF BB BF&lt;/code&gt; is the UTF-8 encoding of U+FEFF, the byte order mark. This file has two.&lt;/p&gt;

&lt;p&gt;One is harmless. &lt;a href="https://www.w3.org/TR/css-syntax-3/#input-byte-stream" rel="noopener noreferrer"&gt;CSS Syntax Level 3&lt;/a&gt; decodes a stylesheet with the &lt;a href="https://encoding.spec.whatwg.org/#decode" rel="noopener noreferrer"&gt;Encoding Standard's decode algorithm&lt;/a&gt;, which sniffs for a BOM, reads three bytes and discards them. It does that once. There is no loop.&lt;/p&gt;

&lt;p&gt;The second &lt;code&gt;EF BB BF&lt;/code&gt; goes through the UTF-8 decoder as an ordinary character, U+FEFF, and reaches the tokenizer. CSS Syntax lists U+FDF0 to U+FFFD among its &lt;a href="https://www.w3.org/TR/css-syntax-3/#non-ascii-ident-code-point" rel="noopener noreferrer"&gt;non-ASCII ident code points&lt;/a&gt;, so U+FEFF can start an identifier. The first rule stops being this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#f5f0e8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* ...129 more */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and becomes this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nt"&gt;FEFF&lt;/span&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#f5f0e8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* ...129 more */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a type selector for an element whose name is U+FEFF, combined with &lt;code&gt;:root&lt;/code&gt;. In an HTML page the root is &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;, so the rule matches nothing. It still parses and still shows up in the CSSOM.&lt;/p&gt;

&lt;p&gt;You might expect the browser to drop the rule as invalid. In a minimal repro I built for this article (headless Chromium, stylesheet served over local HTTP), it was still there, with the extra character in front:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;styleSheets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;cssRules&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;selectorText&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ":root" with an invisible U+FEFF in front of it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From there, &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/var" rel="noopener noreferrer"&gt;&lt;code&gt;var()&lt;/code&gt;&lt;/a&gt; does what MDN describes for an undeclared property: use the fallback if there is one, otherwise resolve to an invalid value, and the property is treated as &lt;code&gt;unset&lt;/code&gt;. In the repro, &lt;code&gt;background: var(--bg, #fff)&lt;/code&gt; gave &lt;code&gt;rgb(255, 255, 255)&lt;/code&gt;, and &lt;code&gt;background: var(--bg)&lt;/code&gt; gave &lt;code&gt;rgba(0, 0, 0, 0)&lt;/code&gt;, transparent over a white canvas. Either way, it looks white. With a single BOM, the same file gave &lt;code&gt;rgb(245, 240, 232)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a file gets two
&lt;/h2&gt;

&lt;p&gt;The usual ways a file ends up with a second BOM:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;concatenating files that each start with a BOM, when the first piece is empty apart from its BOM;&lt;/li&gt;
&lt;li&gt;an editor or tool prepending a BOM to a file that already has one;&lt;/li&gt;
&lt;li&gt;a build step that writes its own BOM in front of content that already carries one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't count on your linter. In the repro, stylelint 17.16.0 with &lt;code&gt;selector-type-no-unknown&lt;/code&gt; exited 0 on the double-BOM file, while the same rule rejected &lt;code&gt;foo:root&lt;/code&gt; as an unknown type selector.&lt;/p&gt;

&lt;p&gt;Your search tool can hide it too. ripgrep sniffs for a BOM by default and strips a leading one before searching (checked with ripgrep 14.1.0), so a search for the bytes misses a single BOM at the start of a file. &lt;code&gt;rg -E none&lt;/code&gt; searches raw bytes. GNU grep works if you force the C locale; under a UTF-8 locale the same pattern found nothing in my test with grep 3.11:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;LC_ALL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;C &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rlP&lt;/span&gt; &lt;span class="s1"&gt;'\xEF\xBB\xBF\xEF\xBB\xBF'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.css'&lt;/span&gt; dist/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The fix and how to confirm it
&lt;/h2&gt;

&lt;p&gt;The fix is deleting the second three bytes. The confirmation is the same live measurement, read in the format the browser gives you. &lt;code&gt;getComputedStyle(document.body).backgroundColor&lt;/code&gt; returns &lt;code&gt;rgb()&lt;/code&gt;, not hex, so the expected value is &lt;code&gt;rgb(245, 240, 232)&lt;/code&gt;: 0xf5 = 245, 0xf0 = 240, 0xe8 = 232. The broken value, &lt;code&gt;#ffffff&lt;/code&gt;, is &lt;code&gt;rgb(255, 255, 255)&lt;/code&gt;. Compare a hex string with an &lt;code&gt;rgb()&lt;/code&gt; string and your check fails for the wrong reason.&lt;/p&gt;

&lt;p&gt;Two checks are worth adding. These are illustrations, not production code.&lt;/p&gt;

&lt;p&gt;A byte check for CI, which allows a BOM at offset 0 and fails on any after it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// check-bom.mjs&lt;/span&gt;
&lt;span class="c1"&gt;// run: find dist -name '*.css' -exec node check-bom.mjs {} +&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BOM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mh"&gt;0xef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0xbb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0xbf&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BOM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// offset 0 is allowed&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: stray BOM at byte &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;bad&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BOM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bad&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;find&lt;/code&gt; rather than &lt;code&gt;dist/**/*.css&lt;/code&gt;: without &lt;code&gt;globstar&lt;/code&gt;, bash expands &lt;code&gt;**&lt;/code&gt; like &lt;code&gt;*&lt;/code&gt; and skips files directly in &lt;code&gt;dist/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And a rendered check, which catches this bug and every other way a token can vanish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// after deploy, in a headless browser (Playwright shown)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LIVE_URL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;getComputedStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;backgroundColor&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bg&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rgb(245, 240, 232)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`background is &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The byte check knows one bug. The rendered check doesn't care why the colour is wrong, which is the point. Pick one token you'd notice, assert its exact computed value, and run it against the live URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I'm keeping
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A green pipeline checks the files you shipped. Only a rendered page tells you what the browser did with them.&lt;/strong&gt; A linter reads your CSS as text. The browser reads it as bytes, through an algorithm that removes exactly one BOM and treats a second as part of your selector.&lt;/p&gt;

&lt;p&gt;Has a stylesheet ever passed every check you had and still rendered wrong for you? I'd like to know what finally caught it.&lt;/p&gt;

</description>
      <category>css</category>
      <category>webdev</category>
      <category>debugging</category>
      <category>devops</category>
    </item>
    <item>
      <title>VPN Kill Switch Test From a Browser Tab: What Polling Can and Can't Prove</title>
      <dc:creator>Katia Belokon</dc:creator>
      <pubDate>Mon, 26 Oct 2026 08:16:27 +0000</pubDate>
      <link>https://dev.to/katiabelokon/vpn-kill-switch-test-from-a-browser-tab-what-polling-can-and-cant-prove-472j</link>
      <guid>https://dev.to/katiabelokon/vpn-kill-switch-test-from-a-browser-tab-what-polling-can-and-cant-prove-472j</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A browser can't see the firewall rules behind a VPN kill switch. It only sees answers: which address a server saw, or no reply at all.&lt;/li&gt;
&lt;li&gt;Sampling leaves gaps, and a leak shorter than the gap between two samples can vanish completely.&lt;/li&gt;
&lt;li&gt;No reply is not a pass. It only proves nothing came back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You click Quit in the VPN app, and the tab that was supposed to record the leak is now behind it. That is the basic problem with any VPN kill switch test run from a web page: to test the kill switch you have to break the tunnel on purpose, and breaking it takes you away from the tab doing the measuring.&lt;/p&gt;

&lt;p&gt;That detail shaped the &lt;a href="https://myipscan.net/tools/vpn-kill-switch-test/" rel="noopener noreferrer"&gt;VPN kill switch test on MyIPScan&lt;/a&gt;. I built MyIPScan, and this post covers how a browser-based recorder works and what it cannot tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a kill switch actually is (and why a browser can't see it)
&lt;/h2&gt;

&lt;p&gt;A kill switch lives below the browser. Mullvad's open-source app is a good reference because its &lt;a href="https://github.com/mullvad/mullvadvpn-app/blob/master/docs/security.md" rel="noopener noreferrer"&gt;security document&lt;/a&gt; spells it out. The app integrates with the system firewall: "This means WFP on Windows, PF on macOS and nftables on Linux." When it cannot build a tunnel, it enters an error state that "locks the firewall so no traffic can flow (except the always active exceptions)."&lt;/p&gt;

&lt;p&gt;The same document separates two things people often merge. The app's "kill switch" is "the fact that the connecting, disconnecting and error states prevent leaks via firewall rules." The "Lockdown mode" setting, which the document says is "regularly misunderstood as the kill switch," only decides whether the &lt;em&gt;disconnected&lt;/em&gt; state blocks traffic too.&lt;/p&gt;

&lt;p&gt;None of this is visible from JavaScript. A page can only send requests and record what happened to them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the VPN kill switch test records a drop
&lt;/h2&gt;

&lt;p&gt;The test runs in three steps, and the first two exist to give the recorder something to compare against:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Capture your own network.&lt;/strong&gt; Disconnect the VPN completely and record the address your ISP gives you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capture the tunnel.&lt;/strong&gt; Connect again and record the VPN's exit address.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Force the drop.&lt;/strong&gt; Start recording, then break the tunnel the way it breaks in real life: quit the VPN app or disable the network adapter. Run it on a desktop: a phone's background tab will likely stop sampling.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each reading is one request to the site's own &lt;code&gt;/api/ip-sample&lt;/code&gt; endpoint, which, in the page's words, "sees the address the request arrives from and sends it back with its AS number, provider name and country." The loop asks for a sample every 1.5 seconds, and every 0.6 seconds for twelve samples after anything changes, so transitions get finer resolution than the quiet stretches.&lt;/p&gt;

&lt;p&gt;Every sample lands in one of four buckets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Counted as exposure?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No reply&lt;/td&gt;
&lt;td&gt;Nothing came back&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tunnel address&lt;/td&gt;
&lt;td&gt;Matches step 2&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your own address&lt;/td&gt;
&lt;td&gt;Matches step 1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Some other address&lt;/td&gt;
&lt;td&gt;Matches neither&lt;/td&gt;
&lt;td&gt;No, timed separately&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only "your own address" counts as a leak. A different VPN server after a reconnect is not your ISP, so it gets its own timer.&lt;/p&gt;

&lt;p&gt;Below is my illustration of how such a loop can be written, not the site's code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;samples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;takeSample&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/ip-sample&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;// never answer from cache&lt;/span&gt;
      &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// a hung request is a result too&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// "no reply"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A recorder should request with &lt;code&gt;cache: 'no-store'&lt;/code&gt;, which makes the browser fetch "from the remote server without first looking in the cache" (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/RequestInit" rel="noopener noreferrer"&gt;MDN, RequestInit&lt;/a&gt;); a cached answer could show the tunnel address after the tunnel is gone. It should also note &lt;code&gt;document.hidden&lt;/code&gt; per sample, to explain later why the gaps grew.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the gap you ask for is not the gap you get
&lt;/h2&gt;

&lt;p&gt;The loop asks for 1.5 seconds, or 0.6 seconds in fast mode. The browser decides what you actually get.&lt;/p&gt;

&lt;p&gt;According to &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout" rel="noopener noreferrer"&gt;MDN's setTimeout page&lt;/a&gt;, "Firefox Desktop has a minimum timeout of 1 second for inactive tabs," and Firefox for Android has a minimum of 15 minutes for inactive tabs "and may unload them entirely." Chrome throttles hidden-tab timers too, and the MyIPScan page sums up the desktop case as browsers clamping "background timers to about one per second."&lt;/p&gt;

&lt;p&gt;Chrome also has &lt;em&gt;intensive throttling&lt;/em&gt;, introduced in Chrome 88, where timers are checked once per minute. MDN lists its conditions: nesting count of 5 or higher, the page invisible for more than 5 minutes, silent for more than 30 seconds, and WebRTC inactive. It only applies after 5 minutes hidden, so it is not relevant to a seconds-long test.&lt;/p&gt;

&lt;p&gt;Put the one-second clamp next to step 3. Forcing a drop means switching to the VPN app, so the measuring tab is hidden at the moment you care about. The 0.6-second fast mode gets clamped to about 1 second while the tab is hidden, and slow requests stretch the spacing further. Each request's round-trip time also adds to the timing uncertainty.&lt;/p&gt;

&lt;p&gt;So the page says the recorder reports "the gap it achieved rather than the gap it wanted, and says how long the tab was hidden." If the achieved gap was, say, 2.0 seconds, then any statement about leaks shorter than that is off the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning samples into a window with two bounds
&lt;/h2&gt;

&lt;p&gt;For a run of consecutive "your own address" readings, there are two defensible numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lower bound:&lt;/strong&gt; the page defines it as "the span actually observed, from the first matching sample to the last." The leak lasted at least this long.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upper bound:&lt;/strong&gt; the widest window the neighbouring samples still allow. The leak could have started just after the previous sample and ended just before the next one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Again, my illustration, not the site's code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;exposureBounds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c1"&gt;// i..j = matching run&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The page's own example output is "at least 3.6s, at most 4.8s". With 0.6-second fast-mode sampling on both sides of the run, 3.6 + 2 × 0.6 = 4.8, which is consistent with the page's example. A single number like "4.2 seconds" would look more precise and be less true.&lt;/p&gt;

&lt;h2&gt;
  
  
  What polling can't prove
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Silence is not success.&lt;/strong&gt; A "no reply" sample fits a working kill switch, but also Wi-Fi reassociating, DNS failing, or a timeout. The page puts it as: "Samples that get no reply show that traffic was not moving, not who stopped it."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short leaks fall between samples.&lt;/strong&gt; If the achieved gap is 1.5 seconds, a 400 ms window of unprotected traffic can sit entirely between two requests and never appear. The page says it directly: "A window shorter than that gap can be missed entirely." Zero matches means "none observed at this resolution", not "none occurred".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It sees HTTP from one tab, not the whole machine.&lt;/strong&gt; Background apps, an OS update service, a torrent client, DNS through another resolver: any of that can leak without touching the tab's requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It errs toward under-reporting.&lt;/strong&gt; If your own address changed after you captured it (the page names "a reconnected router, a mobile handover, carrier-grade NAT"), "a genuine exposure can land in the 'some other address' column instead." The error stays in the conservative direction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One run is one run.&lt;/strong&gt; The page: "It is one session, one device, one browser and one moment," and "It is not a test of any VPN application." Nobody should publish "Brand X has no kill switch" from a single recording.&lt;/p&gt;

&lt;p&gt;On privacy, every sample is a request, so the server sees your address each time it answers. The page states that "Readings are not logged or stored on the server," that the recording "stays in this tab until you close it," and that to name the provider "only the AS number is looked up (RIPEstat), never your address." It also offers a switch to show full addresses instead of masked ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves a browser-based test
&lt;/h2&gt;

&lt;p&gt;Polling from a tab is a decent smoke test: it can catch a leak that lasts seconds and time it with error bars. It cannot certify a kill switch. For that, capture packets at the interface (Wireshark or &lt;code&gt;tcpdump&lt;/code&gt; on the physical adapter while you kill the VPN), because that is where the firewall rules act.&lt;/p&gt;

&lt;p&gt;If you build anything like this, keep one rule: &lt;strong&gt;report the resolution you achieved next to every result, and never let a missing answer count as a passing one.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>vpn</category>
      <category>privacy</category>
      <category>javascript</category>
      <category>networking</category>
    </item>
    <item>
      <title>My regex found an IP address in the word "candidate"</title>
      <dc:creator>Katia Belokon</dc:creator>
      <pubDate>Thu, 22 Oct 2026 13:57:09 +0000</pubDate>
      <link>https://dev.to/katiabelokon/my-regex-found-an-ip-address-in-the-word-candidate-1e7</link>
      <guid>https://dev.to/katiabelokon/my-regex-found-an-ip-address-in-the-word-candidate-1e7</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Versions 1.0.0 and 1.0.1 of my npm package &lt;code&gt;@myipscan/leak-check&lt;/code&gt; reported strings like &lt;code&gt;e:2703186080&lt;/code&gt; as IP addresses. A hex character class matched the last letter of &lt;code&gt;candidate:&lt;/code&gt; plus the ICE foundation, and my Node test never reached the parser. 1.0.2 reads the address field (from &lt;code&gt;candidate.address&lt;/code&gt;, or by position), validates it and skips mDNS names. If you pull IPs out of ICE candidate strings with a regex, check yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two addresses that weren't
&lt;/h2&gt;

&lt;p&gt;On 17 August 2026 my WebRTC leak detector told me my browser was exposing two IP addresses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ips"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"e:2703186080"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"e:1510270939"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither is an address. By then the package had shipped twice, the second time with a passing test, and the &lt;code&gt;e&lt;/code&gt; turned out to be the last letter of the word &lt;code&gt;candidate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The package is &lt;a href="https://www.npmjs.com/package/@myipscan/leak-check" rel="noopener noreferrer"&gt;&lt;code&gt;@myipscan/leak-check&lt;/code&gt;&lt;/a&gt;. I wrote it for MyIPScan, my set of free browser privacy checks. It asks the browser which addresses it will hand to a page script, and its only network traffic is a STUN request, to Google's server by default.&lt;/p&gt;

&lt;p&gt;What bothered me most was how well the output survived a glance: a hex digit, a colon, a run of numbers. Squint and it could be the tail of an IPv6 address.&lt;/p&gt;

&lt;p&gt;A detector that throws costs me a visitor. One that returns a confident wrong value costs the visitor. They compare it with their VPN's exit address and decide either that something leaked or that leak tests are noise, and either way they act on a fact my code made up. For a privacy tool the order is fixed: the right answer, then an honest "couldn't check", then anything else. 1.0.0 and 1.0.1 delivered the third.&lt;/p&gt;

&lt;h2&gt;
  
  
  How my test went green
&lt;/h2&gt;

&lt;p&gt;My test ran the detector in Node. On 1.0.0 it failed with &lt;code&gt;ReferenceError: window is not defined&lt;/code&gt;, because the code touched &lt;code&gt;window&lt;/code&gt; inside the Promise.&lt;/p&gt;

&lt;p&gt;1.0.1 went out 91 minutes later with a guard at the top of the Promise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;supported&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ips&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test went green because that line is where it ended. I had made it pass by making it test less.&lt;/p&gt;

&lt;p&gt;The same release moved the regex into a variable, &lt;code&gt;var IP&lt;/code&gt;. I edited the line holding the bug, gave the bug a name and published it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The e in candidate
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;IP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;]{1,3}(\.[&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;]{1,3}){3}&lt;/span&gt;&lt;span class="sr"&gt;|&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-f0-9:&lt;/span&gt;&lt;span class="se"&gt;]{10,})&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// a server-reflexive candidate; 203.0.113.7 is a documentation address&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;candidate:1510270939 1 udp 1677729535 203.0.113.7 54322 typ srflx raddr 0.0.0.0 rport 0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;IP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "e:1510270939" 8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;JavaScript returns the leftmost match, and at each position it tries both alternatives before moving one character right. The IPv4 branch only wins if nothing matched earlier in the string.&lt;/p&gt;

&lt;p&gt;The second branch wants ten or more characters from &lt;code&gt;[a-f0-9:]&lt;/code&gt;, case-insensitive. Here is where it finds them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;candidate:1510270939 1 udp 1677729535 203.0.113.7 54322 typ srflx ...
ca d da e:1510270939
        ^ 8: first run of 10+ characters from [a-f0-9:]
                                      ^ 38: the address
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runs are &lt;code&gt;ca&lt;/code&gt;, &lt;code&gt;d&lt;/code&gt;, &lt;code&gt;da&lt;/code&gt;, then &lt;code&gt;e:1510270939&lt;/code&gt;, twelve characters. The match starts at 8 and the address is never considered.&lt;/p&gt;

&lt;p&gt;The IPv6 branch described which characters an address may contain, not what an address looks like. A candidate line is full of long hex-looking runs.&lt;/p&gt;

&lt;p&gt;On host and server-reflexive lines the old regex lands on the foundation or, when the foundation is short, on the ten-digit priority. Add a TURN server and relay candidates carry priorities short enough that it sometimes reaches the real address, which is how a bug like this survives a spot check. The foundation isn't even always a number: RFC 8839 allows letters in it too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the field the grammar names
&lt;/h2&gt;

&lt;p&gt;The candidate attribute is defined in &lt;a href="https://www.rfc-editor.org/rfc/rfc8839.html#section-5.1" rel="noopener noreferrer"&gt;RFC 8839, section 5.1&lt;/a&gt;: &lt;code&gt;"candidate" ":" foundation SP component-id SP transport SP priority SP connection-address SP port SP cand-type ...&lt;/code&gt;. The address is the fifth space-separated field. (My source comment still cites RFC 5245, which RFC 8839 replaced.)&lt;/p&gt;

&lt;p&gt;The core of 1.0.2, condensed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// candidate:&amp;lt;foundation&amp;gt; &amp;lt;component-id&amp;gt; &amp;lt;transport&amp;gt; &amp;lt;priority&amp;gt; &amp;lt;address&amp;gt; &amp;lt;port&amp;gt; typ &amp;lt;type&amp;gt; ...&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;addressOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// newer browsers parse it for you&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// fifth field, by position&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isIp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v4&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;v4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;halves&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;::&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                  &lt;span class="c1"&gt;// at most one "::"&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;groups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;halves&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;halves&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{1,4}&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onicecandidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;             &lt;span class="c1"&gt;// null candidate: gathering is over&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;addr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;addressOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;local$/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// mDNS name: the browser withheld the IP&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isIp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;ips&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test I should have written feeds the detector the two candidates a typical browser produces, an mDNS host candidate and a server-reflexive one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:assert&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;detectWebRTCLeak&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@myipscan/leak-check&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fakePC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createDataChannel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;close&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createOffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({});&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setLocalDescription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onicecandidate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onicecandidate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;returns the address from a server-reflexive candidate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;after&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;RTCPeerConnection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;fakePC&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;candidate:2703186080 1 udp 2122260223 9b36a1c4-5e2f-4d1a-8c3b-0f7e6d5c4b3a.local 54321 typ host&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;candidate:1510270939 1 udp 1677729535 203.0.113.7 54322 typ srflx raddr 0.0.0.0 rport 0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ips&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;detectWebRTCLeak&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deepStrictEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ips&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;203.0.113.7&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 1.0.1 it returns &lt;code&gt;["e:2703186080", "e:1510270939"]&lt;/code&gt;, exactly the output I saw in the browser. On 1.0.2 it returns &lt;code&gt;["203.0.113.7"]&lt;/code&gt; and passes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;RTCIceCandidate.address&lt;/code&gt; saves the split where it exists, but MDN lists it as Baseline only since 2024, so the fallback stays. &lt;code&gt;isIp&lt;/code&gt; checks shape only: it lets a trailing colon through and rejects the valid &lt;code&gt;::ffff:192.0.2.1&lt;/code&gt;. If you need strict IPv6 in a browser, &lt;code&gt;new URL("http://[" + s + "]")&lt;/code&gt; throws on malformed input. What the new code can no longer do is return a piece of another field.&lt;/p&gt;

&lt;p&gt;The broken versions were the latest release for about two hours, 17:56 to 20:04 UTC on 17 August. Both are still on the registry, so use &lt;code&gt;^1.0.2&lt;/code&gt;. 1.0.3 only added repository and issue links. The source is on &lt;a href="https://github.com/uazik-sys/leak-check" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three results, not two
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;supported: false&lt;/code&gt;: the check could not run (no &lt;code&gt;window&lt;/code&gt;, no &lt;code&gt;RTCPeerConnection&lt;/code&gt;, or the constructor threw). It never means "no leak".&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;supported: true&lt;/code&gt; with addresses: what the browser was willing to give a page script. A public address here is expected. It is a leak only if it differs from the address your HTTP requests come from, for example your ISP's address while a VPN is on.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;supported: true, ips: []&lt;/code&gt;: no address came back. With a reachable STUN server you would normally see at least your public address, so an empty list usually means STUN was blocked or slower than the 2-second timeout. In 1.0.2 it is also what a failed offer returns. On its own it proves nothing. (Private addresses hidden behind mDNS names ending in &lt;code&gt;.local&lt;/code&gt; are skipped in every case.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trap downstream is &lt;code&gt;if (!ips.length) showClean()&lt;/code&gt;. That turns "I couldn't look" into "nothing there".&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules I'm keeping
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Test the path that touches the browser.&lt;/strong&gt; A fake &lt;code&gt;RTCPeerConnection&lt;/code&gt; that replays recorded candidate lines is a few lines of code, and it fails on 1.0.1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parse by the grammar, then validate.&lt;/strong&gt; If the spec says field five, read field five, then check its shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never let "could not check" render as "clean".&lt;/strong&gt; Carry the third state all the way to the UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read every line before you publish it.&lt;/strong&gt; 1.0.0's &lt;code&gt;index.js&lt;/code&gt; was 19 lines, and I published it without reading all of them. &lt;code&gt;npm pack --dry-run&lt;/code&gt; shows what will ship. Reading it is still on me.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rule 3 is the one I haven't finished. 1.0.2 still folds a STUN timeout or a failed offer into &lt;code&gt;supported: true&lt;/code&gt; with an empty list, which is exactly the collapse rule 3 warns about. The fix is an explicit "incomplete" state, and it isn't in the package yet. If you've shipped a check with a third state, how did you get it to the UI without someone turning it back into a boolean?&lt;/p&gt;

&lt;p&gt;If you want to see what your own browser hands out, &lt;a href="https://myipscan.net/tools/webrtc-leak-test" rel="noopener noreferrer"&gt;MyIPScan's WebRTC leak test&lt;/a&gt; runs the same gathering in the page and sorts candidates into public, private and mDNS-masked. (I build MyIPScan. The package is MIT licensed.)&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>webrtc</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
