Je klikt op een link naar een lokale krant, en de kop laadt, dan de foto, dan de eerste drie alinea’s, en je bent al aan het lezen voordat er verder iets gebeurt. Dan wordt de pagina leeg. Of ze verandert in een hoop ongestylede links, met het logo uitgerekt over de hele breedte van het scherm. Of er schuift een kader omhoog dat je vraagt om tracking te accepteren of £2,99 per maand te betalen, zonder derde knop.

Het artikel stond op jouw machine. De server van de uitgever had het al helemaal gestuurd, tekst en stylesheets, en je browser had het al getekend. Wat het wegnam, was code die de uitgever daarna koos te draaien, op jouw computer, ingekocht bij een commerciële leverancier wiens product bestaat uit terugnemen wat net geleverd is.

Thuis draai ik een Pi-hole1, die advertentie- en trackinghosts blokkeert voor elk apparaat op het netwerk. Op een groeiende lijst Britse nieuwssites was dat genoeg om de pagina te laten vernietigen. Dus bouwde ik op 22 september 2026 een Chrome-extensie om dat te stoppen, Keep The Page genaamd. De code staat op GitHub onder damo2929/browserplugin, met een MIT-licentie, en dit is wat ze doet, wat ik in die pagina’s vond tijdens het bouwen, en welke fixes het erger maakten voordat de juiste opdook.

Het is geen paywall-omzeiling. Als de server het artikel nooit stuurde, tovert niets hier het tevoorschijn. De Times bleef dicht, en dat klopt. Wat ze bewaart, is wat je al toegestuurd kreeg.

Het artikel komt binnen, en dan is het weg

Van buitenaf werken ze allemaal hetzelfde. De HTML komt compleet binnen, en dan besluit een script, meestal geladen van een host van een leverancier in plaats van de krant, dat jij het niet waard bent om het te zien, en haalt het weg.

Wat het weghaalt, en hoe, hangt af van welke leverancier de uitgever kocht. Dit zijn de vier die ik tegenkwam, elk tijdens het bouwen van een echte pagina afgelezen:

UitgeverWat binnenkomtWat de pagina daarna met zichzelf doet
Newsquest (269 titels)2het hele artikelbouwt een muur, draait een eval-payload, opent een confirm()-dialoog
notebookcheck.nethet hele artikelverwijdert <body> na zo’n 7 seconden, dan dialogen, dan een herlaadlus
National World (The Scotsman, Yorkshire Post)3het artikel plus zo’n 68KB eigen CSSverwijdert elke 100ms elke <link> en <style>, voor altijd
Reach plc (Mirror, Daily Record, Manchester Evening News, Liverpool Echo en anderen)4het hele artikeldekt het af met “accepteer tracking of betaal £2,99/maand”

De 269 komt niet uit een persbericht, dat spreekt van “meer dan 200 merken”. Het is het aantal domeinen op de eigen TLS-certificaten van Newsquest, de lijst die de extensie nodig had om te weten waar ze moest draaien.

Het geval National World zou uitgevers het meest zorgen moeten baren, omdat het de pagina breekt om redenen die niets met advertenties te maken hebben. De stylesheet-stripper heeft een partner die de CSS terughaalt van de host van de leverancier. Mijn Pi-hole blokkeert die host. Dus liep het strippen, kwam het herstel nooit, en bleef de eigen opmaak van de krant permanent afhankelijk van de bereikbaarheid van een advertentieleverancier. Dat is geen muur. Dat is een uitgever die het uiterlijk van zijn eigen website aan een derde overlaat zonder het te merken.

Mag ik vragen waarom het stylesheet van een krant moet wachten op de server van een advertentiebedrijf voordat het op de pagina mag blijven? Voor de lezer is daar geen enkele reden voor. Geen.

Waarom ik het malware noem

Ik gebruik het woord bewust, als beschrijving van wat de code doet, en niet als juridisch oordeel over wie dan ook. De anti-adblock-payloads voldoen op vier punten aan de gewone betekenis, en elk punt is rechtstreeks waargenomen:

CriteriumWat ik zag
Draait zonder toestemming, tegen je belang inniemand vroeg erom, en zijn taak is je inhoud afnemen die je al hebt
Vernietigt gegevens die je al geleverd kreeghet artikel en de CSS komen intact aan, dan verwijdert code in de pagina ze
Versluierd tegen lezengepermuteerde stringtabellen zoals o[293 * (r + 450) % e], uitgevoerd via eval
Ontwijkt blokkering en straft ingrijpen afCNAME-cloaking om DNS-blokkeerlijsten te ontduiken, anti-tamper-controles die escaleren naar een dialoog of een herlaadlus

De versluiering is geen minificatie. Geminificeerde code is klein. Dit is code die zo is ingericht dat je er niet met grep in kunt vinden wat ze doet, en de payload draait dan via eval, zodat niets op de schijf overeenkomt met wat er wordt uitgevoerd.

De cloaking is een bekende techniek met een eigen onderzoeksliteratuur5. De loader wordt opgehaald van iets dat eruitziet als een subdomein van de krant, en die naam verwijst naar de leverancier:

a02342.<publisher-domain>   ->  cdn-52-x.privacy-mgmt.com      (Sourcepoint)
fb.html-load.com            ->  adshield-fallback-dev-wskxz.b-cdn.net

Het subdomein is per titel willekeurig. Een blokkeerlijst die hosts noemt kan dat dus niet bijhouden, en daar is het om te doen.

En de code behandelt elk ingrijpen als bewijs van schuld. De gedecodeerde foutmeldingen in de payload die beheerders van filterlijsten aan Ad-Shield toeschrijven6 luiden letterlijk Vital API blocked en Vital API blocked (eval). De loader van Sourcepoint schrijft een attribuut, leest het meteen terug en gooit een fout als de waarde veranderd is:

z.call(O,'src',G), O[x]('src') !== G && throw E
… catch (W) { try { await l(W) } catch (x) { o(W) } }   // o() raises the dialog

Sourcepoint heeft dit openlijk verkocht. In de eigen documentatie stond “on average about 30% of messaged users will turn off their adblockers”7. Ik beschrijf dus geen schurkenscript dat iemand erin smokkelde. Het is een product, door de uitgever gekocht en bewust ingezet.

De consent-or-pay-muren zijn een andere categorie, en die noem ik geen malware. Ze vernietigen niet wat geleverd is en verstoppen zich niet voor blokkeerlijsten. Ze dwingen op een andere manier, en daar gaat de volgende sectie over.

Accepteer 1.467 partners of betaal £2,99

De muur op de titels van Reach is Quantcast Choice, tegenwoordig gerund door InMobi8 en geserveerd vanaf cmp.inmobi.com. Hij geeft twee keuzes. Accepteren, of £2,99 per maand betalen. Een gratis “nee” is er niet.

Accepteren deelt je gegevens met 1.467 vermelde partners en schrijft een euconsent-v2-cookie dat 13 maanden meegaat. Niemand leest een lijst van 1.467 bedrijven, niemand zou kunnen afwegen wat elk van hen met de gegevens zou doen als je hem wel las, en het getal alleen vertelt je al wat voor toestemming er gevraagd wordt.

De Britse AVG zegt dat toestemming vrijelijk gegeven moet zijn, en dat je bij het beoordelen daarvan kijkt of de dienst afhankelijk is gemaakt van toestemming voor verwerking die hij niet nodig heeft9. De ICO heeft richtsnoeren gepubliceerd die zeggen dat consent or pay rechtmatig kan zijn10, richtsnoeren die ze nu herzien noemt, en het Europees Comité voor gegevensbescherming heeft gezegd dat het voor grote platforms die alleen die twee opties bieden in de meeste gevallen niet rechtmatig zal zijn11. Ik ga niet doen alsof de toezichthouder het verboden heeft. Dat heeft ze niet.

Dus hier kom ik uit, gewoon eerlijk. Ik koos ervoor de muur weg te halen en de toestemming te weigeren. Dat betekent dat ik het artikel lees op de gratis tak, zonder te betalen en zonder mijn gegevens aan 1.467 bedrijven te geven. Het is een beslissing. De extensie zegt dat op haar eigen instellingenpagina, en ik vermom het niet als iets neutraals. Een toestemming die je niet kunt weigeren is een prijs, en ik betaal geen prijs die vermomd is als vraag.

Het kader weghalen is het makkelijke derde deel. De andere twee derde staan in De toestemmingsvraag goed beantwoorden, want een banner die je verwijdert zonder te antwoorden komt elke keer terug.

Eén dag, acht commits

Het geheel is op één dag gebouwd. Eerst een paar uur in pagina’s porren voordat er iets gecommit werd, daarna acht commits tussen 20:32 en 22:50. Ik bouwde het met Claude Code, en veel van het zware werk, versluierd inline-script regel voor regel lezen, deed de agent terwijl ik keek wat de pagina’s op het scherm deden. Die taakverdeling werkte goed, en waar het misging staat hieronder, want het ging mis op een manier die je moet kennen.

TijdCommit
20:32eerste commit: Newsquest, notebookcheck, National World, Reach, Page Six
20:46de leverancier achter elk mechanisme, in de README gezet
20:5650 links van de voorpagina van Google News, als niet-gekozen steekproef
21:02de consent-API beantwoorden met elk doel geweigerd
21:11sociale links onschadelijk gemaakt
22:50uitschakelbare beschermingen, MSN en Bing, weigeren per klik, 77 tests

Het is een Manifest V3-extensie met twee rechten, declarativeNetRequest en storage, zonder hostrechten en zonder eigen netwerktoegang, dus ze kan niets ophalen, geen antwoord herschrijven en met geen enkele server namens jou praten. Die beperking heeft het ontwerp meer gevormd dan wat ook, omdat het interessante werk binnen de pagina moet gebeuren.

Twee werelden, één attribuut omlaag en één event omhoogDe code van de pagina en de instellingen van de extensie leven in verschillende wereldenOpslag van de extensiechrome.storage.localbeschermingentracingkanalensociale instellingenlaatste 50 foutengelezen en geschrevendoor de instellingenpaginaGeïsoleerde wereldbridge.jssocial.jsportal.jskan chrome.storage lezenkan de functies van depagina niet aanrakenWereld van de pagina (MAIN)walls.jsguard.jsportal-early.jsomhult setTimeout, cookie,__tcfapi, window.adLighthelemaal geen chrome.*Omlaag: een attribuut op het html-element, alleen wat uit staat<html data-ktp-off="cookies,dom">standaardinstallatie: niets geschrevenOmhoog: een ktp-report-eventdetail: een JSON-stringeen object komt niet betrouwbaar over
Chrome draait extensiescripts in twee werelden. De wereld van de pagina bereikt het eigen JavaScript van de pagina, maar heeft geen extensie-API’s. De geïsoleerde wereld kan instellingen lezen, maar kan de functies van de pagina niet aanraken. Alles gaat ertussen heen en weer als een attribuut naar beneden en een event naar boven.

De MAIN-wereld van Chrome deelt de JavaScript-omgeving van de pagina12. Een script daar kan setTimeout vervangen, document.cookie omhullen of window.adLight definiëren voordat de pagina dat doet, en precies dat heb je nodig om deze muren te bestrijden. In de praktijk kan het chrome.storage niet aanroepen. De geïsoleerde wereld kan dat wel, maar ziet de functies van de pagina niet. Dus leest een kleine brug de instellingen en schrijft ze op <html>, en fouten komen terug als een CustomEvent waarvan het detail een JSON-string is, omdat een object die grens niet betrouwbaar oversteekt.

Het attribuut naar beneden noemt alleen wat je hebt uitgezet. Een standaardinstallatie schrijft helemaal niets in de pagina. Dat telt, omdat een permanente markering op <html> precies het soort ding is waar deze SDK’s naar zoeken, en omdat een opslagfout dan faalt richting het verdedigen van de pagina in plaats van ervan af.

Vind de poort, vecht niet tegen de muur

De Newsquest-fix is twee regels redeneren, en daaraan werd al het andere afgemeten.

Hun hele muur hangt aan één vlag in de pagina:

var adLight = false;        // line 1647
if (adLight !== true) { …}  // line 2020: loader, eval payload, confirm()

adLight is de abonneevlag voor “weinig advertenties”. Is die waar, dan wordt de muur nooit gebouwd. Dus definieert de extensie window.adLight bij document_start als true, voordat het eigen script van de pagina draait, met een setter die negeert wat erin geschreven wordt:

Object.defineProperty(window, 'adLight', {
  configurable: false,
  enumerable: true,
  get() { return true; },
  set() { /* ignore the page's "false" */ }
});

Een var bovenaan een script definieert een eigenschap die het globale object al heeft niet opnieuw. Het kent er alleen een waarde aan toe13. Dus de eigen var adLight = false van de pagina draait, belandt in de setter en doet niets. De loader van de muur start nooit, de eval-payload komt nooit aan, en er is geen anti-tamper-controle om af te laten gaan, want er is aan niets geknoeid. De vlag zei gewoon ja.

Dat is de enige eigenschap in de hele extensie die niet configureerbaar is. Dat moet, om de declaratie te overleven. Al het andere is configurable: true, zodat geen pagina ooit een van haar eigen API’s voorgoed kwijtraakt.

Het dekt 269 titels en is in de instellingen niet uit te zetten, en die zeggen ook waarom: het draait voordat chrome.storage kan antwoorden, en eenmaal gezet is het niet terug te draaien. Een selectievakje zou versiering zijn.

Elke fix die het erger maakte

Dit is de nuttige sectie, want elke fout hier is het voor de hand liggende dat je probeert.

Wat ik probeerdeWat er gebeurde
De host van de loader blokkerende host is per titel een willekeurige first-party-CNAME, en een mislukte fetch is zelf het detectiesignaal
setAttribute bewaken op geïnjecteerde scriptsactiveerde de terugleescontrole van Sourcepoint, die juist de dialoog opende die het moest tegenhouden
De confirm() met Annuleren beantwoordenin deze SDK betekent Annuleren herladen, dus een eindeloze herlaadlus
<body> bij DOMContentLoaded vastleggen om later te herstellende muur maakt de pagina al tijdens het parsen leeg, dus de momentopname was van een lege body
remove() en removeChild() bewakende muur ruimt de pagina op met één innerHTML = '', niet knoop voor knoop
Alles tegelijk bewaken op notebookcheckde muur escaleerde naar een dialoog en daarna een herlaadlus, duidelijk erger dan niets doen
De host van de leverancier omleiden naar een lokale stubeen redirect-regel met alleen declarativeNetRequest maakte de hele regelset ongeldig en doodde stil de Newsquest-regel op 269 sites
De paginabewakers op elke site draaienpatchte globale prototypes op elke pagina die ik bezocht, ook die van mijn bank

De omleiding verdient een tweede blik. Chrome geeft een block-regel impliciete toegang en wil voor alles daarboven een hostrecht14. De documentatie zegt dat ongeldige statische regels genegeerd worden15. Wat ik zag was erger: het hele bestand was weg, zonder enige fout op de pagina, en regel 1 bestond op 269 sites gewoon niet meer. De twee regels voor MSN staan daarom nu in een eigen regelset, zodat een slechte wijziging aan de ene de andere niet meesleurt.

De herlaadlus leerde de andere harde regel. location.reload is niet te onderscheppen. Het Location-object is in de HTML-standaard onvervalsbaar16, dus zijn methoden zijn niet schrijfbaar en niet configureerbaar, en proberen levert op:

Object.defineProperty(location,'reload',...)
-> TypeError: Cannot redefine property: reload

Een muur waarvan het foutpad “herlaad de pagina” is, valt niet meer te stoppen zodra hij op dat pad zit. Door niets. De enige fix is zorgen dat hij daar nooit komt. Dat is dezelfde les als bij adLight, op de pijnlijke manier geleerd: elke poging om een al draaiende muur te bestrijden maakte het erger, en elke fix die werkte hield de muur tegen voordat hij begon.

Eén timer richtte de schade aan

notebookcheck heeft geen adLight. De muur draait altijd. Met tracing aan liet de extensie zien wat hij inplande:

dropped setTimeout(7005ms) scheduled from eval    <- the body.remove()
dropped setTimeout(1251ms) / 105ms / 0ms x8
dropped setInterval(15000ms)

Eén van die timers richt alle schade aan: die na 7 seconden die <body> verwijdert. Alles daarna is reactie, want de lege pagina gooit een fout, de exceptie opent de dialoog, de dialoog herlaadt de pagina, en het hele ding begint van voren af aan met een nieuwe set timers.

Dus de fix is één regel. Laat een timer vallen als hij vanuit eval-code is ingepland, de pagina de handtekening van deze SDK draagt en de handler een functie is. De stack vertelt waar een aanroep vandaan kwam, en eval laat daarin zijn spoor na.

before: 4 page loads, 3 confirms, reload loop
after:  1 page load,  0 confirms, alive 40,378ms, content intact
Eén timer, en alles wat eruit volgtnotebookcheck: één timer richt de schade aan, de rest is reactieZoals geserveerdartikel en CSSbinnen, getekendloader vanhtml-load.comeval-payloadplant timers7,0s: timer loopt afbody.remove()lege pagina gooit,confirm() geopendpagina herlaadten begint opnieuw4 keer laden, 3 dialogen, herlaadlusMet de extensieartikel en CSSbinnen, getekendloader vanhtml-load.comeval-payloadplant timerselke eval-timermeteen geschrapt1 keer laden, 0 dialogen,na 40 seconden in leven
notebookcheck zonder en met de extensie. Niets na de timer van 7 seconden hoeft gerepareerd te worden, want niets daarvan gebeurt als die timer eenmaal gevallen is.

Er zaten op dat moment nog twee andere mechanismen in de build, een omleiding van de loader naar een stub en een lokelement om de schrijfacties van de muur op te vangen. Allebei werkten ze, en allebei behandelden ze symptomen van die ene timer, wat pas duidelijk werd toen de timerregel alleen getest werd en op zichzelf genoeg bleek. Dus gingen ze er allebei uit, en daarmee verdwenen een recht en elk hostrecht. Test altijd of de laatste wijziging alleen het werk doet voordat je de steigers eromheen laat staan.

De handtekening is het data-sdk-attribuut op de loadertag, en die past op een vorm, l/<n>.<n>, in plaats van op een versie. Op één dag doken er drie versies op. Ze klikt vast en onthoudt nooit een negatief, omdat de loadertag misschien nog niet geparst is als de eerste timers worden ingepland, en een onthouden “nee” zou haar op een pagina die de muur wel draagt voorgoed ontwapenen.

De meting zei goed. Het scherm niet.

The Scotsman en de Yorkshire Post golden als gerepareerd op basis van een meting die tekst telde. De pagina had er 8.808 tekens van, 22 elementen onder <body>, stabiel na 2, 8 en 16 seconden. Volgens die maatstaf was ze intact.

Dat was ze niet. Ik keek ernaar, en elk stylesheet was weg, de links waren een kale lijst, het SVG-logo vulde het scherm en er was een horizontale schuifbalk. Alle woorden waren er, en meer kon die meting niet zien. Ik moest naar het scherm wijzen en het zeggen.

Wat de meting mat, en wat er op het scherm stondYorkshire Post vóór de fix: dezelfde pagina, op twee manieren gemetenWat de meting matinnerText.length8.808kinderen van body22na 2s, 8s, 16songewijzigdOordeel: intactWat er op het scherm stonddocument.styleSheets.length0links als kale lijst, logo volle breedte,een horizontale schuifbalkOordeel: kapotStripper geschrapt bij het inplannen: 2 stylesheets, 532 regels, de pagina wordt weergegeven
Dezelfde pagina, op twee manieren gemeten. De tekstlengte zei dat de pagina gezond was. Het aantal stylesheets zei dat ze kapot was, en het aantal stylesheets had gelijk.

De oorzaak was de stripper uit de eerste tabel, en die paste niet op de timerregel, omdat het een gewoon inline-script is en geen eval. Dus is zijn eigen broncode de handtekening: een herhaalde taak waarvan de body querySelectorAll('link,style') en daarna remove() aanroept. Niets legitiems doet dat. Hij wordt bij het inplannen al geschrapt, er wordt nooit iets gestript en er hoeft niets hersteld te worden. De Yorkshire Post ging van 0 stylesheets naar 2, met 532 regels, en de pagina werd weergegeven.

Om hem te vinden was een stacktrace nodig, geen gok. Patch Element.prototype.remove zodat het de stack logt telkens als het een STYLE of LINK verwijdert, en het noemde het inline-script en de forEach bij de eerste poging.

Daarna ging document.styleSheets.length in elke controle. Een pagina zonder stylesheets is kapot, hoeveel tekst ze ook heeft. Meten is lastiger dan je denkt, en dit zijn de valkuilen waar de build die dag in trapte:

ValkuilWat die zeiWat klopte
tekstlengte als rendercontrole“intact”ongestylede markup
één meting na het ladenReach-muren “niet aanwezig” op acht titelsde muur leeft zo’n 600ms en was al opgeruimd
lege console na navigatie“de bewaker vuurt niet”meldingen tijdens het laden overleven de navigatie niet
metingen na 1s en 4snotebookcheck gezondde pagina wordt leeg na 5 tot 8 seconden
cssRules over origins heen geteldESPN had 5 regelsstylesheets van andere origins gooien een fout, dus de telling valt laag uit

De fix voor het geval van 600ms is elke 100ms peilen vanaf het moment dat de pagina laadt. Op Wales Online verscheen de muur na 425ms en was hij na 1.129ms weg.

Daarna werden de controles echt gedraaid. 52 artikelen op 26 domeinen, twee per site, gekozen om elk mechanisme te dekken: 52 van de 52 met stylesheets, geen muur op het scherm achtergebleven, geen herlaadlussen. Daarna 50 links rechtstreeks van de Britse voorpagina van Google News, niet door mij gekozen: 45 normaal weergegeven, 2 waren hosts die mijn Pi-hole bewust blokkeert, 1 was de echte paywall van de Times, en 2 waren hetzelfde artikel dat twee keer landde omdat Google de volgorde van de links bij elke keer laden opnieuw opbouwt. Geen enkele met nul stylesheets. Drie titels van National World die ik nooit getest had doken in die ronde op met dezelfde SDK, en alle drie werden ze weergegeven, en daarvoor pas je op een vorm in plaats van op een lijst sites.

Er zijn 77 unittests, in de repository met al het andere. Er is geen Node op deze machine, dus ze draaien op gjs, en ze laden de echte walls.js tegen een vervangende DOM, zodat de patronen uit productie getest worden. Elke test is gecontroleerd door te breken wat hij bewaakt en te kijken hoe hij rood wordt. Ze testen alleen beslissingen. Dat ze slagen betekent niet dat een pagina rendert, en de Scotsman is de reden dat die zin in de README staat.

De toestemmingsvraag goed beantwoorden

Een consentbanner verwijderen laat de vraag onbeantwoord. Dat heeft twee gevolgen, en het eerste is dat de banner bij elke keer laden opnieuw wordt opgebouwd. En een uitgever die zijn inhoud tegenhoudt tot de consent-API antwoordt, blijft gewoon hangen, terwijl een leverancier die geen antwoord krijgt de vraag als nooit gesteld kan behandelen. Zwijgen is geen weigering.

Dus beantwoordt de extensie haar, in deze volgorde:

Weigeren, nee zeggen, niets opslaan, en pas dan verwijderenEen consentbanner wordt beantwoord, niet alleen verwijderdContainer van een consentleverancier in beeld#qc-cmp2-container #onetrust-consent-sdk sp_message_container1. Hun weigering indrukkenReject all, Decline, Only essential,Continue without acceptingalleen heel label, max. 40 tekenspast op Accept: de build faalt2. De API antwoorden: nee__tcfapielk doel, elke functie enelke leverancier geweigerdtcString "" tcloaded3. Het record nooit opslaaneuconsent-v2 addtl_consentOptanonConsent didomi_tokencookie- en localStorage-schrijfacties weggegooidBij de volgende tik nog in beeld?ja: verwijderen, scrollen vrijgevennee: de weigering blijft staan
Drie antwoorden en een terugvaloptie. De weigerknop wordt ingedrukt als die er is, de consent-API krijgt op alles nee, het record wordt nooit opgeslagen, en alleen een banner die bij de volgende tik nog staat wordt verwijderd.

Eerst drukt ze op hun weigerknop. Alleen knoppen waarvan het hele label een weigering is: “Reject all”, “Decline”, “Only essential”, “Continue without accepting” en nog een paar, elk verankerd, alles boven 40 tekens genegeerd. Een unittest voert haar “I Accept”, “Accept All”, “Agree and close”, “Allow all”, “Got it”, “Subscribe” en “Pay £2.99/mo” en laat de build falen als er één van overeenkomt. Op de verkeerde knop klikken zou namens jou toestemming geven, en dat is het enige wat dit project nooit mag doen. Op msn.com liet “Reject All” de banner van Microsoft verdwijnen, en hij kwam na herladen niet terug, omdat Microsoft de weigering op de eigen servers bewaart.

Ze beantwoordt de consent-API met nee. Het raamwerk van de IAB geeft elke pagina die toestemming vraagt een functie genaamd __tcfapi om te vragen waarmee je hebt ingestemd17. Waar die bestaat, beantwoordt de extensie haar met elk doel, elke speciale functie en elke leverancier geweigerd, een lege consentstring en eventStatus: 'tcloaded', wat betekent dat het antwoord definitief is. Ze verschijnt alleen waar al een consentraamwerk op de pagina staat, dus een site die nooit vroeg ziet haar niet.

Ze slaat het record nooit op. document.cookie en localStorage laten stil euconsent-v2, addtl_consent, OptanonConsent, didomi_token en de rest vallen, zodat een toestemming die je nooit gaf nooit wordt geschreven en op de volgende pagina nooit wordt doorgespeeld aan 1.467 partners. Het Britse recht vereist hiervoor al toestemming voordat er iets op je apparaat wordt opgeslagen18. De extensie dwingt het nee af.

Elke naam op die lijst is aan beide kanten verankerd, en daar zit een verhaal achter. De cookies van Sourcepoint beginnen met _sp_. Een slordig patroon sp_ pakt ook sp_dc en sp_t van Spotify, de inlogsessie, en zou me op elke pagina bij Spotify hebben uitgelogd. Ook dat legt een test vast.

Verwijderen is de terugvaloptie. Alleen voor een banner die na de klik nog op het scherm staat, en alleen voor een banner die echt getoond wordt. Verschillende leveranciers laten een permanente omhulling in de pagina staan, of er nu een banner is of niet. euronews houdt een Didomi-host met hoogte nul aan, en die eruit rukken breekt de pagina zonder enige winst. Die van Reach zat drie niveaus onder <body>, in twee omhullingen die zelf niet vastgezet zijn, dus de controle moet afdalen tot het kader dat dat wel is.

Deelknoppen, en een portaal dat zijn eigen instellingen negeert

Nog twee dingen op deze pagina’s zijn er om iemand anders dan de lezer te dienen, en ze hadden een andere aanpak nodig dan de muren.

Deel- en volgknoppen. Elke link naar Facebook, Instagram, X, TikTok of LinkedIn wordt herschreven naar http://localhost/removeme, met het origineel in een attribuut geparkeerd zodat er niets verloren gaat. Daarna verwijdert een tweede ronde wat duidelijk meubilair is (een icoon zonder tekst, “Share on X”, alles in een container die zichzelf share of social noemt) en verbergt de rest. De markering is er zodat één selector alles laat zien wat op het punt staat te verdwijnen, en een modus die alleen markeert stopt na de eerste ronde, zodat je kunt kijken voordat je haar op een site vertrouwt.

De naïeve versie breekt pagina’s op drie manieren, en elk daarvan is nu een test:

Naïeve versieWat die breektWat er in plaats daarvan gebeurt
a[href*="x.com"]pakt ook netflix.com, linux.com, phoenix.comde host parsen en hele labels vergelijken
elke sociale link verwijderen“Continue with Facebook” verdwijnt en mensen worden buitengesloteninlog-, OAuth-, juridische en ontwikkelaarslinks blijven met rust
een link in een zin verwijderende woorden gaan meestandaard verbergen, een optie pakt hem uit en houdt de woorden

Dat laatste is een afweging die ik bewust maakte. Een afsluiting van de BBC leest dan als “follow BBC Manchester on , , and .”. Ik heb dat bekeken en toch voor verbergen gekozen. De optie om de woorden te houden is er voor iedereen die het er niet mee eens is.

MSN en Bing. Beide draaien op dezelfde web components, zo’n 160 shadow roots op de voorpagina. Een gewone query op het document vond 1 link. De shadow roots doorlopen vond er 73, waaronder de tegels van Facebook en X. Wat ze niet doorloopt, ziet er bijna niets van.

MSN heeft wel eigen inhoudsinstellingen. Die staan op de servers van Microsoft, gekoppeld aan een anonieme ID, niet in een cookie, dus ze zijn weg na het wissen van cookies, in een nieuw profiel en in incognito. Bing bereiken ze helemaal niet. En met elk ervan uit en na 11.700 pixels scrollen stond dit nog in de feed:

Eigen schakelaar van MSN, uitNog op de pagina
Casual Gamesde spelletjestegel
Shoppingadvertentietegels voor Booking, Temu en eBay
Comments501 reactielinks
Weather, Finance, Sportsweg, tot de volgende keer cookies wissen

Een schakelaar met het label Comments die 501 reactielinks op de pagina laat, is een bewering tegenover de gebruiker, en een onware. Dus haalt de extensie ze er zelf uit, gericht op de stabiele componentnamen van MSN in plaats van de klassenamen, die bij elke build veranderen.

Eén ding dat ik daar leerde en dat ver buiten MSN geldt: een afbeelding uit de pagina halen houdt het laden niet tegen. Chrome begint met ophalen zodra src wordt gezet, zelfs voor een afbeelding die nooit in de pagina wordt geplaatst19. Tegen de tijd dat een content script een kaart ziet, is de miniatuur al onderweg. Dus worden de feedgegevens geblokkeerd bij de twee eindpunten die ze leveren, en nooit de afbeeldingshosts, omdat th.bing.com ook de afbeeldingenzoekfunctie van Bing bedient.

En de MSN-feed flitst nog steeds even op het scherm voordat hij verdwijnt. Vier pogingen om hem eerder te verbergen werden allemaal als werkend gemeten, en geen enkele stopte het flitsen, wat betekent dat wat getekend wordt niet is wat de extensie verbergt. De instellingenpagina zegt dat ronduit. Liever zegt ze dat dan dat ze een schone laadbeurt suggereert die ze niet levert.

Wat ze niet doet

Ze zal nietOmdat
een echte paywall openenals de server het artikel achterhoudt, blijft het achtergehouden
gokken bij een siteeen domein komt er pas in als de muur daar gezien is; twee sites van News Corp waarvan werd aangenomen dat ze op Page Six leken, deden dat niet en gingen er weer uit
een site zonder handtekening aanrakenop de BBC is elke hook geïnstalleerd en wordt niets gelogd, geen timer geschrapt, geen knoop aangeraakt
naar huis bellengeen hostrechten, geen netwerktoegang, geen telemetrie; het foutenlogboek blijft in je browser
doen alsofhet MSN-flitsen is onopgelost, en één Ad-Shield-variant, wp-ls/…, past nog niet; de pagina ervan werd prima weergegeven, dus het is opgeschreven in plaats van op de gok gerepareerd

Een pagina die je is toegestuurd, is van jou

Zodra een server je browser een pagina heeft gestuurd, staat die kopie op jouw machine. Er daarna code op draaien om haar weer af te pakken, is geen verdienmodel dat ik erken. Het is de oude truc van je iets verkopen en er de hand op houden.

Kranten waren vroeger iets wat je kocht en daarna bezat: iemand op de straathoek had een stapel, je gaf het geld, en de krant ging met je mee naar huis en was van jou, om te lezen, te vouwen, uit te lenen of de kachel mee aan te maken. Niemand kwam een uur later langs om de tweede pagina eruit te knippen omdat je de advertenties had overgeslagen. De webversie gaf de krant voor niets weg en verkocht daarna de lezer. Eerst aan de adverteerders, dan aan de 1.467 partners, en nu aan een leverancier wiens hele product bestaat uit beslissen of je je goed genoeg hebt gedragen om te houden wat je gegeven is.

Waar ik niet overheen kom, is het stylesheet. Een krant die de server van een advertentiebedrijf laat beslissen of haar eigen opmaak overleeft, heeft haar eigen voorpagina weggegeven. Ze zal het niet geweten hebben, omdat binnen niemand de host blokkeerde, dus de eerste die het merkte was een lezer met een Pi-hole, starend naar een pagina vol kale links, die van een dialoogvenster te horen kreeg dat de fout bij hem lag. Dat was niet zo.

Journalistiek heeft een prijs, en die betaal ik zonder moeite. Wat ik niet doe, is een script laten beslissen wat ik al heb. Die grens wordt getrokken in mijn browser, niet in de hunne.


  1. Pi-hole documentation — “The Pi-hole® is a DNS sinkhole that protects your devices from unwanted content, without installing any client-side software.” ↩︎

  2. Newsquest — About us — “We are the leading local news publisher in the UK with a portfolio of more than 200 brands.” Merken zijn geen domeinen, vandaar het hogere aantal uit de certificaten. ↩︎

  3. Daily Business, 18 december 2024 — “National World, owner of The Scotsman and Yorkshire Post, has reached agreement on a £65.1 million takeover by Irish publisher Media Concierge.” ↩︎

  4. Reach plc — About us, gearchiveerd op 5 september 2026 — “120+ brands, from household names like the Mirror, Express, Daily Record and Daily Star, to local titles like MyLondon, BelfastLive and the Manchester Evening News”. ↩︎

  5. Dimova et al., “The CNAME of the Game: Large-scale Analysis of DNS-based Tracking Evasion”, PETS 2021 — CNAME-cloaking “effectively bypasses antitracking measures that rely on fixed hostname-based block lists.” ↩︎

  6. uBlockOrigin/uAssets, issue #30988, door de beheerders onder het label “Ad-Shield” geplaatst, met de melding geserveerd vanaf error-report.com: “Failed to load website properly since html-load.com is blocked.” Zie ook Jacob Desforges, “Ad-Shield ad reinsertion”, 12 april 2026. De toeschrijving komt van de filterlijstgemeenschap; de leverancier maakt niets bekend. ↩︎

  7. Sourcepoint — Anti-adblock FAQs, gearchiveerd op 25 mei 2022 — “Historical experience shows on average about 30% of messaged users will turn off their adblockers.” Dezelfde pagina vraagt of “a CNAME applied to a 1st-party subdomain” zou voorkomen dat het detectiescript geblokkeerd wordt. ↩︎

  8. AdExchanger, 16 augustus 2023 — “InMobi acquired Quantcast’s consent management platform, called Quantcast Choice”. ↩︎

  9. UK GDPR, Article 7 — “utmost account shall be taken of whether, inter alia, the performance of a contract, including the provision of a service, is conditional on consent”. Overweging 42: toestemming is niet vrij gegeven “if the data subject has no genuine or free choice”. ↩︎

  10. ICO — Consent or pay, gepubliceerd op 23 januari 2025 — ““Consent or pay” models can be compliant with data protection law if you can demonstrate that people can freely give their consent”. De pagina zegt nu dat de richtsnoeren worden herzien na de Data (Use and Access) Act. ↩︎

  11. EDPB Opinion 08/2024, aangenomen op 17 april 2024 — “In most cases, it will not be possible for large online platforms to comply with the requirements for valid consent if they confront users only with a binary choice”. Het gaat over grote onlineplatforms, niet over regionale kranten. ↩︎

  12. Chrome for Developers — content_scripts manifest key — “Choosing the “MAIN” world means the script will share the execution environment with the host page’s JavaScript.” ↩︎

  13. ECMAScript — CreateGlobalVarBinding — “If a binding already exists, it is reused and assumed to be initialized.” ↩︎

  14. Chrome for Developers — declarativeNetRequest — “provides implicit access to allow , allowAllRequests and block rules”, en anders “you must request host permissions before you can perform any action on a host.” ↩︎

  15. Chrome for Developers — declarativeNetRequest — “Errors and warnings about invalid static rules are only displayed for unpacked extensions. Invalid static rules in packed extensions are ignored.” ↩︎

  16. HTML Standard — the Location interface markeert zijn leden als [LegacyUnforgeable], wat in Web IDL betekent: “the property will be non-configurable and will exist as an own property on the object itself”. ↩︎

  17. IAB Tech Lab — TCF v2 CMP API — “Every consent manager MUST provide the following API function: __tcfapi(command, version, callback, parameter)”. ↩︎

  18. PECR, regulation 6 — “a person must not store information, or gain access to information stored, in the terminal equipment of a subscriber or user”, met toestemming als voorwaarde in Schedule A1, gewijzigd door de Data (Use and Access) Act 2025. ↩︎

  19. HTML Standard — update the image data — draait “whenever that element is created or has experienced relevant mutations”, ook wanneer de src wordt gezet; in het document staan is geen voorwaarde. ↩︎