<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Blog]]></title><description><![CDATA[Blog]]></description><link>https://devholster.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Blog</title><link>https://devholster.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 08 Sep 2026 11:49:33 GMT</lastBuildDate><atom:link href="https://devholster.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Your HTML is lying about your trackers. Detecting third-party services with a headless browser]]></title><description><![CDATA[I built a free privacy policy generator for Austrian websites. The boring part is assembling legal text from building blocks. The interesting part is a feature I call the website scan. You enter a URL]]></description><link>https://devholster.hashnode.dev/your-html-is-lying-about-your-trackers-detecting-third-party-services-with-a-headless-browser</link><guid isPermaLink="true">https://devholster.hashnode.dev/your-html-is-lying-about-your-trackers-detecting-third-party-services-with-a-headless-browser</guid><dc:creator><![CDATA[René]]></dc:creator><pubDate>Wed, 26 Aug 2026 08:39:06 GMT</pubDate><content:encoded><![CDATA[<p>I built a free privacy policy generator for Austrian websites. The boring part is assembling legal text from building blocks. The interesting part is a feature I call the website scan. You enter a URL, and the generator tells you which third-party services the site actually uses, so the policy describes reality instead of wishful thinking.</p>
<p>Here is what I learned building that scanner, including the two places where the obvious approach confidently returns wrong answers.</p>
<h2>Why fetching the HTML is not enough</h2>
<p>My first prototype fetched the page server-side and grepped for known script URLs. It took an hour to build and it was wrong on almost every real website.</p>
<p>Modern sites load trackers through tag managers, so the HTML contains one <code>googletagmanager.com</code> script and nothing else, while the browser ends up talking to five ad networks. Consent tools gate half of the requests behind a click. Bundlers hide vendor SDKs inside <code>chunk-4f2a.js</code>. And some services never appear in any markup because another script injects them at runtime.</p>
<p>The only reliable source of truth is the network traffic of a real browser. So the scanner became a headless Chromium instance that opens the page and records every request and every cookie.</p>
<pre><code class="language-js">const requests = []
page.on('request', r =&gt; requests.push(new URL(r.url()).hostname))
await page.goto(url)
await page.waitForLoadState('networkidle')
</code></pre>
<p>(Simplified. The production version also waits for late scripts, scrolls a bit to trigger lazy loading, and visits the contact and booking pages, because that is where booking widgets and captchas live.)</p>
<h2>The consent diff is the most valuable part</h2>
<p>Under EU rules, non-essential trackers may only load after the visitor consents. In Austria that is § 165 of the telecom act, and it is the single most violated rule I see in the wild. The site has a consent banner, looks compliant, and Google Analytics fires on the first page load anyway.</p>
<p>A network-recording browser detects this almost for free. You record in two phases.</p>
<ol>
<li><p>Load the page and record everything that happens before anyone touches the banner.</p>
</li>
<li><p>Find the accept button, click it, wait, and record again.</p>
</li>
</ol>
<p>Everything from phase two is a service that belongs in the privacy policy. Everything trackery from phase one is a service that loads without consent, which the generator flags with a warning instead of silently writing a paragraph that claims consent is obtained first.</p>
<p>Clicking the banner is the messy bit. There is no standard, so it is a pile of heuristics. Known consent-platform selectors first, then buttons and links matched by text in German and English, scored so that "accept all" beats "settings". It fails on exotic custom banners, and that is fine, the scan then simply reports what loads without any interaction.</p>
<h2>From hostnames to services</h2>
<p>Raw hostnames are useless to normal people, so the scanner maps them against a hand-maintained catalog of services. Each entry has match patterns plus the metadata the legal text needs, meaning provider, purpose, legal basis, retention and whether data leaves the EU. Cookie names act as a second signal, which catches services that route traffic through the first-party domain.</p>
<p>I resisted the temptation to auto-generate this catalog. Sixty-odd services curated by hand beat six hundred scraped ones, because every entry directly produces legal text someone will publish.</p>
<h2>Hosting detection, or why nameservers lie</h2>
<p>The policy also needs a section about the hosting provider, so the scanner tries to detect it. My first version looked up the domain's nameservers. That felt reasonable and was wrong embarrassingly often.</p>
<p>Nameservers tell you who manages DNS, which is usually the registrar, not the hoster. In Austria, tons of domains sit on the DNS of a big telco while the site itself runs on Hetzner or wherever. The signals that actually work are the reverse DNS entry of the site's IP address and the ASN it belongs to, in that order. The PTR record of a Hetzner IP says Hetzner no matter who sold the domain, and the ASN catches providers with unhelpful PTR records.</p>
<pre><code class="language-js">const [ip] = await dns.promises.resolve4(hostname)
const [ptr] = await dns.promises.reverse(ip).catch(() =&gt; [null])
</code></pre>
<p>One more trap. Scan the final URL after redirects, not the one the user typed. <code>example.at</code> redirecting to <code>www.example.at</code> or to a completely different domain is common, and scanning the redirect source gives you an empty page and a wrong hoster.</p>
<h2>Where it ended up</h2>
<p>The scanner powers the free privacy policy generator I built for Austrian websites. It pre-selects the detected services, fills in the hosting provider, pulls the operator data from the site's legal notice, and warns about trackers that fire before consent. The generated policy cites the Austrian laws instead of the German ones that most templates copy. If you run an .at website or are just curious what the scan finds on your site, it lives at <a href="https://webgaudi.at/datenschutzerklaerung-generator-oesterreich/">https://webgaudi.at/datenschutzerklaerung-generator-oesterreich/</a></p>
<p>The lesson that stuck with me is an old one. For anything compliance-adjacent, do not trust declarations, not even your own HTML. Measure the behavior.</p>
]]></content:encoded></item><item><title><![CDATA[Die teuerste Zeile HTML: Google Fonts direkt von Google laden]]></title><description><![CDATA[Was am dynamischen Einbinden das Problem ist
Beim Laden der Seite holt der Browser das CSS von fonts.googleapis.com und die Schriftdateien von fonts.gstatic.com. Dabei wird zwangsläufig die IP-Adresse]]></description><link>https://devholster.hashnode.dev/die-teuerste-zeile-html-google-fonts-direkt-von-google-laden</link><guid isPermaLink="true">https://devholster.hashnode.dev/die-teuerste-zeile-html-google-fonts-direkt-von-google-laden</guid><dc:creator><![CDATA[René]]></dc:creator><pubDate>Thu, 20 Aug 2026 10:53:54 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a86d8f3296359fe682bac32/74e10b6a-e390-47ea-9cec-3ea7f457f571.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Was am dynamischen Einbinden das Problem ist</h2>
<p>Beim Laden der Seite holt der Browser das CSS von <code>fonts.googleapis.com</code> und die Schriftdateien von <code>fonts.gstatic.com</code>. Dabei wird zwangsläufig die IP-Adresse des Besuchers an Google übertragen, und zwar bevor irgendein Cookie-Banner gefragt hat. Die IP-Adresse ist ein personenbezogenes Datum, also braucht die Übertragung nach DSGVO eine Rechtsgrundlage. Und die fehlt hier, weil sich Schriften problemlos ohne Google-Server ausliefern lassen.</p>
<p>Das ist keine Theorie. Das <a href="https://www.gesetze-bayern.de/Content/Document/Y-300-Z-BECKRS-B-2022-N-612">Landgericht München I hat im Jänner 2022 entschieden</a> (Az. 3 O 17493/20), dass die dynamische Einbindung rechtswidrig ist, und dem Kläger 100 Euro Schadenersatz zugesprochen. Danach rollte durch Deutschland und Österreich eine Abmahnwelle mit zigtausenden anwaltlichen Forderungsschreiben an Website-Betreiber. Viele dieser Schreiben waren selbst fragwürdig, das Grundproblem dahinter ist es nicht: Die Einbindung war auf all diesen Seiten tatsächlich so, wie beschrieben.</p>
<p>Aus meiner Erfahrung ist das Verrückte daran: Die meisten Betroffenen wussten gar nicht, dass ihre Seite Google Fonts lädt. Das Theme bringt sie mit, ein Page-Builder, ein Buchungs-Widget, eine eingebettete Karte.</p>
<h2>Selbst hosten dauert keine 10 Minuten</h2>
<p>Die Lösung ist unspektakulär: Schriftdateien gehören zum eigenen Deployment wie das Logo. Bei npm-basierten Projekten nehmen wir dafür <a href="https://fontsource.org/">Fontsource</a>, das jede Google-Font als Paket bereitstellt:</p>
<pre><code class="language-bash">npm install @fontsource-variable/inter
</code></pre>
<pre><code class="language-js">import '@fontsource-variable/inter';
</code></pre>
<pre><code class="language-css">body { font-family: 'Inter Variable', sans-serif; }
</code></pre>
<p>Fertig. Kein externer Request, keine Einwilligung nötig, kein Banner-Thema. Ohne Build-Setup geht es genauso: <code>WOFF2</code>-Dateien herunterladen, per <code>@font-face</code> einbinden, <code>font-display: swap</code> dazu.</p>
<p>Nebenbei wird die Seite schneller. Der Browser spart sich DNS-Lookup und TLS-Handshake zu zwei Google-Domains, die Schriften kommen vom selben Server wie alles andere. Wir hosten bei unseren Kundenprojekten grundsätzlich alle Schriften lokal, und die Seiten laden im Schnitt in 0,8 Sekunden. Die Fonts sind daran ein kleiner, aber messbarer Baustein.</p>
<h2>Warum "einfach den Quelltext durchsuchen" nicht reicht</h2>
<p>Jetzt der Teil, der uns beim Bauen unseres Checkers am meisten beschäftigt hat. Der erste Reflex für so ein Prüf-Tool: HTML der Seite laden, nach <code>fonts.googleapis.com</code> suchen, fertig. Dieser Ansatz übersieht ziemlich viel.</p>
<p>Fonts werden nämlich oft erst zur Laufzeit geladen: per <code>@import</code> tief in einer CSS-Datei, von einem Skript nachgeladen, von einem Widget oder Embed mitgebracht. Umgekehrt gibt es False Positives, etwa wenn die Domain nur in einem auskommentierten Block oder in einem Consent-Tool-Katalog vorkommt. Ein reiner Quelltext-Scan rät also mehr, als er prüft.</p>
<p>Unser <a href="https://webgaudi.at/google-fonts-checker/">Google-Fonts-Checker</a> startet deshalb für jede Prüfung einen echten Browser, lädt die Startseite (inklusive Weiterleitung, wenn die Domain umleitet) und schaut auf die tatsächlichen Netzwerk-Requests. Ein Detail, auf das ich ein bisschen stolz bin: Der Checker lehnt Cookie-Banner aktiv ab, bevor er misst. Denn "wir laden Google Fonts erst nach Einwilligung" ist ja eine legitime Lösung, und die soll nicht als Verstoß gemeldet werden. Gemessen wird das, was ein Besucher erlebt, der nichts akzeptiert.</p>
<p>Das Tool ist kostenlos und ohne Anmeldung nutzbar. Wenn du eine ältere Website betreibst oder betreust, wirf die Domain einfach mal rein. Das Ergebnis überrascht öfter, als man glaubt.</p>
<hr />
<p><em>Ich bin René und betreibe</em> <a href="https://webgaudi.at/"><em>webgaudi</em></a><em>, eine Webdesign-Agentur in Wien. Wir bauen individuell programmierte Websites für Selbstständige und KMU, ohne Baukasten, zum Fixpreis ab 1.500 €.</em></p>
]]></content:encoded></item><item><title><![CDATA[A 20-minute ReDoS audit for a Node API you already shipped]]></title><description><![CDATA[Every regex in your request path is code with a runtime that depends on the input, and unlike the rest of your code, nobody ever asked what its worst case is. That is the whole vulnerability. Not obsc]]></description><link>https://devholster.hashnode.dev/a-20-minute-redos-audit-for-a-node-api-you-already-shipped</link><guid isPermaLink="true">https://devholster.hashnode.dev/a-20-minute-redos-audit-for-a-node-api-you-already-shipped</guid><dc:creator><![CDATA[René]]></dc:creator><pubDate>Thu, 20 Aug 2026 10:40:28 GMT</pubDate><content:encoded><![CDATA[<p>Every regex in your request path is code with a runtime that depends on the input, and unlike the rest of your code, nobody ever asked what its worst case is. That is the whole vulnerability. Not obscure, not theoretical, just unmeasured.</p>
<p>Two well-documented cases to set the stakes. Cloudflare went down globally for 27 minutes on 2 July 2019 because a WAF rule contained the sub-pattern <code>.*.*=.*</code> and it pinned CPU across the fleet. Stack Overflow was down for 34 minutes on 20 July 2016 because a post-trimming regex, roughly <code>^[\s‌]+|[\s‌]+$</code>, met a post with about 20,000 consecutive spaces.</p>
<p>Neither was an exotic pattern. Both were validation code that ran fine for years.</p>
<p>Here is a pass you can do this afternoon on your own service.</p>
<h2>Step 1: find every regex, including the ones you forgot</h2>
<pre><code class="language-bash">rg -n --type js -e 'new RegExp\(' -e '/(?:[^/\n\\]|\\.){8,}/[gimsuyd]*'
</code></pre>
<p>Then the ones that are not in your code:</p>
<pre><code class="language-bash">rg -n -e 'pattern:' -e 'matches:' --type yaml --type json
</code></pre>
<p>That second one catches JSON Schema <code>pattern</code> keywords, OpenAPI specs, Joi and Zod <code>.regex()</code> calls, and validation rules that live in config. In most codebases I have looked at, that is where the risky ones actually are, because a schema is data and never gets code review.</p>
<p>Sort what you find into two piles: regexes that ever touch a request body, a query string, a header or an uploaded file, and everything else. Only the first pile matters. A regex over a hardcoded constant cannot be attacked.</p>
<h2>Step 2: triage by shape, not by feeling</h2>
<p>There are three shapes worth knowing. If a pattern has none of them, move on.</p>
<p><strong>Nested quantifier.</strong> A repetition inside a repetition, where the inner one can match the same character the outer one can.</p>
<pre><code class="language-js">/^(a+)+$/
/^([a-zA-Z0-9_.\-])+@(([a-zA-Z0-9\-])+\.)+([a-zA-Z0-9]{2,4})+$/
</code></pre>
<p>That second one is a real email validator that has been copied into thousands of projects. The <code>+</code> after the final group is the bug. Feed it 30 valid-looking characters followed by one that breaks the match and the engine walks an exponential number of ways to split them.</p>
<p><strong>Overlapping alternation under a quantifier.</strong></p>
<pre><code class="language-js">/^(\w|\d|_)*$/
/(a|ab)+c/
</code></pre>
<p><code>\w</code> already contains <code>\d</code> and <code>_</code>. Every character has three ways to match, so the engine has three paths per character to back through.</p>
<p><strong>Two quantifiers that can eat the same characters.</strong></p>
<pre><code class="language-js">/.*.*=.*/          // the Cloudflare one, reduced
/^\s*(.*?)\s*$/
/(\s*)+$/
</code></pre>
<p>This one is only quadratic, not exponential, which sounds reassuring and is not. Quadratic at 100,000 characters is still ten billion steps, and a 100 KB request body is nothing.</p>
<p>The rule of thumb underneath all three: <strong>ambiguity plus repetition plus a failing match</strong>. The blowup needs the match to eventually fail, which is why "I tested it with valid input and it was instant" tells you nothing at all. Valid input takes the first path. Attackers do not send valid input.</p>
<h2>Step 3: measure it, do not reason about it</h2>
<p>Reasoning about backtracking is a good way to convince yourself a pattern is safe when it is not. Time it instead.</p>
<pre><code class="language-js">const re = /^([a-zA-Z0-9_.\-])+@(([a-zA-Z0-9\-])+\.)+([a-zA-Z0-9]{2,4})+$/;

for (let n = 10; n &lt;= 40; n += 2) {
  const attack = 'a'.repeat(n) + '@';   // matches the local part, then fails
  const t = process.hrtime.bigint();
  re.test(attack);
  const ms = Number(process.hrtime.bigint() - t) / 1e6;
  console.log(n, ms.toFixed(2) + 'ms');
  if (ms &gt; 1000) break;
}
</code></pre>
<p>You are not looking at the absolute numbers. You are looking at the <strong>shape of the curve</strong>. If each step of 2 roughly quadruples the time, it is exponential and you are done deliberating. If it roughly quadruples every time you <em>double</em> n, it is quadratic and you need to know your input size limit. If it stays flat, the pattern is fine.</p>
<p>The number to write in the ticket is the input length at which the match costs one full second of CPU. If that number is smaller than your body-size limit, it is a live denial of service, not a code smell.</p>
<h2>Step 4: know what your tooling does and does not cover</h2>
<p><code>npm audit</code> finds published ReDoS advisories in your dependencies. It has nothing to say about the regex you wrote in <code>validators.js</code>, which is where yours is.</p>
<p><code>safe-regex</code> is the package people reach for and it is old, static-only, and has real false negatives. The current state of the art is <code>recheck</code>, which does a hybrid static and fuzzing analysis. It is packaged as <code>eslint-plugin-redos</code>, so you can put it in CI:</p>
<pre><code class="language-bash">npm i -D eslint-plugin-redos
</code></pre>
<p>That covers regex literals in JS. It does not cover patterns in JSON Schema, OpenAPI or config, which is why step 1 is a separate step.</p>
<h2>Step 5: fixes, in order of how much they buy you</h2>
<ol>
<li><p><strong>Cap the input before the regex sees it.</strong> <code>if (value.length &gt; 254) return false;</code> in front of an email validator removes an entire class of attack for one line, and 254 is the actual RFC limit anyway. Do this first, always, even after you fix the pattern.</p>
</li>
<li><p><strong>Remove the ambiguity.</strong> <code>(\w|\d|_)*</code> is just <code>\w*</code>. <code>([a-zA-Z0-9\-]+\.)+</code> should have a bounded repetition. Most nested quantifiers are a redundant <code>+</code> that someone added while debugging and never removed.</p>
</li>
<li><p><strong>Anchor and be specific.</strong> An unanchored pattern gets retried at every start position, which multiplies whatever the per-position cost is by the input length.</p>
</li>
<li><p><strong>Stop using a regex.</strong> Email, URLs, dates and CSV are all better handled by a parser or a library. <code>new URL()</code> cannot be made to backtrack.</p>
</li>
<li><p><strong>Swap the engine where the pattern must stay.</strong> The <code>re2</code> npm package binds Google's RE2, which is linear time because it does not backtrack at all. The trade is no backreferences and no lookbehind. For validation patterns that is almost never a real constraint. V8 also has an experimental linear-time engine behind <code>--enable-experimental-regexp-engine</code>, which is worth knowing about and not worth shipping on yet.</p>
</li>
</ol>
]]></content:encoded></item></channel></rss>