PageSourceSearch

https://smrtbld.ai/js/utils/html-sanitizer.js

js smrtbld.ai collected 2026-10-05 15:08:39 UTC 10,960 bytes, 265 lines download raw bytes

1/**
2 * HTML sanitization + rendering-quality utilities for email and messaging content.
3 *
4 * Three strategies (don't conflate them):
5 *  - sanitizeEmailHtml:        SECURITY — strip dangerous tags / attrs / URIs
6 *  - cleanupEmailRenderingHtml: QUALITY — fix nested tables, cid: images, fixed
7 *                               widths, oversized images. Ported from legacy
8 *                               messaging-helpers.js#fixReplyBodyHTML.
9 *  - sanitizeMessageHtml:      Allowlist for chat (Teams) — stricter than email
10 *
11 * For an inbound email body the pipeline is sanitizeEmail → cleanupEmail → render.
12 * One does security, the other does presentation. Don't merge them — they have
13 * different reasons to change and different fail modes.
14 */
15
16// Browsers discard ASCII whitespace/control characters when recognizing a URL
17// scheme. Check that normalized candidate without rewriting legitimate URLs.
18function isUnsafeHtmlUri(value) {
19    const candidate = String(value ?? '').replace(/[\u0000-\u0020\u007f-\u009f]/g, '');
20    return /^(javascript|vbscript|data):/i.test(candidate);
21}
22
23/**
24 * Sanitize HTML email body — strip dangerous elements and attributes.
25 * Used for Gmail and Outlook mail content before rendering.
26 * @param {string} html - raw HTML from email provider
27 * @returns {string} sanitized HTML safe for innerHTML
28 */
29export function sanitizeEmailHtml(html) {
30    if (!html) return '';
31
32    // Extract <body> content if full HTML document
33    const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body/i);
34    let content = bodyMatch ? bodyMatch[1] : html;
35
36    const temp = document.createElement('div');
37    temp.innerHTML = content;
38
39    // Remove dangerous elements
40    temp.querySelectorAll('script, style, link, iframe, object, embed, form, base, meta, svg, math, template')
41        .forEach(el => el.remove());
42
43    // Remove event handlers and dangerous URIs
44    temp.querySelectorAll('*').forEach(el => {
45        Array.from(el.attributes).forEach(attr => {
46            if (attr.name.startsWith('on') || attr.name === 'srcdoc') {
47                el.removeAttribute(attr.name);
48            }
49            if (['href', 'src', 'xlink:href', 'action', 'formaction', 'background', 'poster'].includes(attr.name) &&
50                isUnsafeHtmlUri(attr.value)) {
51                el.removeAttribute(attr.name);
52            }
53        });
54    });
55
56    return temp.innerHTML;
57}
58
59/**
60 * Strip HTML from a snippet of text — for safe preview/snippet rendering with
61 * collapsed whitespace. Ported verbatim from legacy messaging-helpers.js so
62 * truncated bodies (BodyShort, snippet, bodyPreview) can't smuggle broken or
63 * malicious HTML into the list cards.
64 *
65 * Handles three categories that escapeHtml misses:
66 *  - Complete tags (removed)
67 *  - Orphaned opening tag at end of truncated string (removed)
68 *  - Orphaned closing > at start (removed when not paired)
69 *
70 * Then decodes entities via a textarea and collapses whitespace.
71 *
72 * @param {string|null|undefined} text
73 * @returns {string} plain-text snippet, whitespace-collapsed
74 */
75export function stripHtml(text) {
76    if (text === null || text === undefined) return '';
77    let s = String(text);
78    s = s.replace(/<!--[\s\S]*?-->/g, '');     // comments
79    s = s.replace(/<[^>]*>/g, '');             // complete tags
80    s = s.replace(/<\/?[a-zA-Z][^<]*$/g, '');  // orphaned opening tag at end
81    s = s.replace(/^[^>]*>/, (m) => (m.indexOf('<') === -1 ? '' : m));
82    const ta = document.createElement('textarea');
83    ta.innerHTML = s;
84    return ta.value.replace(/\s+/g, ' ').trim();
85}
86
87/**
88 * RENDERING-QUALITY cleanup for an HTML email body. Distinct from
89 * sanitizeEmailHtml (security) — this fixes the rendering problems that come
90 * from wild-west outbound email HTML: nested tbodies, fixed-pixel widths,
91 * orphaned cid: image references, oversized inline images, non-wrapping <pre>.
92 *
93 * Run AFTER sanitizeEmailHtml — once it's safe to insert, we make it look
94 * decent. Ported from legacy messaging-helpers.js#fixReplyBodyHTML.
95 *
96 * Idempotent — re-running it on already-cleaned HTML is a no-op.
97 *
98 * @param {string} html - sanitized HTML body
99 * @returns {string} cleaned HTML, ready to insert
100 */
101export function cleanupEmailRenderingHtml(html) {
102    if (!html) return '';
103
104    try {
105        const parser = new DOMParser();
106        const doc = parser.parseFromString(html, 'text/html');
107
108        // 1. Merge multiple <tbody> children inside the same table — invalid HTML
109        //    that Outlook etc. produce; browsers render the rows but layout breaks.
110        doc.querySelectorAll('table').forEach((table) => {
111            const tbodies = Array.from(table.children).filter((c) => c.tagName === 'TBODY');
112            if (tbodies.length > 1) {
113                const primary = tbodies[0];
114                for (let i = 1; i < tbodies.length; i++) {
115                    const extra = tbodies[i];
116                    Array.from(extra.querySelectorAll('tr')).forEac
116h((row) => primary.appendChild(row));
117                    extra.remove();
118                }
119            }
120        });
121
122        // 2. Force tables to fit the column they render in.
123        doc.querySelectorAll('table').forEach((table) => {
124            table.style.width = '100%';
125            table.style.maxWidth = '100%';
126            table.style.tableLayout = 'fixed';
127        });
128
129        // 3. Strip cid: image refs — these point at MIME parts we can't resolve
130        //    client-side. Better to show an empty box than a broken-image icon.
131        doc.querySelectorAll('img[src^="cid:"]').forEach((img) => {
132            img.removeAttribute('src');
133        });
134
135        // 4. Make <pre> wrap so signature monospace blocks don't blow the layout.
136        doc.querySelectorAll('pre').forEach((pre) => {
137            pre.style.whiteSpace = 'pre-wrap';
138            pre.style.wordBreak = 'break-all';
139        });
140
141        // 5. Image sizing — convert width/height attrs to inline styles, intelligently:
142        //    - Preserve specific pixel HEIGHT (logos sized intentionally).
143        //    - Specific pixel WIDTH → cap with max-width:100% so it scales down.
144        //    - No specific width → just max-width:100%, leave height auto.
145        doc.querySelectorAll('img').forEach((img) => {
146            const widthAttr = img.getAttribute('width');
147            const heightAttr = img.getAttribute('height');
148
149            if (heightAttr && !heightAttr.includes('%')) {
150                const height = parseInt(heightAttr, 10);
151                if (!isNaN(height) && height > 0) {
152                    img.style.height = `${height}px`;
153                    img.removeAttribute('height');
154                }
155            }
156
157            if (widthAttr && !widthAttr.includes('%')) {
158                const width = parseInt(widthAttr, 10);
159                if (!isNaN(width) && width > 0) {
160                    img.style.width = `${width}px`;
161                    img.style.maxWidth = '100%';
162                    if (!heightAttr) img.style.height = 'auto';
163                    img.removeAttribute('width');
164                    img.removeAttribute('height');
165                }
166            } else {
167                img.style.maxWidth = '100%';
168                if (!heightAttr) img.style.height = 'auto';
169                img.style.width = '';
170            }
171        });
172
173        // 6. Cap fixed-pixel widths on non-image block elements. These cause
174        //    horizontal scroll on phones when an email was authored for desktop.
175        doc.querySelectorAll('table, td, div, p').forEach((el) => {
176            const widthAttr = el.getAttribute('width');
177            if (widthAttr && !widthAttr.includes('%')) {
178                el.style.maxWidth = '100%';
179                el.style.width = 'auto';
180                el.removeAttribute('width');
181            }
182            if (el.style.maxWidth && el.style.maxWidth.endsWith('px')) {
183                el.style.maxWidth = '100%';
184            }
185        });
186
187        return doc.body.innerHTML;
188    } catch (err) {
189        console.error('cleanupEmailRenderingHtml failed:', err);
190        return html;
191    }
192}
193
194/**
195 * Render stored email HTML through security sanitization before presentation cleanup.
196 * The returned markup is suitable for the intentional JsRender HTML body slot.
197 */
198export function renderEmailHtml(html) {
199    return cleanupEmailRenderingHtml(sanitizeEmailHtml(html));
200}
201
202/**
203 * Sanitize HTML from messaging platforms (Teams, etc.) — allowlist approach.
204 * Only permits a safe subset of tags; everything else becomes text.
205 * @param {string} html - raw HTML from messaging provider
206 * @returns {string} sanitized HTML safe for innerHTML
207 */
208export function sanitizeMessageHtml(html) {
209    if (!html) return '';
210
211    // Inert document: no browsing context, so a disallowed <img onerror> never loads or fires while
212    // it is being sanitized (a live-document div did both) — CA-660.
213    const doc = document.implementation.createHTMLDocument('');
214    const temp = doc.body;
215    temp.innerHTML = html;
216
217    const ALLOWED = new Set([
218        'B', 'I', 'U', 'A', 'BR', 'P', 'UL', 'OL', 'LI',
219        'H1', 'H2', 'H3', 'PRE', 'CODE', 'BLOCKQUOTE',
220        'STRONG', 'EM', 'SPAN', 'DIV'
221    ]);
222
223    function clean(node) {
224        const children = [...node.childNodes];
225        for (const child of children) {
226            if (child.nodeType === Node.TEXT_NODE) continue;
227            if (child.nodeType === Node.ELEMENT_NODE) {
228                if (!ALLOWED.has(child.tagName)) {
229                    const text = doc.createTextNode(child.textContent);
230                    node.replaceChild(text, child);
231                } else {
232                    // Strip dangerous attributes
233                    for (const attr of [...child.attributes]) {
234                        if (attr.name.startsWith('on') || attr.name === 'style') {
235                            child.removeAttribute(attr.name);
236                        }
237                    }
238                    // Keep ordinary links under the same URI rule as email.
239                    if (child.tagName === 'A') {
240                        const href = child.getAttribute('href') || '';
241                        if (isUnsafeHtmlUri(href)) {
242                            child.removeAttribute('href');
243                        }
244                        child.setAttribute('target', '_blank');
245                        child.setAttribute('rel', 'noopener noreferrer');
246                    }
247                    clean(child);
248                }
249            } else {
250                node.removeChild(child);
251            }
252        }
253    }
254
255    clean(temp);
256    return temp.innerHTML;
257}
258
259/**
260 * Legacy-era stored HTML (the RFI Suggestions suffix `<br/><br/><i>…</i>`) through the allowlist above,
261 * never raw — utils/rfi-suggestions.js feeds it only that suffix (CA-660).
262 */
263export function renderLegacyHtml(value) {
264    return sanitizeMessageHtml(value == null ? '' : String(value));
265}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.