Script Blocking

How Zest blocks tracking scripts until user consent

How It Works

Zest uses a sophisticated script blocking system that:

  1. Monitors the DOM using MutationObserver to detect new scripts
  2. Checks each script against blocking rules based on the configured mode
  3. Blocks matching scripts by changing their type to text/plain
  4. Queues blocked scripts for later execution
  5. Replays scripts when the user grants consent

Blocking Modes

Manual Mode

Only blocks scripts explicitly tagged with data-consent-category:

<!-- This will be blocked -->
<script data-consent-category="analytics" src="https://example.com/tracker.js"></script>

<!-- This will NOT be blocked -->
<script src="https://www.googletagmanager.com/gtag/js"></script>

Safe Mode (Default)

Blocks manually tagged scripts plus known major trackers:

Blocked Analytics:

  • google-analytics.com
  • analytics.google.com
  • googletagmanager.com
  • plausible.io
  • cloudflareinsights.com

Blocked Marketing:

  • connect.facebook.net
  • ads.google.com
  • googleads.g.doubleclick.net
  • pagead2.googlesyndication.com

Strict Mode

Everything in Safe mode plus extended tracking services:

  • Hotjar, Microsoft Clarity, Heap
  • Mixpanel, Segment, FullStory, Amplitude
  • LinkedIn, Twitter/X, TikTok, Snapchat, Pinterest
  • Bing, Yahoo, Amazon, Criteo, Taboola, Outbrain

Doomsday Mode

Blocks all third-party scripts. Use with caution—this may break legitimate functionality.

Tagging Scripts

Basic Tagging

Use data-consent-category to specify which consent category a script requires:

<!-- Requires analytics consent -->
<script data-consent-category="analytics" src="https://example.com/analytics.js"></script>

<!-- Requires marketing consent -->
<script data-consent-category="marketing" src="https://example.com/pixel.js"></script>

<!-- Inline scripts work too -->
<script data-consent-category="analytics">
  console.log('This runs after analytics consent');
</script>

Categories

Category Purpose
essential Always allowed (cannot be blocked)
functional Site personalization features
analytics Usage tracking and statistics
marketing Advertising and remarketing

Security note. Since v2.0.0, a script self-labeling as data-consent-category="essential" is ignored — the only valid self-labels are functional, analytics, and marketing. This prevents third-party scripts from escaping the blocker by declaring themselves essential. Categories assigned by the blocker's mode (e.g. known-tracker hostname) always take precedence over self-labels.

Allow-listing Scripts

Prevent a script from being blocked (even in strict/doomsday mode):

<script data-zest-allow src="https://trusted-cdn.com/library.js"></script>

Custom Blocked Domains

Add your own domains to the blocklist:

window.ZestConfig = {
  mode: 'safe',
  blockedDomains: [
    // Simple domain (defaults to marketing category)
    'custom-tracker.com',

    // With explicit category
    { domain: 'my-analytics.com', category: 'analytics' },
    { domain: 'my-cdn.com', category: 'functional' }
  ]
};

Element Interception (v2.3.0+)

MutationObserver-based script blocking is asynchronous — it fires a microtask after the DOM mutation, so by the time we react, the browser has already kicked off the network request. The script may not execute (we flip type to text/plain), but the fetch already happened, and to a privacy auditor that fetch IS a pre-consent leak.

Zest now also installs synchronous prototype-setter patches that catch tracker elements BEFORE the browser fetches them:

Patched Catches
HTMLScriptElement.src setter script.src = "..."
HTMLLinkElement.href setter link.href = "..." (stylesheets, preload, prefetch)
HTMLImageElement.src setter img.src = "..." (tracking pixels)
HTMLIFrameElement.src setter iframe.src = "..." (tracking iframes)
Element.prototype.setAttribute el.setAttribute("src", "...") for the same four element types
window.Image constructor new Image() followed by .src = "..."

When code does:

const s = document.createElement('script');
s.src = 'https://tracker.example/track.js';   // ← intercepted HERE,
                                              //   URL never lands on the element
document.head.appendChild(s);                 // ← appendChild fires nothing

The URL is queued with its category. When consent arrives, replayElements() re-applies the URL via the ORIGINAL setter for every element that's still in the DOM, so previously-blocked scripts / stylesheets / images execute without a page reload.

What this does NOT catch

Inline HTML <script src="..."> and <link rel="stylesheet" href="..."> tags parsed from the original document response. The browser starts fetching those during HTML parsing, BEFORE any JavaScript runs. The only complete fix for that class is server-side CSP or template-time removal (e.g. self-host Google Fonts instead of using fonts.googleapis.com).

Element interception is gated by the same intercept.scripts toggle as the MutationObserver-based blocker — they're both element-level defences and ship together.

Network Interception (v2.3.0+)

Modern CMSes (HubSpot, Cloudflare Zaraz, server-side GTM, Shopify, Webflow) increasingly proxy tracker scripts through the site's own origin to defeat ad-blockers. The <script> tag itself is first-party (e.g. /hs/scriptloader/{id}.js), so a hostname-based script blocker cannot match it. But at runtime that script still phones home to the vendor's analytics endpoint via fetch, XMLHttpRequest, or navigator.sendBeacon — and THAT URL is third-party.

Zest's network interceptor closes that gap. It patches the three network APIs and matches every outbound URL against the same blockedDomains + mode-based tracker list that the script blocker uses.

What gets blocked

Blocked requests are dropped rather than queued for replay (network calls are one-shot and time-sensitive; replaying a stale beacon after consent would create duplicate data). Each API fails cleanly in a way trackers handle gracefully:

API Blocked behavior
fetch(url) Resolves to Response with status 204 and empty body
XMLHttpRequest Fires error event with readyState=4, status=0
navigator.sendBeacon(url, data) Returns false (spec's "not queued" signal)

Example: HubSpot CMS

Zest.init({
  blockedDomains: [
    { domain: 'track-eu1.hubspot.com', category: 'analytics' },
    { domain: 'app-eu1.hubspot.com', category: 'analytics' }
  ]
});

HubSpot's scriptloader at /hs/scriptloader/{portalId}.js still loads (first-party path, not blockable by hostname) — but its runtime beacons to track-eu1.hubspot.com are now caught. No tracking data leaves the page before consent.

Opting out

Headless integrations that gate their own network calls can disable the interceptor:

Zest.init({
  intercept: { network: false }
});

Zest also intercepts cookie and storage operations:

How It Works

  1. Intercepts document.cookie setter operations
  2. Proxies localStorage.setItem() and sessionStorage.setItem()
  3. Categorizes operations based on key name patterns
  4. Queues blocked operations
  5. Replays when consent is granted

Automatic Categorization

Keys are categorized by matching patterns:

Analytics patterns:

_ga, _gid, _gat, _utm, __utm, plausible, _pk_, matomo, _hj, ajs_

Marketing patterns:

_fbp, _fbc, _gcl, _ttp, ads, doubleclick, __gads, __gpi, _pin_, li_

Functional patterns:

lang, locale, theme, preferences, ui_

Essential patterns (always allowed):

zest_, csrf, xsrf, session, __host-, __secure-

Unknown keys default to the marketing category (strictest).

Custom Patterns

Add your own categorization patterns:

window.ZestConfig = {
  patterns: {
    analytics: [/^my_analytics_/, /^custom_ga/],
    marketing: [/^my_ad_/, /^campaign_/],
    functional: [/^user_pref_/]
  }
};

Script Execution Order

When consent is granted:

  1. External scripts are loaded in their original order
  2. Inline scripts execute after their dependencies load
  3. The zest:consent event fires after all scripts execute

Debugging

Inspect the current consent state and which categories have been granted:

// In browser console
console.log(Zest.getConsent());
// { essential: true, functional: false, analytics: false, marketing: false }

console.log(Zest.getConsentProof());
// { version, timestamp, categories }

Subscribe to real-time changes as you test:

Zest.on('consent', (consent) => console.log('consented:', consent));
Zest.on('change',  (consent) => console.log('changed:', consent));

Changed in v2.0.0. Zest no longer writes the data-blocked-src attribute to blocked <script> tags (it was a DOM-tampering vector). If your previous debug snippet inspected that attribute, use Zest.getConsentProof() or Zest.on('consent', …) instead.

Best Practices

  1. Load Zest first - Before any tracking scripts in your HTML
  2. Use safe mode - Good balance of protection and compatibility
  3. Tag unknown scripts - Use data-consent-category for scripts Zest doesn't recognize
  4. Test thoroughly - Check that your site works after consent is granted
  5. Allow essential scripts - Use data-zest-allow for critical third-party libraries