mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
351 lines
14 KiB
TypeScript
351 lines
14 KiB
TypeScript
// Safe HTML shaping for email bodies rendered inside a sandboxed WebView.
|
|
//
|
|
// Layered defenses (in order of importance):
|
|
// 1. The WebView runs the content with `originWhitelist={['about:*']}` and a
|
|
// strict Content-Security-Policy meta (no script-src, no default-src),
|
|
// so inline <script>, inline event handlers, and external resources are
|
|
// blocked by the engine itself. This is the primary boundary.
|
|
// 2. `onShouldStartLoadWithRequest` refuses any navigation except the
|
|
// initial about:blank doc - links are opened in the OS browser.
|
|
// 3. This module pre-strips known-dangerous tags and on* attributes so the
|
|
// rendered DOM is also clean. Regex stripping is imperfect; it is here
|
|
// as belt-and-suspenders, not the primary defense.
|
|
//
|
|
// Tag/attribute lists mirror `lib/email-sanitization.ts` from the webmail.
|
|
|
|
const FORBIDDEN_TAGS = [
|
|
'script', 'iframe', 'object', 'embed', 'form',
|
|
'input', 'button', 'textarea', 'select', 'option',
|
|
'meta', 'link', 'base', 'style', 'svg', 'math',
|
|
'frame', 'frameset', 'applet', 'portal',
|
|
// Mutation-XSS / parser-quirk vectors. `noscript` flips parsing modes when
|
|
// JS is enabled, `xmp`/`plaintext`/`listing` switch the parser into raw-text
|
|
// mode and can stash code that re-emerges after later transforms. Drop them
|
|
// wholesale rather than try to reason about their interaction with the
|
|
// strip pipeline below.
|
|
'noscript', 'noembed', 'noframes', 'xmp', 'plaintext', 'listing',
|
|
'template', 'slot', 'shadow',
|
|
];
|
|
|
|
// HTML comments can hide otherwise-stripped tags and survive certain DOM
|
|
// transforms (especially conditional comments, which old IE parsed as code).
|
|
// We're not running IE here, but emails are routed through many sanitizers
|
|
// before reaching us — strip comments too so nothing rides along inside them.
|
|
function stripHtmlComments(html: string): string {
|
|
return html.replace(/<!--[\s\S]*?-->/g, '');
|
|
}
|
|
|
|
const HTML_ESCAPES: Record<string, string> = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": ''',
|
|
};
|
|
|
|
export function escapeHtml(str: string): string {
|
|
return str.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]!);
|
|
}
|
|
|
|
export function stripDangerousTags(html: string): string {
|
|
// Strip comments first so they can't hide forbidden tags from the loops below.
|
|
let out = stripHtmlComments(html);
|
|
|
|
// Remove entire forbidden tag blocks (paired). Non-greedy across lines.
|
|
// Loop-until-stable so nested forbidden tags (e.g. `<style><script>...`)
|
|
// collapse fully — one pass would leave the inner one behind.
|
|
let prev: string;
|
|
do {
|
|
prev = out;
|
|
for (const tag of FORBIDDEN_TAGS) {
|
|
const paired = new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}\\s*>`, 'gi');
|
|
out = out.replace(paired, '');
|
|
const selfClosing = new RegExp(`<${tag}\\b[^>]*\\/?>`, 'gi');
|
|
out = out.replace(selfClosing, '');
|
|
const closing = new RegExp(`<\\/${tag}\\s*>`, 'gi');
|
|
out = out.replace(closing, '');
|
|
}
|
|
} while (out !== prev);
|
|
|
|
// Strip inline event handlers — both `on*` and the rarer `formmethod`,
|
|
// `srcdoc`, `ping` attribute families that have a side-channel.
|
|
const dangerousAttrs = '(?:on[a-z]+|srcdoc|ping|formmethod|formenctype|formtarget|form)';
|
|
out = out.replace(new RegExp(`\\s+${dangerousAttrs}\\s*=\\s*"[^"]*"`, 'gi'), '');
|
|
out = out.replace(new RegExp(`\\s+${dangerousAttrs}\\s*=\\s*'[^']*'`, 'gi'), '');
|
|
out = out.replace(new RegExp(`\\s+${dangerousAttrs}\\s*=\\s*[^\\s>]+`, 'gi'), '');
|
|
|
|
// Neutralize dangerous URI schemes. Allow `data:image/*` and `cid:`.
|
|
out = out.replace(
|
|
/(\b(?:href|src|action|xlink:href|formaction|poster|background)\s*=\s*)(")([^"]*)(")/gi,
|
|
(_m, pre, q1, value, q2) => `${pre}${q1}${safeUri(value)}${q2}`,
|
|
);
|
|
out = out.replace(
|
|
/(\b(?:href|src|action|xlink:href|formaction|poster|background)\s*=\s*)(')([^']*)(')/gi,
|
|
(_m, pre, q1, value, q2) => `${pre}${q1}${safeUri(value)}${q2}`,
|
|
);
|
|
|
|
return out;
|
|
}
|
|
|
|
function safeUri(raw: string): string {
|
|
// Decode HTML entities and strip control chars before scheme matching:
|
|
// `javascript:` and `java\nscript:` are the two classic bypass shapes
|
|
// that survive a naive prefix check.
|
|
const decoded = raw
|
|
.replace(/&#x([0-9a-f]+);?/gi, (_m, hex: string) => String.fromCharCode(parseInt(hex, 16)))
|
|
.replace(/&#(\d+);?/g, (_m, dec: string) => String.fromCharCode(parseInt(dec, 10)))
|
|
.replace(/&(?:amp|colon|tab|newline);/gi, (m) =>
|
|
m.toLowerCase() === '&' ? '&' : m.toLowerCase() === ':' ? ':' : '',
|
|
)
|
|
.replace(/[ |