mirror of
https://github.com/ProtonMail/ios-mail.git
synced 2026-05-15 09:50:39 +00:00
fix(ET-5551) Sanitize content pasted into composer
This commit is contained in:
+21
-25
@@ -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
|
||||
|
||||
+4
-4
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-8
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user