mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: drop event handlers that follow a slash and run no inline handlers in the reader and composer
This commit is contained in:
@@ -5,7 +5,7 @@ import { useNavigation } from '@react-navigation/native';
|
|||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { Image as ImageIcon, ShieldCheck } from './icons';
|
import { Image as ImageIcon, ShieldCheck } from './icons';
|
||||||
import type { Email } from '../api/types';
|
import type { Email } from '../api/types';
|
||||||
import { extractCidRefs } from '../lib/email-html';
|
import { extractCidRefs, isUserLinkNavigation } from '../lib/email-html';
|
||||||
import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body';
|
import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body';
|
||||||
import { buildQuoteCollapseScript } from '../lib/quote-collapse';
|
import { buildQuoteCollapseScript } from '../lib/quote-collapse';
|
||||||
import { bodyDocument } from '../lib/email-body-document';
|
import { bodyDocument } from '../lib/email-body-document';
|
||||||
@@ -834,6 +834,11 @@ export default function EmailBodyView({
|
|||||||
// any other) navigation would replace the email body with no way
|
// any other) navigation would replace the email body with no way
|
||||||
// back, so it is refused; images are reachable via the chips.
|
// back, so it is refused; images are reachable via the chips.
|
||||||
if (!url || url === 'about:blank') return true;
|
if (!url || url === 'about:blank') return true;
|
||||||
|
// A message opens links only when the user taps one. The page
|
||||||
|
// runs no script of its own, so nothing else should navigate;
|
||||||
|
// iOS says how a navigation started, so anything but a tap is
|
||||||
|
// refused there as well. Android always reports 'other'.
|
||||||
|
if (!isUserLinkNavigation(request, Platform.OS)) return false;
|
||||||
if (/^mailto:/i.test(url)) {
|
if (/^mailto:/i.test(url)) {
|
||||||
openMailto(url);
|
openMailto(url);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
import { LIGHT_COLORS, DARK_COLORS } from '../../theme/tokens';
|
import { LIGHT_COLORS, DARK_COLORS } from '../../theme/tokens';
|
||||||
|
|
||||||
function extractScript(html: string): string {
|
function extractScript(html: string): string {
|
||||||
const m = html.match(/<script>([\s\S]*?)<\/script>/);
|
const m = html.match(/<script(?: nonce="[0-9a-f]+")?>([\s\S]*?)<\/script>/);
|
||||||
expect(m, 'page must contain an inline <script>').toBeTruthy();
|
expect(m, 'page must contain an inline <script>').toBeTruthy();
|
||||||
return m![1];
|
return m![1];
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,18 @@ describe('editor CSP', () => {
|
|||||||
const html = buildEditorHtml(base);
|
const html = buildEditorHtml(base);
|
||||||
expect(html.indexOf('Content-Security-Policy')).toBeLessThan(html.indexOf('<body>'));
|
expect(html.indexOf('Content-Security-Policy')).toBeLessThan(html.indexOf('<body>'));
|
||||||
expect(cspOf(html)).toContain("default-src 'none'");
|
expect(cspOf(html)).toContain("default-src 'none'");
|
||||||
expect(cspOf(html)).toContain("script-src 'unsafe-inline'");
|
});
|
||||||
|
|
||||||
|
// An `<img onerror>` in a quoted original or a paste must not run: only the
|
||||||
|
// page's own script, which carries a fresh nonce, may.
|
||||||
|
it('runs only its own script, never inline handlers', () => {
|
||||||
|
const html = buildEditorHtml(base);
|
||||||
|
const csp = cspOf(html);
|
||||||
|
const nonce = csp.match(/script-src 'nonce-([0-9a-f]{32})'/)?.[1];
|
||||||
|
expect(nonce).toBeTruthy();
|
||||||
|
expect(csp).not.toMatch(/script-src[^;]*unsafe-inline/);
|
||||||
|
expect(html).toContain(`<script nonce="${nonce}">`);
|
||||||
|
expect(cspOf(buildEditorHtml(base))).not.toBe(csp);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blocks remote images and fonts but keeps data: and blob: images', () => {
|
it('blocks remote images and fonts but keeps data: and blob: images', () => {
|
||||||
@@ -84,7 +95,9 @@ describe('editor CSP', () => {
|
|||||||
|
|
||||||
it('allows remote images when not blocking', () => {
|
it('allows remote images when not blocking', () => {
|
||||||
expect(buildEditorCsp(false)).toContain('img-src data: blob: https: http:');
|
expect(buildEditorCsp(false)).toContain('img-src data: blob: https: http:');
|
||||||
expect(cspOf(buildEditorHtml(base))).toBe(buildEditorCsp(false));
|
const csp = cspOf(buildEditorHtml(base));
|
||||||
|
const nonce = csp.match(/'nonce-([0-9a-f]+)'/)![1];
|
||||||
|
expect(csp).toBe(buildEditorCsp(false, nonce));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
hasNativeDarkMode,
|
hasNativeDarkMode,
|
||||||
sniffImageMime,
|
sniffImageMime,
|
||||||
TRANSPARENT_BLOCKED_PIXEL,
|
TRANSPARENT_BLOCKED_PIXEL,
|
||||||
|
isUserLinkNavigation,
|
||||||
} from '../email-html';
|
} from '../email-html';
|
||||||
|
|
||||||
describe('stripDangerousTags', () => {
|
describe('stripDangerousTags', () => {
|
||||||
@@ -46,6 +47,46 @@ describe('stripDangerousTags', () => {
|
|||||||
expect(stripDangerousTags('<a href="javascript:alert(1)">x</a>')).toContain('#blocked');
|
expect(stripDangerousTags('<a href="javascript:alert(1)">x</a>')).toContain('#blocked');
|
||||||
expect(stripDangerousTags('<a href="java\nscript:alert(1)">x</a>')).toContain('#blocked');
|
expect(stripDangerousTags('<a href="java\nscript:alert(1)">x</a>')).toContain('#blocked');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GHSA-g35q-3gpc-x2rr: the browser starts a new attribute after a slash or
|
||||||
|
// right after a closing quote, with no whitespace in between.
|
||||||
|
it('drops on* handlers that follow a slash or a quote', () => {
|
||||||
|
for (const input of [
|
||||||
|
'<img src="x"/onerror=alert(1)>',
|
||||||
|
'<img/onerror=alert(1) src=x>',
|
||||||
|
'<img src="x"onerror="alert(1)">',
|
||||||
|
"<img src='x'/onerror='alert(1)'>",
|
||||||
|
'<body/onload=alert(1)>',
|
||||||
|
'<img src=x ONERROR=alert(1)>',
|
||||||
|
]) {
|
||||||
|
expect(stripDangerousTags(input), input).not.toMatch(/onerror|onload/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads an attribute after a slash as a new, URI-checked attribute', () => {
|
||||||
|
expect(stripDangerousTags('<a title="x"/href="javascript:alert(1)">l</a>')).toContain('href="#blocked"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves no tag hidden behind a stray quote in an attribute name', () => {
|
||||||
|
expect(stripDangerousTags('<img x"=y onerror=alert(1) src=z>')).not.toMatch(/onerror/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not read tags inside a stylesheet, where the browser sees text', () => {
|
||||||
|
const out = stripDangerousTags('<style>/*<a b="*/</style><img src=x onerror=alert(1) c="">');
|
||||||
|
expect(out).toContain('<style>/*<a b="*/</style>');
|
||||||
|
expect(out).not.toMatch(/onerror/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ends comments where the browser does', () => {
|
||||||
|
expect(stripDangerousTags('a<!-- x --!><img src=x onerror=alert(1)>')).not.toMatch(/onerror/i);
|
||||||
|
expect(stripDangerousTags('<p>a</p><!--> <img src=x onerror=alert(1)>')).not.toMatch(/onerror/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a < that starts no tag as text and keeps ordinary markup', () => {
|
||||||
|
expect(stripDangerousTags('<p>1 < 2</p>')).toBe('<p>1 < 2</p>');
|
||||||
|
expect(stripDangerousTags('<p class="a" data-x=\'b "c"\'>t</p><br/>'))
|
||||||
|
.toBe('<p class="a" data-x="b "c"">t</p><br />');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isExternalResourceUrl', () => {
|
describe('isExternalResourceUrl', () => {
|
||||||
@@ -126,6 +167,21 @@ describe('blockExternalResources', () => {
|
|||||||
expect(html).not.toContain('https://t');
|
expect(html).not.toContain('https://t');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps CSS-escaped markup inside the scrubbed sheet', () => {
|
||||||
|
const { html } = prepareEmailHtml(
|
||||||
|
'<style>.a{background:url(https://t/x.png)} \\3c /style\\3e \\3c img src=x onerror=alert(1)\\3e </style><p>x</p>',
|
||||||
|
{ blockRemoteImages: true },
|
||||||
|
);
|
||||||
|
expect(html).not.toMatch(/<img/i);
|
||||||
|
expect(html).not.toMatch(/<\/style>\s*<img/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds the image behind a stray quote, as the browser does', () => {
|
||||||
|
const { html, blocked } = blockExternalResources('<img x"=y src=https://t/a.png>');
|
||||||
|
expect(blocked).toBe(true);
|
||||||
|
expect(html).toContain('data-blocked-src="https://t/a.png"');
|
||||||
|
});
|
||||||
|
|
||||||
it('reports nothing blocked for a local-only body', () => {
|
it('reports nothing blocked for a local-only body', () => {
|
||||||
const { html, blocked } = blockExternalResources('<p style="color:red">hi</p><img src="cid:x">');
|
const { html, blocked } = blockExternalResources('<p style="color:red">hi</p><img src="cid:x">');
|
||||||
expect(blocked).toBe(false);
|
expect(blocked).toBe(false);
|
||||||
@@ -144,6 +200,16 @@ describe('prepareEmailHtml', () => {
|
|||||||
expect(open.html).toContain('img-src data: cid: https: http:');
|
expect(open.html).toContain('img-src data: cid: https: http:');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The bridge scripts are injected by the WebView, so the page itself never
|
||||||
|
// needs to run a script or an inline handler.
|
||||||
|
it('runs no script or inline handler from the page in either mode', () => {
|
||||||
|
for (const blockRemoteImages of [true, false]) {
|
||||||
|
expect(prepareEmailHtml('<p>x</p>', { blockRemoteImages }).html).toContain("script-src 'none'");
|
||||||
|
}
|
||||||
|
expect(wrapPlainTextEmail('x')).toContain("script-src 'none'");
|
||||||
|
expect(prepareEmailHtml('<p>x</p>').html).not.toMatch(/<script/i);
|
||||||
|
});
|
||||||
|
|
||||||
it('evaluates native dark mode once and reports the inversion decision', () => {
|
it('evaluates native dark mode once and reports the inversion decision', () => {
|
||||||
const native = prepareEmailHtml('<style>@media (prefers-color-scheme: dark){body{color:#fff}}</style><p>x</p>', { isDark: true });
|
const native = prepareEmailHtml('<style>@media (prefers-color-scheme: dark){body{color:#fff}}</style><p>x</p>', { isDark: true });
|
||||||
expect(native.hasNativeDark).toBe(true);
|
expect(native.hasNativeDark).toBe(true);
|
||||||
@@ -263,3 +329,17 @@ describe('reading width on a wide pane', () => {
|
|||||||
expect(plain).toContain('@media (min-width: 840px) { body { max-width: 80ch; } }');
|
expect(plain).toContain('@media (min-width: 840px) { body { max-width: 80ch; } }');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isUserLinkNavigation', () => {
|
||||||
|
it('opens only tapped links in the top frame on iOS', () => {
|
||||||
|
expect(isUserLinkNavigation({ navigationType: 'click', isTopFrame: true }, 'ios')).toBe(true);
|
||||||
|
expect(isUserLinkNavigation({ navigationType: 'other', isTopFrame: true }, 'ios')).toBe(false);
|
||||||
|
expect(isUserLinkNavigation({ navigationType: 'formsubmit' }, 'ios')).toBe(false);
|
||||||
|
expect(isUserLinkNavigation({ navigationType: 'click', isTopFrame: false }, 'ios')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps taps working on Android, which always reports other', () => {
|
||||||
|
expect(isUserLinkNavigation({ navigationType: 'other', isTopFrame: true }, 'android')).toBe(true);
|
||||||
|
expect(isUserLinkNavigation({ navigationType: 'other', isTopFrame: false }, 'android')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+11
-4
@@ -10,6 +10,7 @@
|
|||||||
// page verbatim; write escape-free code or double the backslash.
|
// page verbatim; write escape-free code or double the backslash.
|
||||||
|
|
||||||
import type { ThemePalette } from '../theme/tokens';
|
import type { ThemePalette } from '../theme/tokens';
|
||||||
|
import { randomHex } from './random';
|
||||||
|
|
||||||
// Minimum visible editor height (px). The editor auto-grows beyond this as the
|
// Minimum visible editor height (px). The editor auto-grows beyond this as the
|
||||||
// user types, and the parent ScrollView handles overflow.
|
// user types, and the parent ScrollView handles overflow.
|
||||||
@@ -27,14 +28,19 @@ export const CHANGE_THROTTLE_MS = 400;
|
|||||||
* what the composer produces itself - `data:` (inline and hydrated `cid:`
|
* what the composer produces itself - `data:` (inline and hydrated `cid:`
|
||||||
* images) and `blob:` (pasted images) - so a quoted original's tracking
|
* images) and `blob:` (pasted images) - so a quoted original's tracking
|
||||||
* pixels and remote images don't fire just because it is replied to.
|
* pixels and remote images don't fire just because it is replied to.
|
||||||
|
*
|
||||||
|
* Scripts need the page's `nonce`: the editor's own script carries it, and
|
||||||
|
* inline handlers in seeded or pasted HTML (a quoted `<img onerror=…>`)
|
||||||
|
* don't run. Commands from the app arrive through injectJavaScript, which
|
||||||
|
* page CSP doesn't govern.
|
||||||
*/
|
*/
|
||||||
export function buildEditorCsp(blockRemoteImages: boolean): string {
|
export function buildEditorCsp(blockRemoteImages: boolean, nonce?: string): string {
|
||||||
const resources = blockRemoteImages
|
const resources = blockRemoteImages
|
||||||
? ['img-src data: blob:', 'font-src data:', "media-src 'none'"]
|
? ['img-src data: blob:', 'font-src data:', "media-src 'none'"]
|
||||||
: ['img-src data: blob: https: http:', 'font-src data: https: http:', 'media-src data: https: http:'];
|
: ['img-src data: blob: https: http:', 'font-src data: https: http:', 'media-src data: https: http:'];
|
||||||
return [
|
return [
|
||||||
"default-src 'none'",
|
"default-src 'none'",
|
||||||
"script-src 'unsafe-inline'",
|
nonce ? `script-src 'nonce-${nonce}'` : "script-src 'none'",
|
||||||
"style-src 'unsafe-inline'",
|
"style-src 'unsafe-inline'",
|
||||||
...resources,
|
...resources,
|
||||||
"base-uri 'none'",
|
"base-uri 'none'",
|
||||||
@@ -108,13 +114,14 @@ export function buildEditorHtml(opts: {
|
|||||||
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
|
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
|
||||||
// are safely embedded (no template literal collision with the script body).
|
// are safely embedded (no template literal collision with the script body).
|
||||||
const initialJson = JSON.stringify(initialHtml);
|
const initialJson = JSON.stringify(initialHtml);
|
||||||
|
const nonce = randomHex(16);
|
||||||
const placeholderJson = JSON.stringify(placeholder);
|
const placeholderJson = JSON.stringify(placeholder);
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta http-equiv="Content-Security-Policy" content="${buildEditorCsp(blockRemoteImages)}" />
|
<meta http-equiv="Content-Security-Policy" content="${buildEditorCsp(blockRemoteImages, nonce)}" />
|
||||||
<meta name="referrer" content="no-referrer" />
|
<meta name="referrer" content="no-referrer" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||||
<style>
|
<style>
|
||||||
@@ -167,7 +174,7 @@ export function buildEditorHtml(opts: {
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="editor" contenteditable="true" dir="auto" data-placeholder=${placeholderJson}></div>
|
<div id="editor" contenteditable="true" dir="auto" data-placeholder=${placeholderJson}></div>
|
||||||
<script>
|
<script nonce="${nonce}">
|
||||||
(function () {
|
(function () {
|
||||||
var editor = document.getElementById('editor');
|
var editor = document.getElementById('editor');
|
||||||
var lastHtml = null;
|
var lastHtml = null;
|
||||||
|
|||||||
+270
-64
@@ -9,12 +9,11 @@
|
|||||||
// initial about:blank doc - links are opened in the OS browser / composer.
|
// initial about:blank doc - links are opened in the OS browser / composer.
|
||||||
// 3. This module pre-strips known-dangerous tags and on* attributes so the
|
// 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
|
// rendered DOM is also clean. Regex stripping is imperfect; it is here
|
||||||
// as belt-and-suspenders, not the primary defense. `script-src
|
// as belt-and-suspenders, not the primary defense. The attribute pass
|
||||||
// 'unsafe-inline'` has to stay on for the injected measurement bridge,
|
// reads tags the way the browser's tokenizer does, and the page CSP is
|
||||||
// so a parser-differential bypass of the regex stripper would execute in
|
// `script-src 'none'` (the bridge scripts are injected by the WebView,
|
||||||
// the WebView. The blast radius is small: the page has no origin, no
|
// which CSP doesn't govern), so an inline handler that slipped through
|
||||||
// storage, no cookies, and the bridge only accepts height/swipe/zoom
|
// would still not run.
|
||||||
// strings - but it is the residual risk of not using a real HTML parser.
|
|
||||||
//
|
//
|
||||||
// Tag/attribute lists mirror `lib/email-sanitization.ts` from the webmail.
|
// Tag/attribute lists mirror `lib/email-sanitization.ts` from the webmail.
|
||||||
// `<style>` is kept on purpose: the WebView is an isolated document, so the
|
// `<style>` is kept on purpose: the WebView is an isolated document, so the
|
||||||
@@ -67,54 +66,260 @@ export const TRANSPARENT_BLOCKED_PIXEL =
|
|||||||
const RASTER_IMAGE_DATA_URI =
|
const RASTER_IMAGE_DATA_URI =
|
||||||
/^data:image\/(?:png|jpe?g|gif|webp|bmp|avif|x-icon|vnd\.microsoft\.icon)[;,]/i;
|
/^data:image\/(?:png|jpe?g|gif|webp|bmp|avif|x-icon|vnd\.microsoft\.icon)[;,]/i;
|
||||||
|
|
||||||
// Matches one start tag, keeping quoted attribute values (which may contain
|
// ─── Tag tokenizer ───────────────────────────────────────────────────────
|
||||||
// `>`) intact. Group 1 = tag name, group 2 = raw attribute text.
|
//
|
||||||
const TAG_RE = /<([a-z][a-z0-9-]*)\b((?:[^>"']|"[^"]*"|'[^']*')*)>/gi;
|
// The attribute passes read markup the way the browser's HTML tokenizer does
|
||||||
// One attribute inside a tag's attribute text.
|
// (WHATWG HTML §13.2.5), not with a tag regex. A regex that disagrees with
|
||||||
const ATTR_RE = /([^\s=/]+)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s>]+))?/g;
|
// the browser about where a tag or an attribute starts is a bypass: the old
|
||||||
|
// one wanted whitespace before `on*`, so `<img src="x"/onerror=…>` kept its
|
||||||
|
// handler, and a stray quote could hide a whole tag from it. Every tag is
|
||||||
|
// rebuilt in one canonical form (`name="value"`, no stray quotes), comments
|
||||||
|
// and bogus comments are dropped, and a `<` that starts no tag is written as
|
||||||
|
// `<`, so later passes and the WebView see the same tags this one did.
|
||||||
|
|
||||||
interface ParsedAttr {
|
interface ParsedAttr {
|
||||||
name: string;
|
name: string;
|
||||||
/** Unquoted value, or null for a boolean attribute. */
|
/** Raw value (character references intact), or null for a boolean attribute. */
|
||||||
value: string | null;
|
value: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAttrs(raw: string): ParsedAttr[] {
|
interface ParsedTag {
|
||||||
const out: ParsedAttr[] = [];
|
/** Tag name as written. */
|
||||||
ATTR_RE.lastIndex = 0;
|
name: string;
|
||||||
let m: RegExpExecArray | null;
|
attrs: ParsedAttr[];
|
||||||
while ((m = ATTR_RE.exec(raw)) !== null) {
|
selfClosing: boolean;
|
||||||
if (!m[1]) continue;
|
/** Index just past the closing `>`. */
|
||||||
const rawValue = m[2];
|
end: number;
|
||||||
if (rawValue === undefined) {
|
}
|
||||||
out.push({ name: m[1], value: null });
|
|
||||||
|
function isHtmlSpace(ch: string | undefined): boolean {
|
||||||
|
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\f' || ch === '\r';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAsciiAlpha(ch: string | undefined): boolean {
|
||||||
|
if (!ch) return false;
|
||||||
|
const code = ch.charCodeAt(0) | 0x20;
|
||||||
|
return code >= 97 && code <= 122;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The browser lowercases tag and attribute names in ASCII only. */
|
||||||
|
function asciiLower(s: string): string {
|
||||||
|
return s.replace(/[A-Z]+/g, (m) => m.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a start or end tag whose name begins at `i`. Returns null when the
|
||||||
|
* input ends inside the tag: the browser drops such a tag and everything
|
||||||
|
* after it.
|
||||||
|
*/
|
||||||
|
function readTag(html: string, i: number): ParsedTag | null {
|
||||||
|
const n = html.length;
|
||||||
|
let j = i;
|
||||||
|
while (j < n && !isHtmlSpace(html[j]) && html[j] !== '/' && html[j] !== '>') j++;
|
||||||
|
const name = html.slice(i, j);
|
||||||
|
const attrs: ParsedAttr[] = [];
|
||||||
|
while (j < n) {
|
||||||
|
const ch = html[j];
|
||||||
|
if (isHtmlSpace(ch)) { j++; continue; }
|
||||||
|
if (ch === '>') return { name, attrs, selfClosing: false, end: j + 1 };
|
||||||
|
if (ch === '/') {
|
||||||
|
// A slash only self-closes right before `>`; anywhere else it just
|
||||||
|
// separates attributes, which is what `<img/onerror=…>` relies on.
|
||||||
|
j++;
|
||||||
|
if (html[j] === '>') return { name, attrs, selfClosing: true, end: j + 1 };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Attribute name. Its first character may be `=`; after that it runs to
|
||||||
|
// whitespace, `/`, `>` or `=`. Quotes and `<` are part of the name.
|
||||||
|
const start = j;
|
||||||
|
j++;
|
||||||
|
while (j < n && !isHtmlSpace(html[j]) && html[j] !== '/' && html[j] !== '>' && html[j] !== '=') j++;
|
||||||
|
const attrName = html.slice(start, j);
|
||||||
|
while (j < n && isHtmlSpace(html[j])) j++;
|
||||||
|
if (html[j] !== '=') {
|
||||||
|
attrs.push({ name: attrName, value: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
j++;
|
||||||
|
while (j < n && isHtmlSpace(html[j])) j++;
|
||||||
|
if (j >= n) return null;
|
||||||
|
const q = html[j];
|
||||||
|
if (q === '"' || q === "'") {
|
||||||
|
const close = html.indexOf(q, j + 1);
|
||||||
|
if (close < 0) return null;
|
||||||
|
attrs.push({ name: attrName, value: html.slice(j + 1, close) });
|
||||||
|
j = close + 1;
|
||||||
|
} else if (q === '>') {
|
||||||
|
attrs.push({ name: attrName, value: '' });
|
||||||
} else {
|
} else {
|
||||||
const q = rawValue[0];
|
const s = j;
|
||||||
const unquoted = (q === '"' || q === "'") ? rawValue.slice(1, -1) : rawValue;
|
while (j < n && !isHtmlSpace(html[j]) && html[j] !== '>') j++;
|
||||||
out.push({ name: m[1], value: unquoted });
|
attrs.push({ name: attrName, value: html.slice(s, j) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Elements whose content the tokenizer reads as text up to their end tag.
|
||||||
|
// Only `style` and `title` survive the sanitiser; the rest are listed so a
|
||||||
|
// forbidden one is dropped together with its content.
|
||||||
|
const RAW_TEXT_TAGS = new Set([
|
||||||
|
'style', 'title', 'script', 'textarea', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Attribute names written in a form that every later pass reads the same way.
|
||||||
|
const SAFE_ATTR_NAME = /^[a-zA-Z_:][-a-zA-Z0-9_:.]*$/;
|
||||||
|
|
||||||
function serializeAttrs(attrs: ParsedAttr[]): string {
|
function serializeAttrs(attrs: ParsedAttr[]): string {
|
||||||
return attrs
|
return attrs
|
||||||
.map((a) => (a.value === null ? a.name : `${a.name}="${a.value.replace(/"/g, '"')}"`))
|
.map((a) => (a.value === null ? a.name : `${a.name}="${a.value.replace(/"/g, '"')}"`))
|
||||||
.join(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeTag(name: string, attrs: ParsedAttr[], selfClosing: boolean): string {
|
||||||
|
const body = serializeAttrs(attrs);
|
||||||
|
return `<${name}${body ? ' ' + body : ''}${selfClosing ? ' /' : ''}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenize `html` and rebuild every start tag through `fn`, which gets the
|
||||||
|
* ASCII-lowercased tag name and the attributes (first of each name only, as
|
||||||
|
* the browser keeps them) and returns the list to write, `null` to keep the
|
||||||
|
* attributes unchanged, or `false` to drop the tag. End tags are written
|
||||||
|
* without attributes; comments, doctypes and bogus comments are dropped.
|
||||||
|
*/
|
||||||
|
function rewriteTags(
|
||||||
|
html: string,
|
||||||
|
fn: (tag: string, attrs: ParsedAttr[]) => ParsedAttr[] | null | false,
|
||||||
|
): string {
|
||||||
|
const n = html.length;
|
||||||
|
let out = '';
|
||||||
|
let i = 0;
|
||||||
|
while (i < n) {
|
||||||
|
const lt = html.indexOf('<', i);
|
||||||
|
if (lt < 0) {
|
||||||
|
out += html.slice(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out += html.slice(i, lt);
|
||||||
|
const next = html[lt + 1];
|
||||||
|
|
||||||
|
if (isAsciiAlpha(next)) {
|
||||||
|
const tag = readTag(html, lt + 1);
|
||||||
|
if (!tag) break;
|
||||||
|
const lower = asciiLower(tag.name);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const attrs = tag.attrs.filter((a) => {
|
||||||
|
const key = asciiLower(a.name);
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const result = fn(lower, attrs);
|
||||||
|
i = tag.end;
|
||||||
|
if (result !== false) out += serializeTag(tag.name, result ?? attrs, tag.selfClosing);
|
||||||
|
if (RAW_TEXT_TAGS.has(lower)) {
|
||||||
|
// Copy the element's text verbatim up to its end tag, which the loop
|
||||||
|
// then reads as a tag. A dropped element loses its text too.
|
||||||
|
const close = new RegExp(`</${lower}[\\t\\n\\f\\r />]`, 'gi');
|
||||||
|
close.lastIndex = i;
|
||||||
|
const m = close.exec(html);
|
||||||
|
const textEnd = m ? m.index : n;
|
||||||
|
if (result !== false) out += html.slice(i, textEnd);
|
||||||
|
i = textEnd;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === '/') {
|
||||||
|
const after = html[lt + 2];
|
||||||
|
if (isAsciiAlpha(after)) {
|
||||||
|
const tag = readTag(html, lt + 2);
|
||||||
|
if (!tag) break;
|
||||||
|
if (fn(asciiLower(tag.name), []) !== false) out += `</${tag.name}>`;
|
||||||
|
i = tag.end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (after === '>') {
|
||||||
|
i = lt + 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (after === undefined) {
|
||||||
|
out += '</';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Bogus comment up to the next `>`.
|
||||||
|
const gt = html.indexOf('>', lt + 2);
|
||||||
|
i = gt < 0 ? n : gt + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === '!') {
|
||||||
|
if (html.startsWith('<!--', lt)) {
|
||||||
|
// A comment ends at `-->` or `--!>`; `<!-->` and `<!--->` end at once.
|
||||||
|
const k = lt + 4;
|
||||||
|
if (html[k] === '>') { i = k + 1; continue; }
|
||||||
|
if (html.startsWith('->', k)) { i = k + 2; continue; }
|
||||||
|
const a = html.indexOf('-->', k);
|
||||||
|
const b = html.indexOf('--!>', k);
|
||||||
|
if (a < 0 && b < 0) { i = n; continue; }
|
||||||
|
i = a >= 0 && (b < 0 || a < b) ? a + 3 : b + 4;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Doctype, CDATA outside foreign content and other bogus comments all
|
||||||
|
// end at the first `>`.
|
||||||
|
const gt = html.indexOf('>', lt + 2);
|
||||||
|
i = gt < 0 ? n : gt + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === '?') {
|
||||||
|
const gt = html.indexOf('>', lt + 2);
|
||||||
|
i = gt < 0 ? n : gt + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A `<` that starts no tag is text.
|
||||||
|
out += '<';
|
||||||
|
i = lt + 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Rebuild every start tag through `fn`, which may edit its attribute list. */
|
/** Rebuild every start tag through `fn`, which may edit its attribute list. */
|
||||||
function mapTags(
|
function mapTags(
|
||||||
html: string,
|
html: string,
|
||||||
fn: (tag: string, attrs: ParsedAttr[]) => ParsedAttr[] | null,
|
fn: (tag: string, attrs: ParsedAttr[]) => ParsedAttr[] | null,
|
||||||
): string {
|
): string {
|
||||||
return html.replace(TAG_RE, (full, tag: string, rawAttrs: string) => {
|
return rewriteTags(html, fn);
|
||||||
const attrs = parseAttrs(rawAttrs);
|
}
|
||||||
const next = fn(tag.toLowerCase(), attrs);
|
|
||||||
if (next === null) return full;
|
const FORBIDDEN_TAG_SET = new Set(FORBIDDEN_TAGS);
|
||||||
const selfClosing = /\/\s*$/.test(rawAttrs);
|
// `on*` handlers, and the rarer `formmethod`, `srcdoc`, `ping` families that
|
||||||
const body = serializeAttrs(next);
|
// have a side-channel.
|
||||||
return `<${tag}${body ? ' ' + body : ''}${selfClosing ? ' /' : ''}>`;
|
const DANGEROUS_ATTR = /^(?:on|srcdoc$|ping$|formmethod$|formenctype$|formtarget$|form$)/;
|
||||||
|
const URI_ATTRS = new Set(['href', 'src', 'action', 'xlink:href', 'formaction', 'poster', 'background']);
|
||||||
|
|
||||||
|
/** Drop forbidden tags and dangerous attributes, neutralise unsafe URIs. */
|
||||||
|
function sanitizeTags(html: string): string {
|
||||||
|
return rewriteTags(html, (tag, attrs) => {
|
||||||
|
if (FORBIDDEN_TAG_SET.has(tag)) return false;
|
||||||
|
const kept: ParsedAttr[] = [];
|
||||||
|
for (const attr of attrs) {
|
||||||
|
if (!SAFE_ATTR_NAME.test(attr.name)) continue;
|
||||||
|
const name = asciiLower(attr.name);
|
||||||
|
if (DANGEROUS_ATTR.test(name)) continue;
|
||||||
|
if (attr.value !== null && URI_ATTRS.has(name)) {
|
||||||
|
kept.push({ name: attr.name, value: safeUri(attr.value) });
|
||||||
|
} else if (attr.value !== null && name === 'srcset') {
|
||||||
|
// srcset is a list: check every candidate's scheme individually so
|
||||||
|
// an allowed first candidate can't wave a `data:image/svg+xml` through.
|
||||||
|
kept.push({ name: attr.name, value: safeSrcset(attr.value) });
|
||||||
|
} else {
|
||||||
|
kept.push(attr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,31 +342,10 @@ export function stripDangerousTags(html: string): string {
|
|||||||
}
|
}
|
||||||
} while (out !== prev);
|
} while (out !== prev);
|
||||||
|
|
||||||
// Strip inline event handlers - both `on*` and the rarer `formmethod`,
|
// Then read what is left the way the browser will and rebuild every tag:
|
||||||
// `srcdoc`, `ping` attribute families that have a side-channel.
|
// dangerous attributes and unsafe URIs go whatever precedes them, and
|
||||||
const dangerousAttrs = '(?:on[a-z]+|srcdoc|ping|formmethod|formenctype|formtarget|form)';
|
// whatever the passes above left half-cut is settled one way.
|
||||||
out = out.replace(new RegExp(`\\s+${dangerousAttrs}\\s*=\\s*"[^"]*"`, 'gi'), '');
|
return sanitizeTags(out);
|
||||||
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 raster `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}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// srcset is a list: check every candidate's scheme individually so an
|
|
||||||
// allowed first candidate can't wave a `data:image/svg+xml` through.
|
|
||||||
out = out.replace(
|
|
||||||
/(\bsrcset\s*=\s*)(["'])([^"']*)\2/gi,
|
|
||||||
(_m, pre, q, value: string) => `${pre}${q}${safeSrcset(value)}${q}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeUriEntities(raw: string): string {
|
function decodeUriEntities(raw: string): string {
|
||||||
@@ -409,8 +593,13 @@ export function blockExternalResources(html: string): BlockExternalResult {
|
|||||||
|
|
||||||
// <style> block CSS.
|
// <style> block CSS.
|
||||||
out = out.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style\s*>)/gi, (_m, open, css: string, close) => {
|
out = out.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style\s*>)/gi, (_m, open, css: string, close) => {
|
||||||
const cleaned = stripExternalStyleSheetCss(css);
|
const stripped = stripExternalStyleSheetCss(css);
|
||||||
if (cleaned !== css) blocked = true;
|
if (stripped === css) return `${open}${css}${close}`;
|
||||||
|
blocked = true;
|
||||||
|
// The cleaned sheet comes back with its CSS escapes decoded, so an
|
||||||
|
// escaped `\3c /style\3e <img onerror=…>` would turn into markup that
|
||||||
|
// leaves the sheet. Write every `<` as the CSS escape again.
|
||||||
|
const cleaned = stripped.replace(/</g, '\\3c ');
|
||||||
return `${open}${cleaned}${close}`;
|
return `${open}${cleaned}${close}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -595,10 +784,12 @@ export interface PreparedEmailHtml {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildCsp(strict: boolean): string {
|
function buildCsp(strict: boolean): string {
|
||||||
// Strict CSP. Inline <script> and handlers from the email are stripped by
|
// Strict CSP. The document carries no script of its own: the measurement,
|
||||||
// `stripDangerousTags`, so script-src only needs to allow our own injected
|
// swipe, zoom and quote bridges arrive as react-native-webview's injected
|
||||||
// measurement bridge - 'unsafe-inline' is the minimum that lets RN WebView's
|
// JavaScript (evaluateJavascript on Android, WKUserScript/evaluateJavaScript
|
||||||
// injected script run on Android (where page CSP can apply to evaluateJs).
|
// on iOS), which page CSP doesn't govern, and they attach listeners with
|
||||||
|
// addEventListener. So script-src is 'none': if markup ever slips past
|
||||||
|
// `stripDangerousTags`, its inline handlers and scripts still don't run.
|
||||||
//
|
//
|
||||||
// In blocking mode img/media/font are restricted to data: only - the
|
// In blocking mode img/media/font are restricted to data: only - the
|
||||||
// network-level backstop for every tracking vector, including ones the
|
// network-level backstop for every tracking vector, including ones the
|
||||||
@@ -608,7 +799,7 @@ function buildCsp(strict: boolean): string {
|
|||||||
: ['img-src data: cid: https: http:', 'font-src data: https: http:', 'media-src data: https: http:'];
|
: ['img-src data: cid: https: http:', 'font-src data: https: http:', 'media-src data: https: http:'];
|
||||||
return [
|
return [
|
||||||
"default-src 'none'",
|
"default-src 'none'",
|
||||||
"script-src 'unsafe-inline'",
|
"script-src 'none'",
|
||||||
"style-src 'unsafe-inline'",
|
"style-src 'unsafe-inline'",
|
||||||
...resources,
|
...resources,
|
||||||
"base-uri 'none'",
|
"base-uri 'none'",
|
||||||
@@ -754,3 +945,18 @@ export function sniffImageMime(bytes: Uint8Array, fallback?: string): string {
|
|||||||
if (f.startsWith('image/')) return f;
|
if (f.startsWith('image/')) return f;
|
||||||
return 'application/octet-stream';
|
return 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a navigation request from the message WebView is the user tapping
|
||||||
|
* a link in the top document. iOS reports how a navigation started; Android
|
||||||
|
* leaves `navigationType` at 'other' for everything, so there only the frame
|
||||||
|
* check applies.
|
||||||
|
*/
|
||||||
|
export function isUserLinkNavigation(
|
||||||
|
request: { navigationType?: string; isTopFrame?: boolean },
|
||||||
|
platform: string,
|
||||||
|
): boolean {
|
||||||
|
if (request.isTopFrame === false) return false;
|
||||||
|
if (platform === 'ios') return request.navigationType === 'click';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user