Version 2.0.0

Headless entry for bring-your-own-UI, new Zest.on/once subscribers, and a defense-in-depth security hardening pass

Zest reaches v2.0.0 with a new headless entry for consumers who want to bring their own UI, new event-subscriber helpers, and a comprehensive security hardening pass. See the full release on GitHub.

Breaking changes ahead. If you don't reassign window.Zest or read data-blocked-src directly, you're fine. See the Breaking section below.

Highlights

Headless Entry

A brand new entry point that ships the consent engine without any UI, translations, or auto-init — ~11 KB gzipped. Ideal if you want to render your own banner/modal with your own CSS.

import Zest from '@freshjuice/zest/headless';

Zest.init({
  mode: 'safe',
  callbacks: {
    onAccept: (consent) => console.log('Accepted:', consent)
  }
});

Zest.on('consent', (consent) => {
  if (consent.analytics) initAnalytics();
});

The headless build:

  • Does not auto-initialize — you call Zest.init(config) explicitly
  • Does not define window.Zest
  • Contains no Shadow DOM UI and no translations
  • Adds Zest.updateConsent(selections) for saving arbitrary category selections from your custom UI

New Subscriber API

Both entries now ship Zest.on() and Zest.once() for cleaner event handling:

// Subscribe to events
const unsubscribe = Zest.on('consent', (consent) => {
  console.log('User consented to:', consent);
});

// Fire once and auto-unsubscribe
Zest.once('ready', (consent) => {
  console.log('Zest is ready with:', consent);
});

// Reference event names as constants
Zest.on(Zest.EVENTS.CHANGE, handleChange);

Security Hardening

Every config value that ends up in HTML, CSS, URLs, JSON, or a regex now passes through a dedicated validator/sanitizer.

High severity

  • XSS via innerHTML in banner, modal, and widget — all interpolated config values (labels, category names, aria-labels) now pass through escapeHTML() before rendering. The banner position class is additionally validated against an allowlist.
  • javascript: URL via policyUrl — now validated with safeUrl() (allowlist: http: / https: / mailto: / tel: / relative). Link rel upgraded to noopener noreferrer.
  • Arbitrary CSS injection via customStyles — sanitized by stripping @import, @charset, expression(), -moz-binding, external url() values, and any selectors targeting the accept/reject buttons (prevents clickjacking via invisible-button attacks). 20 KB hard cap.

Medium severity

  • ReDoS in user-supplied regex patternssetPatterns() now routes through safeRegExp() which rejects catastrophic-backtracking shapes and caps pattern length at 500 characters.
  • DOM-based script-source tamperingreplayScripts() no longer re-reads data-blocked-src from the DOM. The internal script queue is now the single source of truth, snapshotted before any DOM mutation.
  • Consent cookie on HTTPS now carries the Secure flag.
  • Cookie JSON schema validation — parsed cookies are sanitized via sanitizeConsentPayload() (allowlisted category keys, forced boolean values, prototype-pollution safe).
  • Overly broad tracker URL matching — tracker matching is now restricted to hostname (and path prefix for entries containing a slash). The old substring fallback caused false positives on URLs with the domain in query params.
  • CSS injection via accentColor — validated with safeColor() (hex / named / rgb() / rgba() / hsl() / hsla()).

Low severity

  • window.Zest is now defined with writable: false, configurable: false and the API object is frozen.
  • All user-supplied callbacks are invoked through a safe wrapper — exceptions are logged and swallowed so the consent flow stays consistent.
  • Cookie / localStorage / sessionStorage / script replay queues are size-capped (100 / 200 / 200 / 500 entries) to mitigate memory DoS.
  • Cookie-interceptor descriptor installed with configurable: false so a later-loaded script cannot re-override document.cookie.
  • Script self-labeling as data-consent-category="essential" is now ignored — only functional, analytics, marketing self-labels are honored, and mode-assigned categories take precedence.

Breaking

  • data-blocked-src DOM attribute is no longer written to blocked <script> tags. If you were reading this attribute for debugging, switch to Zest.getConsentProof() or subscribe via Zest.on('consent', …).
  • window.Zest is now locked (writable: false, configurable: false). Code that replaced or monkey-patched the global will now fail silently. Note: window.ZestConfig is not locked — the config surface is unchanged.

Changed

  • src/index.js refactored to delegate non-UI work to a shared core-lifecycle.js module. Public API and behavior unchanged.

Fixed

  • Accent-color CSS now falls back cleanly to the default when a non-hex form (named color, rgb(), etc.) is supplied that can't be mathematically brightness-shifted.

Install

npm install @freshjuice/zest
// Full build (banner + modal + widget, auto-init)
import '@freshjuice/zest';

// Headless (BYO UI, manual init)
import Zest from '@freshjuice/zest/headless';
Zest.init({ /* your config */ });

Or via CDN:

<script src="https://unpkg.com/@freshjuice/zest@2"></script>