# Script Blocking

> How Zest blocks tracking scripts until user consent

Source: https://zest.freshjuice.dev/docs/script-blocking/

## 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`:

```html
<!-- 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:

```html
<!-- 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):

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

## Custom Blocked Domains

Add your own domains to the blocklist:

```javascript
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:

```javascript
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

```javascript
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:

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

## Cookie & Storage Interception

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:

```javascript
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:

```javascript
// 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:

```javascript
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

