/** * ╔════╗─────────────╔╗───────────────╔╗──────╔════╗╔═══╗╔═══╗╔═╗─╔╗╔═══╗──╔═══╗╔═══╗╔═══╗ * ║╔╗╔╗║─────────────║║───────────────║║──────║╔╗╔╗║║╔═╗║║╔═╗║║║╚╗║║║╔═╗║──║╔═╗║║╔═╗║║╔═╗║ * ╚╝║║╚╝╔╗─╔╗╔══╗╔══╗║║───╔══╗╔══╗╔══╗║╚═╦╗─╔╗╚╝║║╚╝║╚═╝║║║─║║║╔╗╚╝║║║─╚╝──║╚═╝║║╚═╝║║║─║║ * ──║║──║║─║║║╔╗║║║═╣║║─╔╗║║═╣║══╣║══╣║╔╗║║─║║──║║──║╔╗╔╝║║─║║║║╚╗║║║║╔═╗──║╔══╝║╔╗╔╝║║─║║ * ──║║──║╚═╝║║╚╝║║║═╣║╚═╝║║║═╣╠══║╠══║║╚╝║╚═╝║──║║──║║║╚╗║╚═╝║║║─║║║║╚╩═║╔╗║║───║║║╚╗║╚═╝║ * ──╚╝──╚═╗╔╝║╔═╝╚══╝╚═══╝╚══╝╚══╝╚══╝╚══╩═╗╔╝──╚╝──╚╝╚═╝╚═══╝╚╝─╚═╝╚═══╝╚╝╚╝───╚╝╚═╝╚═══╝ * ──────╔═╝║─║║──────────────────────────╔═╝║ * ──────╚══╝─╚╝──────────────────────────╚══╝ * * TypeLess - Auto Form Filler * dom-utils.js — Safe DOM rendering + Trusted Types API policy * * SECURITY ARCHITECTURE * ───────────────────── * This module provides two layers of DOM XSS defense: * * Layer 1 – Trusted Types Policy (Chrome 83+) * Registers a "typeless#html" TrustedTypePolicy. Any innerHTML assignment * that does NOT go through this policy is blocked by the browser at runtime. * The policy's createHTML() function is the SINGLE chokepoint where all * markup is validated before injection. * * Layer 2 – Safe DOM builder (all browsers) * `DOMBuilder` creates elements imperatively via document.createElement / * textContent / setAttribute — zero innerHTML for user-controlled data. * Use this for dynamic content built from profile data, field values, etc. * * Usage * ───── * // Trusted HTML from a known-safe template (icons, i18n, static layout): * TrustedHTML.setHTML(element, `save`); * * // Build a card from user data — never use innerHTML for this: * const card = DOMBuilder.el('div', { className: 'profile-card' }, [ * DOMBuilder.el('span', { textContent: profile.name }), * DOMBuilder.el('button', { className: 'delete-btn', dataset: { id: profile.id } }) * ]); * container.appendChild(card); */ 'use strict'; // ─── Trusted Types Policy ──────────────────────────────────────────────────── /** * ALLOWLIST of safe HTML patterns. * Only tags whose src comes from the extension origin (chrome-extension://) * or a relative path, and whose alt text is plain text, are considered safe. * All other tags are stripped of potentially dangerous attributes. */ const _SAFE_IMG_PATTERN = /^]*src=["'](?:chrome-extension:\/\/[a-z0-9]+\/|icons\/)[a-z0-9._-]+\.svg["'][^>]*>$/i; /** * Create (or retrieve an existing) Trusted Types policy. * Falls back gracefully when Trusted Types is not supported. */ const TrustedHTML = (() => { let _policy = null; // Only one policy may be created per name — guard against double-init. const _getPolicy = () => { if (_policy) return _policy; if (typeof window !== 'undefined' && window.trustedTypes && window.trustedTypes.createPolicy) { try { _policy = window.trustedTypes.createPolicy('typeless#html', { /** * createHTML is the ONLY entry point for innerHTML in the extension. * It validates that the string is a known-safe template: * • Pure text (no tags) * • Extension-origin icon * • Static layout HTML produced by our own template functions * (identified by the __trustedSource sentinel property) * * Anything else throws so the bug surfaces immediately during dev. */ createHTML(input, context) { if (typeof input !== 'string') { throw new TypeError('[TrustedHTML] Input must be a string'); } // Allow strings marked as trusted by internal template functions if (context && context.__trustedSource === 'typeless-internal') { return input; } // Allow plain text (no angle brackets) if (!input.includes('<')) return input; // Allow single extension icon img tags if (_SAFE_IMG_PATTERN.test(input.trim())) return input; // Everything else is rejected throw new Error( `[TrustedHTML] Untrusted HTML rejected. Use DOMBuilder for dynamic content.\n` + `Input (first 120 chars): ${input.substring(0, 120)}` ); } }); } catch (e) { // Policy already exists (happens in content scripts re-injected on SPA nav) _policy = window.trustedTypes.getAttributeType ? null // Can't retrieve existing policy; fall through to no-TT path : null; console.warn('[TrustedHTML] Policy creation skipped:', e.message); } } return _policy; }; return { /** * Safe innerHTML assignment. * - In browsers with Trusted Types: wraps in a TrustedHTML value. * - In all browsers: the `html` argument MUST come from `TrustedHTML.raw()` * or an internal template (not from user data). * * @param {Element} element Target DOM node * @param {string} html Static/trusted HTML string (NO user data) */ setHTML(element, html) { const policy = _getPolicy(); if (policy) { element.innerHTML = policy.createHTML(html, { __trustedSource: 'typeless-internal' }); } else { // Trusted Types unavailable — assign directly. // This path still requires the caller to only pass safe strings. element.innerHTML = html; } }, /** * Escape user-supplied text for safe interpolation into HTML attribute values. * Use this when you must build an HTML string that includes user data * in an attribute position (e.g. title, placeholder). */ escapeAttr(text) { return String(text ?? '') .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, ''') .replace(//g, '>'); }, /** * Escape user-supplied text for safe interpolation into HTML text nodes. */ escapeText(text) { const div = document.createElement('div'); div.textContent = String(text ?? ''); return div.innerHTML; } }; })(); // ─── Safe DOM Builder ──────────────────────────────────────────────────────── /** * Imperatively build DOM trees from plain data — no innerHTML required. * * @example * const btn = DOMBuilder.el('button', { * className: 'delete-btn', * textContent: 'Delete', * dataset: { profileId: profile.id }, * on: { click: handleDelete } * }); */ const DOMBuilder = { /** * Create a DOM element with optional properties and children. * * @param {string} tag Element tag name * @param {Object} [props] Properties to assign: * - Standard props: className, id, type, src, href, title, placeholder, value, checked, disabled, style, tabIndex, draggable, spellcheck, autocomplete * - textContent: safe text (auto-escaped by the DOM) * - dataset: { key: value } → element.dataset.key = value * - style: { prop: value } object or CSS string * - on: { eventName: handler } → addEventListener * - attr: { name: value } → setAttribute (for non-standard attributes like data-i18n) * @param {Array} [children] Child elements or text strings * @returns {Element} */ el(tag, props = {}, children = []) { const el = document.createElement(tag); for (const [key, val] of Object.entries(props)) { if (val === undefined || val === null) continue; switch (key) { case 'textContent': el.textContent = val; // XSS-safe: browser treats as text node break; case 'dataset': Object.entries(val).forEach(([k, v]) => { el.dataset[k] = v; }); break; case 'style': if (typeof val === 'string') { el.style.cssText = val; } else { Object.assign(el.style, val); } break; case 'on': Object.entries(val).forEach(([evt, fn]) => el.addEventListener(evt, fn)); break; case 'attr': Object.entries(val).forEach(([k, v]) => el.setAttribute(k, v)); break; case 'className': el.className = val; break; default: // Standard reflected IDL properties (type, src, href, title, etc.) try { el[key] = val; } catch (_) { el.setAttribute(key, val); } } } for (const child of children) { if (child === null || child === undefined) continue; if (typeof child === 'string') { el.appendChild(document.createTextNode(child)); } else if (child instanceof Node) { el.appendChild(child); } } return el; }, /** * Create a text node. Convenience wrapper for document.createTextNode. */ text(str) { return document.createTextNode(String(str ?? '')); }, /** * Create an extension icon element safely. * src is validated to be an extension-origin SVG icon path. */ icon(iconName, opts = {}) { let src; try { src = chrome.runtime.getURL(`icons/${iconName}.svg`); } catch (_) { src = `icons/${iconName}.svg`; } return this.el('img', { src, alt: opts.alt || `[${iconName}]`, style: { width: opts.size || '1em', height: opts.size || '1em', verticalAlign: '-0.15em', display: 'inline-block', ...(opts.style || {}) } }); }, /** * Append multiple children to a container element. Returns container. */ append(container, ...children) { children.flat().forEach(child => { if (!child) return; if (typeof child === 'string') container.appendChild(document.createTextNode(child)); else container.appendChild(child); }); return container; }, /** * Empty a container and replace its contents. */ replace(container, ...children) { container.textContent = ''; // removes all children safely return this.append(container, ...children); } }; // ─── Exports ───────────────────────────────────────────────────────────────── if (typeof window !== 'undefined') { window.TrustedHTML = TrustedHTML; window.DOMBuilder = DOMBuilder; } if (typeof globalThis !== 'undefined') { globalThis.TrustedHTML = TrustedHTML; globalThis.DOMBuilder = DOMBuilder; }