mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: keep a quoted stylesheet from ending the composer's editor script
This commit is contained in:
@@ -4,7 +4,7 @@ import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
|||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
import type { ThemePalette } from '../theme/tokens';
|
import type { ThemePalette } from '../theme/tokens';
|
||||||
import { perfEndAfterPaint } from '../lib/perf-marks';
|
import { perfEndAfterPaint } from '../lib/perf-marks';
|
||||||
import { buildEditorHtml, MIN_EDITOR_HEIGHT } from '../lib/editor-html';
|
import { buildEditorHtml, isEditorNavigationAllowed, MIN_EDITOR_HEIGHT } from '../lib/editor-html';
|
||||||
|
|
||||||
export type RichTextCommand =
|
export type RichTextCommand =
|
||||||
| 'bold'
|
| 'bold'
|
||||||
@@ -276,13 +276,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
androidLayerType={Platform.OS === 'android' ? 'hardware' : undefined}
|
androidLayerType={Platform.OS === 'android' ? 'hardware' : undefined}
|
||||||
style={styles.webview}
|
style={styles.webview}
|
||||||
containerStyle={styles.webviewContainer}
|
containerStyle={styles.webviewContainer}
|
||||||
onShouldStartLoadWithRequest={(request) => {
|
onShouldStartLoadWithRequest={(request) => isEditorNavigationAllowed(request.url)}
|
||||||
// Allow only the initial about:blank doc + data URIs we emit
|
|
||||||
// ourselves. Reject `about:*` (e.g. about:srcdoc) and every other
|
|
||||||
// scheme so a paste-injected anchor can't navigate the editor.
|
|
||||||
const url = request.url;
|
|
||||||
return !url || url === 'about:blank' || url.startsWith('data:');
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
buildEditorCsp, buildEditorHtml, CHANGE_THROTTLE_MS, shouldBlockEditorRemoteImages,
|
buildEditorCsp, buildEditorHtml, CHANGE_THROTTLE_MS, isEditorNavigationAllowed, shouldBlockEditorRemoteImages,
|
||||||
} from '../editor-html';
|
} from '../editor-html';
|
||||||
import { LIGHT_COLORS, DARK_COLORS } from '../../theme/tokens';
|
import { LIGHT_COLORS, DARK_COLORS } from '../../theme/tokens';
|
||||||
|
|
||||||
@@ -28,18 +28,43 @@ describe('buildEditorHtml', () => {
|
|||||||
it('emits no cooked-away backslashes in the script', () => {
|
it('emits no cooked-away backslashes in the script', () => {
|
||||||
// The page script is written escape-free on purpose; a backslash-free
|
// The page script is written escape-free on purpose; a backslash-free
|
||||||
// source cannot silently lose escapes to template-literal cooking.
|
// source cannot silently lose escapes to template-literal cooking.
|
||||||
const script = extractScript(buildEditorHtml(base));
|
// (The embedded content writes `<` as an escape, so it is left out here.)
|
||||||
|
const script = extractScript(buildEditorHtml({ ...base, initialHtml: 'x' }));
|
||||||
expect(script).not.toContain('\\');
|
expect(script).not.toContain('\\');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('embeds initial content and placeholder as JSON strings', () => {
|
it('embeds initial content as a JSON string and the placeholder as an attribute', () => {
|
||||||
const html = buildEditorHtml({
|
const html = buildEditorHtml({
|
||||||
...base,
|
...base,
|
||||||
initialHtml: '<p>He said "hi" & left</p>',
|
initialHtml: '<p>He said "hi" & left</p>',
|
||||||
placeholder: 'Say "something"…',
|
placeholder: 'Say "something"…',
|
||||||
});
|
});
|
||||||
expect(html).toContain(JSON.stringify('<p>He said "hi" & left</p>'));
|
expect(html).toContain(JSON.stringify('<p>He said "hi" & left</p>').replace(/</g, '\\u003c'));
|
||||||
expect(html).toContain(`data-placeholder=${JSON.stringify('Say "something"…')}`);
|
expect(html).toContain('data-placeholder="Say "something"…"');
|
||||||
|
});
|
||||||
|
|
||||||
|
// A quoted original's kept <style> carried `</script/>`: the page script
|
||||||
|
// ended there, the rest of the reply ran as markup and a refresh meta took
|
||||||
|
// the editor to a data:text/html page.
|
||||||
|
it('keeps a quoted </script inside the page script', () => {
|
||||||
|
const quoted = '<p>hello</p><style>p{}</script/><img src=x onerror=alert(1)><meta http-equiv=refresh content=0;url=data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg== x=';
|
||||||
|
const initialHtml = `<p><br></p><div data-quoted-html>On X wrote:<blockquote>${quoted}</blockquote></div>\u2028`;
|
||||||
|
const html = buildEditorHtml({ ...base, initialHtml });
|
||||||
|
// The only end tag is the script's own.
|
||||||
|
expect(html.match(/<\/script/gi)).toHaveLength(1);
|
||||||
|
const script = extractScript(html);
|
||||||
|
expect(() => new Function(script)).not.toThrow();
|
||||||
|
const embedded = script.match(/var initial = (.*);/)![1];
|
||||||
|
expect(JSON.parse(embedded)).toBe(initialHtml);
|
||||||
|
expect(embedded).not.toContain('\u2028');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the editor load nothing but its own about:blank page', () => {
|
||||||
|
expect(isEditorNavigationAllowed('about:blank')).toBe(true);
|
||||||
|
expect(isEditorNavigationAllowed(undefined)).toBe(true);
|
||||||
|
expect(isEditorNavigationAllowed('data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==')).toBe(false);
|
||||||
|
expect(isEditorNavigationAllowed('about:srcdoc')).toBe(false);
|
||||||
|
expect(isEditorNavigationAllowed('https://example.com/')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the editor contenteditable and the bridge object wiring', () => {
|
it('keeps the editor contenteditable and the bridge object wiring', () => {
|
||||||
|
|||||||
@@ -77,6 +77,17 @@ describe('stripDangerousTags', () => {
|
|||||||
expect(out).not.toMatch(/onerror/i);
|
expect(out).not.toMatch(/onerror/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The composer embeds the sanitised quote in its inline script, where any
|
||||||
|
// `</script` ends the script whatever the CSS around it.
|
||||||
|
it('leaves no end tag inside a kept stylesheet or title', () => {
|
||||||
|
const out = stripDangerousTags(
|
||||||
|
'<p>hello</p><style>p{}</script/><img src=x onerror=alert(1)><meta http-equiv=refresh content=0;url=data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg== x=',
|
||||||
|
);
|
||||||
|
expect(out).not.toMatch(/<\/script/i);
|
||||||
|
expect(out).toContain('<style>p{}<\\/script/>');
|
||||||
|
expect(stripDangerousTags('<title>a</SCRIPT/>b</title>')).toBe('<title>a</SCRIPT/>b</title>');
|
||||||
|
});
|
||||||
|
|
||||||
it('ends comments where the browser does', () => {
|
it('ends comments where the browser does', () => {
|
||||||
expect(stripDangerousTags('a<!-- x --!><img src=x onerror=alert(1)>')).not.toMatch(/onerror/i);
|
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);
|
expect(stripDangerousTags('<p>a</p><!--> <img src=x onerror=alert(1)>')).not.toMatch(/onerror/i);
|
||||||
|
|||||||
+33
-4
@@ -97,6 +97,35 @@ export const FOCUS_END_SOURCE = `function focusEnd(editor) {
|
|||||||
sel.addRange(range);
|
sel.addRange(range);
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the editor WebView may load `url`: only its initial about:blank
|
||||||
|
* document (`source={{ html }}` loads as about:blank on both platforms, as
|
||||||
|
* in the reader). Every other URL, `about:srcdoc` and `data:` included, is
|
||||||
|
* refused: a `data:text/html` page reached through a refresh meta or a
|
||||||
|
* pasted link would replace the editor with a document of the sender's.
|
||||||
|
*/
|
||||||
|
export function isEditorNavigationAllowed(url: string | undefined): boolean {
|
||||||
|
return !url || url === 'about:blank';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON for a value written into an inline `<script>`. The HTML tokenizer
|
||||||
|
* ends the script at the first `</script` whatever the JS around it, so a
|
||||||
|
* quoted original carrying one (inside a kept `<style>`, say) used to close
|
||||||
|
* the editor's script early and run as markup. `<` is written as `<`;
|
||||||
|
* U+2028/2029 are escaped too, for engines that read them as line ends.
|
||||||
|
*/
|
||||||
|
export function jsonForScript(value: unknown): string {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
.replace(/</g, '\\u003c')
|
||||||
|
.replace(/\u2028/g, '\\u2028')
|
||||||
|
.replace(/\u2029/g, '\\u2029');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(value: string): string {
|
||||||
|
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
export function buildEditorHtml(opts: {
|
export function buildEditorHtml(opts: {
|
||||||
initialHtml: string;
|
initialHtml: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
@@ -112,10 +141,10 @@ export function buildEditorHtml(opts: {
|
|||||||
}): string {
|
}): string {
|
||||||
const { initialHtml, placeholder, c, blockRemoteImages = false, pinVisualViewport = false } = opts;
|
const { initialHtml, placeholder, c, blockRemoteImages = false, pinVisualViewport = false } = 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);
|
// and no `</script` that ends the script, see jsonForScript).
|
||||||
|
const initialJson = jsonForScript(initialHtml);
|
||||||
const nonce = randomHex(16);
|
const nonce = randomHex(16);
|
||||||
const placeholderJson = JSON.stringify(placeholder);
|
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -173,7 +202,7 @@ export function buildEditorHtml(opts: {
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="editor" contenteditable="true" dir="auto" data-placeholder=${placeholderJson}></div>
|
<div id="editor" contenteditable="true" dir="auto" data-placeholder="${escapeAttr(placeholder)}"></div>
|
||||||
<script nonce="${nonce}">
|
<script nonce="${nonce}">
|
||||||
(function () {
|
(function () {
|
||||||
var editor = document.getElementById('editor');
|
var editor = document.getElementById('editor');
|
||||||
|
|||||||
+14
-1
@@ -167,6 +167,19 @@ const RAW_TEXT_TAGS = new Set([
|
|||||||
'style', 'title', 'script', 'textarea', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript',
|
'style', 'title', 'script', 'textarea', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kept raw text with every `</` written so that no parser reads an end tag
|
||||||
|
* in it. Here only `</style` ends a sheet, but the sanitised markup is also
|
||||||
|
* embedded elsewhere: the composer seeds its editor with it inside an
|
||||||
|
* inline script, where a `</script` in a quoted sheet ended the script. In
|
||||||
|
* CSS `\/` is the same `/`; a title's text is RCDATA, where `<` is `<`.
|
||||||
|
*/
|
||||||
|
function closeProofRawText(tag: string, text: string): string {
|
||||||
|
if (tag === 'style') return text.replace(/<\//g, '<\\/');
|
||||||
|
if (tag === 'title') return text.replace(/<\//g, '</');
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
// Attribute names written in a form that every later pass reads the same way.
|
// 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_:.]*$/;
|
const SAFE_ATTR_NAME = /^[a-zA-Z_:][-a-zA-Z0-9_:.]*$/;
|
||||||
|
|
||||||
@@ -225,7 +238,7 @@ function rewriteTags(
|
|||||||
close.lastIndex = i;
|
close.lastIndex = i;
|
||||||
const m = close.exec(html);
|
const m = close.exec(html);
|
||||||
const textEnd = m ? m.index : n;
|
const textEnd = m ? m.index : n;
|
||||||
if (result !== false) out += html.slice(i, textEnd);
|
if (result !== false) out += closeProofRawText(lower, html.slice(i, textEnd));
|
||||||
i = textEnd;
|
i = textEnd;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
Reference in New Issue
Block a user