fix: keep a quoted stylesheet from ending the composer's editor script

This commit is contained in:
Linus Rath
2026-10-29 04:25:57 +01:00
parent 07679cbf8c
commit 47a1d24999
5 changed files with 90 additions and 18 deletions
+2 -8
View File
@@ -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>
); );
+30 -5
View File
@@ -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 &quot;something&quot;…"');
});
// 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', () => {
+11
View File
@@ -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&lt;/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
View File
@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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
View File
@@ -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 `&lt;` is `<`.
*/
function closeProofRawText(tag: string, text: string): string {
if (tag === 'style') return text.replace(/<\//g, '<\\/');
if (tag === 'title') return text.replace(/<\//g, '&lt;/');
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;