Branch data Line data Source code
1 : : #include "HtmlSanitizer.h"
2 : :
3 : : #include <QFile>
4 : : #include <QLoggingCategory>
5 : : #include <QWebEnginePage>
6 : : #include <QWebEngineProfile>
7 : : #include <QWebEngineSettings>
8 : :
9 [ + + + - : 5 : Q_LOGGING_CATEGORY(lcSanitizer, "mailjd.sanitizer")
+ - - - ]
10 : :
11 : 10 : HtmlSanitizer::HtmlSanitizer(QObject *parent) : QObject(parent) {}
12 : :
13 : 20 : HtmlSanitizer::~HtmlSanitizer() = default;
14 : :
15 : 10 : void HtmlSanitizer::init() {
16 [ - + ]: 10 : if (m_page)
17 : 0 : return; // Already initialized
18 : :
19 : : // Use off-the-record profile (no persistent storage)
20 [ + - + - : 10 : auto *profile = new QWebEngineProfile(this);
- + - - ]
21 : :
22 [ + - + - : 10 : m_page = new QWebEnginePage(profile, this);
- + - - ]
23 : :
24 : : // Enable JS on the sanitizer page (needed for DOMPurify)
25 [ + - + - ]: 10 : m_page->settings()->setAttribute(QWebEngineSettings::JavascriptEnabled, true);
26 : : // Disable everything else for security
27 [ + - + - ]: 10 : m_page->settings()->setAttribute(QWebEngineSettings::AutoLoadImages, false);
28 [ + - + - ]: 10 : m_page->settings()->setAttribute(QWebEngineSettings::LocalContentCanAccessRemoteUrls, false);
29 [ + - + - ]: 10 : m_page->settings()->setAttribute(QWebEngineSettings::LocalContentCanAccessFileUrls, false);
30 : :
31 : : // Load DOMPurify from Qt resources
32 [ + - ]: 10 : QFile jsFile(QStringLiteral(":/dompurify/purify.min.js"));
33 [ + - - + ]: 10 : if (!jsFile.open(QIODevice::ReadOnly)) {
34 [ # # # # : 0 : qCCritical(lcSanitizer) << "Failed to load DOMPurify resource";
# # # # ]
35 : 0 : return;
36 : : }
37 [ + - + - ]: 10 : QString dompurifyJs = QString::fromUtf8(jsFile.readAll());
38 : :
39 : : // Create minimal HTML page with DOMPurify embedded
40 : 20 : QString html = QStringLiteral(
41 : : "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
42 : : "<script>%1</script></head><body></body></html>")
43 [ + - ]: 10 : .arg(dompurifyJs);
44 : :
45 : : // Load the page and wait for it to finish
46 : 10 : connect(m_page, &QWebEnginePage::loadFinished, this,
47 [ + - ]: 10 : [this](bool ok) {
48 [ - + ]: 5 : if (!ok) {
49 [ # # # # : 0 : qCCritical(lcSanitizer) << "DOMPurify page load failed";
# # # # ]
50 : 0 : return;
51 : : }
52 : :
53 : : // Verify DOMPurify is available
54 [ + - ]: 5 : m_page->runJavaScript(
55 : 10 : QStringLiteral("typeof DOMPurify !== 'undefined'"),
56 : 10 : [this](const QVariant &result) {
57 [ + - ]: 5 : if (result.toBool()) {
58 [ + - + - : 10 : qCInfo(lcSanitizer) << "DOMPurify sanitizer ready";
+ - + + ]
59 : 5 : m_ready = true;
60 : 5 : emit ready();
61 : :
62 : : // Process any queued request
63 [ + + ]: 5 : if (m_pendingCallback) {
64 [ + - ]: 1 : sanitize(m_pendingHtml, std::move(m_pendingCallback));
65 : 1 : m_pendingHtml.clear();
66 : 1 : m_pendingCallback = nullptr;
67 : : }
68 : : } else {
69 [ # # # # : 0 : qCCritical(lcSanitizer) << "DOMPurify not found after page load";
# # # # ]
70 : : }
71 : 5 : });
72 : : });
73 : :
74 [ + - + - ]: 10 : m_page->setHtml(html);
75 [ + - ]: 10 : }
76 : :
77 : 17 : void HtmlSanitizer::sanitize(
78 : : const QString &dirtyHtml,
79 : : std::function<void(const QString &cleanHtml)> callback) {
80 : :
81 [ - + ]: 17 : if (!m_page) {
82 [ # # # # : 0 : qCWarning(lcSanitizer) << "Sanitizer not initialized, call init() first";
# # # # ]
83 [ # # ]: 0 : if (callback)
84 [ # # ]: 0 : callback(QString()); // Return empty to be safe
85 : 2 : return;
86 : : }
87 : :
88 [ + + ]: 17 : if (!m_ready) {
89 : : // Queue the request until DOMPurify is loaded
90 : 2 : m_pendingHtml = dirtyHtml;
91 : 2 : m_pendingCallback = std::move(callback);
92 : 2 : return;
93 : : }
94 : :
95 : : // Escape the HTML for safe embedding in a JS string literal.
96 : : // We use JSON-style escaping by wrapping in a template.
97 : 15 : QString escaped = dirtyHtml;
98 [ + - ]: 15 : escaped.replace(QLatin1Char('\\'), QStringLiteral("\\\\"));
99 [ + - ]: 15 : escaped.replace(QLatin1Char('`'), QStringLiteral("\\`"));
100 [ + - ]: 30 : escaped.replace(QStringLiteral("${"), QStringLiteral("\\${"));
101 : :
102 : : // DOMPurify config:
103 : : // - FORBID_TAGS: dangerous tags that should never appear in email
104 : : // - FORBID_ATTR: event handlers and other dangerous attributes
105 : : // - ALLOW_DATA_ATTR: false — no data-* attributes
106 : : // - ADD_ATTR: allow 'target' for links (mailto: etc.)
107 : 30 : QString js = QStringLiteral(
108 : : "(function() {"
109 : : " try {"
110 : : " var dirty = `%1`;"
111 : : " var clean = DOMPurify.sanitize(dirty, {"
112 : : " FORBID_TAGS: ['style', 'math', 'svg', 'form', 'input',"
113 : : " 'textarea', 'select', 'button', 'object',"
114 : : " 'embed', 'applet', 'base', 'meta', 'link'],"
115 : : " FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover',"
116 : : " 'onfocus', 'onblur', 'onsubmit'],"
117 : : " ALLOW_DATA_ATTR: false,"
118 : : " ADD_ATTR: ['target']"
119 : : " });"
120 : : " return clean;"
121 : : " } catch(e) {"
122 : : " return '';"
123 : : " }"
124 : : "})()")
125 [ + - ]: 15 : .arg(escaped);
126 : :
127 [ + - + - : 15 : m_page->runJavaScript(js, [callback](const QVariant &result) {
+ - ]
128 [ + - ]: 15 : QString clean = result.toString();
129 [ + - ]: 15 : if (callback)
130 [ + - ]: 15 : callback(clean);
131 : 15 : });
132 : 15 : }
|