/**
* ╔════╗─────────────╔╗───────────────╔╗──────╔════╗╔═══╗╔═══╗╔═╗─╔╗╔═══╗──╔═══╗╔═══╗╔═══╗
* ║╔╗╔╗║─────────────║║───────────────║║──────║╔╗╔╗║║╔═╗║║╔═╗║║║╚╗║║║╔═╗║──║╔═╗║║╔═╗║║╔═╗║
* ╚╝║║╚╝╔╗─╔╗╔══╗╔══╗║║───╔══╗╔══╗╔══╗║╚═╦╗─╔╗╚╝║║╚╝║╚═╝║║║─║║║╔╗╚╝║║║─╚╝──║╚═╝║║╚═╝║║║─║║
* ──║║──║║─║║║╔╗║║║═╣║║─╔╗║║═╣║══╣║══╣║╔╗║║─║║──║║──║╔╗╔╝║║─║║║║╚╗║║║║╔═╗──║╔══╝║╔╗╔╝║║─║║
* ──║║──║╚═╝║║╚╝║║║═╣║╚═╝║║║═╣╠══║╠══║║╚╝║╚═╝║──║║──║║║╚╗║╚═╝║║║─║║║║╚╩═║╔╗║║───║║║╚╗║╚═╝║
* ──╚╝──╚═╗╔╝║╔═╝╚══╝╚═══╝╚══╝╚══╝╚══╝╚══╩═╗╔╝──╚╝──╚╝╚═╝╚═══╝╚╝─╚═╝╚═══╝╚╝╚╝───╚╝╚═╝╚═══╝
* ──────╔═╝║─║║──────────────────────────╔═╝║
* ──────╚══╝─╚╝──────────────────────────╚══╝
*
* 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, `
`);
*
* // 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;
}