fix(ET-5551) Sanitize content pasted into composer

This commit is contained in:
Jacek Krasiukianis
2025-12-04 15:15:24 +01:00
parent 52efbc91cd
commit 04eec1190d
6 changed files with 51 additions and 102 deletions
@@ -40,6 +40,7 @@ extension HtmlBodyDocument {
static let cursorPositionX = "x"
static let cursorPositionY = "y"
static let imageData = "imageData"
static let mimeType = "mimeType"
static let text = "text"
}
@@ -137,11 +138,11 @@ extension HtmlBodyDocument {
// --------------------
document.getElementById('\(ID.editor)').addEventListener('focus', function(){
window.webkit.messageHandlers.\(JSEvent.focus).postMessage({ "messageHandler": "\(JSEvent.focus)" });
window.webkit.messageHandlers.\(JSEvent.focus).postMessage({});
});
document.getElementById('\(ID.editor)').addEventListener('input', function(event){
window.webkit.messageHandlers.\(JSEvent.editorChanged).postMessage({ "messageHandler": "\(JSEvent.editorChanged)" });
window.webkit.messageHandlers.\(JSEvent.editorChanged).postMessage({});
handleUpdateCursorPosition(event);
});
@@ -160,7 +161,6 @@ extension HtmlBodyDocument {
if (src && src.startsWith('cid:')) {
const cid = src.substring(4);
window.webkit.messageHandlers.\(JSEvent.inlineImageRemoved).postMessage({
"messageHandler": "\(JSEvent.inlineImageRemoved)",
"cid": cid
});
}
@@ -179,13 +179,12 @@ extension HtmlBodyDocument {
const rect = img.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
const x = rect.left + window.scrollX;
const y = rect.top + window.scrollY;
const x = rect.left + window.scrollX;
const y = rect.top + window.scrollY;
const cid = src.substring(4);
window.webkit.messageHandlers.\(JSEvent.inlineImageTapped).postMessage({
"messageHandler": "\(JSEvent.inlineImageTapped)",
"cid": cid,
"x": x,
"y": y,
@@ -228,8 +227,8 @@ extension HtmlBodyDocument {
handleTextPaste(chosenTextItem);
}
// Pasting could push some content above the visible area of the editor, to avoid
// this we reset scrolling attributes.
// Pasting could push some content above the visible area of the editor, to avoid
// this we reset scrolling attributes.
setTimeout(() => {
resetAllScrollPositions();
notifyHeightChange();
@@ -244,7 +243,6 @@ extension HtmlBodyDocument {
const base64data = event.target.result.split(',')[1];
window.webkit.messageHandlers.\(JSEvent.imagePasted).postMessage({
"messageHandler": "\(JSEvent.imagePasted)",
"\(EventAttributeKey.imageData)": base64data
});
};
@@ -254,8 +252,8 @@ extension HtmlBodyDocument {
function handleTextPaste(item) {
item.getAsString((text) => {
window.webkit.messageHandlers.\(JSEvent.textPasted).postMessage({
"messageHandler": "\(JSEvent.textPasted)",
"\(EventAttributeKey.text)": text
"\(EventAttributeKey.text)": text,
"\(EventAttributeKey.mimeType)": item.type
});
});
}
@@ -302,11 +300,11 @@ extension HtmlBodyDocument {
const images = editor.getElementsByTagName('img');
const exactCidPattern = 'cid:' + cid + '(?![0-9a-zA-Z])'; // Matches exact CID
const cidRegex = new RegExp(exactCidPattern);
for (let i = images.length - 1; i >= 0; i--) {
const img = images[i];
const attributes = img.attributes;
for (let j = 0; j < attributes.length; j++) {
const attr = attributes[j];
if (cidRegex.test(attr.value)) {
@@ -315,7 +313,7 @@ extension HtmlBodyDocument {
}
}
}
editor.dispatchEvent(new Event('input'));
};
@@ -329,9 +327,8 @@ extension HtmlBodyDocument {
// editor.offsetHeight: The visible height of the editor component as currently rendered
// we want offsetHeight when the content is shorter than the actual component
const height = Math.max(editor.scrollHeight, editor.offsetHeight);
window.webkit.messageHandlers.\(JSEvent.bodyResize).postMessage({
"messageHandler": "\(JSEvent.bodyResize)",
"\(EventAttributeKey.height)": height
});
}
@@ -372,19 +369,19 @@ extension HtmlBodyDocument {
// Create a temporary span with a zero-width character
const span = document.createElement('span');
span.appendChild(document.createTextNode('\\u200b'));
// Insert the span
range.insertNode(span);
// Get position
let rect = span.getBoundingClientRect();
// If we got a zero position and we're at the start/end of a node,
// try to get position from adjacent content
if (rect.y === 0) {
const previousNode = node.previousSibling;
const nextNode = node.nextSibling;
if (offset === 0 && previousNode) {
// Try to get position from end of previous node
rect = previousNode.getBoundingClientRect();
@@ -396,19 +393,19 @@ extension HtmlBodyDocument {
rect = nextNode.getBoundingClientRect();
}
}
// Remove the span but keep the selection
const parent = span.parentNode;
const next = span.nextSibling;
parent.removeChild(span);
// Restore selection
const newRange = document.createRange();
newRange.setStart(next || parent, 0);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
// Only return position if we actually found one
return rect.y === 0 ? null : {x: rect.x, y: rect.y};
}
@@ -416,10 +413,9 @@ extension HtmlBodyDocument {
function updateCursorPosition() {
const position = getCursorCoordinates();
if (!position) return;
if (position) {
window.webkit.messageHandlers.\(JSEvent.cursorPositionChanged).postMessage({
"messageHandler": "\(JSEvent.cursorPositionChanged)",
"\(EventAttributeKey.cursorPosition)": {
"\(EventAttributeKey.cursorPositionX)": position.x,
"\(EventAttributeKey.cursorPositionY)": position.y
@@ -113,10 +113,10 @@ final class HtmlBodyEditorController: UIViewController, BodyEditor {
return
}
onEvent?(.onImagePasted(image: image))
case .onTextPasted(let text):
let styleStrippedText = HtmlSanitizer.removeStyleAttribute(html: text)
let sanitisedText = HtmlSanitizer.applyStringLiteralEscapingRules(html: styleStrippedText)
handleBodyAction(.insertText(text: sanitisedText))
case .onTextPasted(let text, let mimeType):
let sanitizedText = sanitizePastedContent(content: text, mimeType: mimeType)
let escapedSanitizedText = HtmlSanitizer.applyStringLiteralEscapingRules(html: sanitizedText)
handleBodyAction(.insertText(text: escapedSanitizedText))
}
}
}
@@ -29,7 +29,7 @@ final class HtmlBodyWebViewInterface: NSObject, HtmlBodyWebViewInterfaceProtocol
case onInlineImageRemoved(cid: String)
case onInlineImageTapped(cid: String, imageRect: CGRect)
case onImagePasted(image: Data)
case onTextPasted(text: String)
case onTextPasted(text: String, mimeType: MessageMimeType)
}
let webView: WKWebView
@@ -178,8 +178,7 @@ final class HtmlBodyWebViewInterface: NSObject, HtmlBodyWebViewInterfaceProtocol
func handleScriptMessage(_ message: WKScriptMessage) {
let userInfo = message.body as! [String: Any]
let messageHandler = userInfo["messageHandler"] as! String
let jsEvent = HtmlBodyDocument.JSEvent(rawValue: messageHandler)!
let jsEvent = HtmlBodyDocument.JSEvent(rawValue: message.name)!
switch jsEvent {
case .bodyResize:
@@ -210,8 +209,8 @@ final class HtmlBodyWebViewInterface: NSObject, HtmlBodyWebViewInterfaceProtocol
guard let data = readImageData(from: userInfo) else { return }
onEvent?(.onImagePasted(image: data))
case .textPasted:
guard let text = readText(from: userInfo) else { return }
onEvent?(.onTextPasted(text: text))
guard let (text, mimeType) = readText(from: userInfo) else { return }
onEvent?(.onTextPasted(text: text, mimeType: mimeType))
}
}
@@ -236,12 +235,16 @@ final class HtmlBodyWebViewInterface: NSObject, HtmlBodyWebViewInterfaceProtocol
return data
}
private func readText(from dict: [String: Any]) -> String? {
guard let text = dict[HtmlBodyDocument.EventAttributeKey.text] as? String else {
private func readText(from dict: [String: Any]) -> (String, MessageMimeType)? {
guard
let text = dict[HtmlBodyDocument.EventAttributeKey.text] as? String,
let rawMimeType = dict[HtmlBodyDocument.EventAttributeKey.mimeType] as? String,
let mimeType = MessageMimeType(rawValue: rawMimeType)
else {
AppLogger.log(message: "no text retrieved", category: .composer, isError: true)
return nil
}
return text
return (text, mimeType)
}
}
@@ -258,3 +261,16 @@ private class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
target?.handleScriptMessage(message)
}
}
private extension MessageMimeType {
init?(rawValue: String) {
switch rawValue {
case "text/plain":
self = .textPlain
case "text/html":
self = .textHtml
default:
return nil
}
}
}
@@ -26,14 +26,4 @@ struct HtmlSanitizer {
let sanitized = String(data: jsonEncodedText, encoding: .utf8)!
return sanitized
}
static func removeStyleAttribute(html: String) -> String {
let regex = try! NSRegularExpression(
pattern: #"(?<![\w-])style\s*=\s*(?:"[^"]*"|'[^']*')"#,
options: .caseInsensitive
)
let range = NSRange(location: 0, length: html.utf16.count)
let sanitizedString = regex.stringByReplacingMatches(in: html, options: [], range: range, withTemplate: "")
return sanitizedString
}
}
@@ -49,10 +49,10 @@ final class HtmlBodyEditorControllerTests {
func onEventTextPasted_sanitizesAndCallsInsertText() async {
triggerViewDidLoad()
let raw = "<span style=\"color:red;\">Hello</span>"
mockInterface.onEvent?(.onTextPasted(text: raw))
mockInterface.onEvent?(.onTextPasted(text: raw, mimeType: .textHtml))
await Task.yield()
#expect(mockInterface.insertedTexts == ["\"<span >Hello<\\/span>\""])
#expect(mockInterface.insertedTexts == [#""<span>Hello<\/span>""#])
}
}
@@ -22,59 +22,6 @@ import proton_app_uniffi
@testable import InboxComposer
final class HtmlSanitizerTests {
@Test(
"removes style attribute correctly",
arguments: [
(
"when empty html",
"",
""
),
(
"when no style attributes",
"<p>Hello</p>",
"<p>Hello</p>"
),
(
"when single style attribute",
"<p style=\"color:red;\">Hello</p>",
"<p >Hello</p>"
),
(
"when multiple style attributes",
"<div style=\"margin:10px;\"><span style=\"color:blue;\">Text</span></div>",
"<div ><span >Text</span></div>"
),
(
"when style in uppercase",
"<p STYLE=\"color:red;\">Hello</p>",
"<p >Hello</p>"
),
(
"when more complex CSS",
"<p style=\"color:red; font-size:14px; background:#fff;\">Hello</p>",
"<p >Hello</p>"
),
(
"when multiple different attributes",
"<p class=\"text\" style=\"color:red;\" id=\"p1\">Hello</p>",
"<p class=\"text\" id=\"p1\">Hello</p>"
),
(
"when attribute with style in its name, it keeps it",
"<p data-style=\"foo:bar;\" style=\"color:red;\">Hello</p>",
"<p data-style=\"foo:bar;\" >Hello</p>"
),
(
"when img tag has a style attribute",
"<div style=\"background:red;\"><p>Here is an image: <img src=\"image.png\" style=\"width:100px; height:auto;\" alt=\"image\"></p></div>",
"<div ><p>Here is an image: <img src=\"image.png\" alt=\"image\"></p></div>"
),
])
func removeStyleAttribute(context: String, input: String, expected: String) {
#expect(HtmlSanitizer.removeStyleAttribute(html: input) == expected, Comment(rawValue: context))
}
@Test(
"applies JS string literal escaping rules",
arguments: [