mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +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 type { ThemePalette } from '../theme/tokens';
|
||||
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 =
|
||||
| 'bold'
|
||||
@@ -276,13 +276,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
androidLayerType={Platform.OS === 'android' ? 'hardware' : undefined}
|
||||
style={styles.webview}
|
||||
containerStyle={styles.webviewContainer}
|
||||
onShouldStartLoadWithRequest={(request) => {
|
||||
// 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:');
|
||||
}}
|
||||
onShouldStartLoadWithRequest={(request) => isEditorNavigationAllowed(request.url)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildEditorCsp, buildEditorHtml, CHANGE_THROTTLE_MS, shouldBlockEditorRemoteImages,
|
||||
buildEditorCsp, buildEditorHtml, CHANGE_THROTTLE_MS, isEditorNavigationAllowed, shouldBlockEditorRemoteImages,
|
||||
} from '../editor-html';
|
||||
import { LIGHT_COLORS, DARK_COLORS } from '../../theme/tokens';
|
||||
|
||||
@@ -28,18 +28,43 @@ describe('buildEditorHtml', () => {
|
||||
it('emits no cooked-away backslashes in the script', () => {
|
||||
// The page script is written escape-free on purpose; a backslash-free
|
||||
// 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('\\');
|
||||
});
|
||||
|
||||
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({
|
||||
...base,
|
||||
initialHtml: '<p>He said "hi" & left</p>',
|
||||
placeholder: 'Say "something"…',
|
||||
});
|
||||
expect(html).toContain(JSON.stringify('<p>He said "hi" & left</p>'));
|
||||
expect(html).toContain(`data-placeholder=${JSON.stringify('Say "something"…')}`);
|
||||
expect(html).toContain(JSON.stringify('<p>He said "hi" & left</p>').replace(/</g, '\\u003c'));
|
||||
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', () => {
|
||||
|
||||
@@ -77,6 +77,17 @@ describe('stripDangerousTags', () => {
|
||||
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', () => {
|
||||
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);
|
||||
|
||||
+33
-4
@@ -97,6 +97,35 @@ export const FOCUS_END_SOURCE = `function focusEnd(editor) {
|
||||
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: {
|
||||
initialHtml: string;
|
||||
placeholder: string;
|
||||
@@ -112,10 +141,10 @@ export function buildEditorHtml(opts: {
|
||||
}): string {
|
||||
const { initialHtml, placeholder, c, blockRemoteImages = false, pinVisualViewport = false } = opts;
|
||||
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
|
||||
// are safely embedded (no template literal collision with the script body).
|
||||
const initialJson = JSON.stringify(initialHtml);
|
||||
// are safely embedded (no template literal collision with the script body,
|
||||
// and no `</script` that ends the script, see jsonForScript).
|
||||
const initialJson = jsonForScript(initialHtml);
|
||||
const nonce = randomHex(16);
|
||||
const placeholderJson = JSON.stringify(placeholder);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -173,7 +202,7 @@ export function buildEditorHtml(opts: {
|
||||
</style>
|
||||
</head>
|
||||
<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}">
|
||||
(function () {
|
||||
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',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
const SAFE_ATTR_NAME = /^[a-zA-Z_:][-a-zA-Z0-9_:.]*$/;
|
||||
|
||||
@@ -225,7 +238,7 @@ function rewriteTags(
|
||||
close.lastIndex = i;
|
||||
const m = close.exec(html);
|
||||
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;
|
||||
}
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user