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 : : #include <QWebEngineUrlRequestInterceptor>
9 : : #include <QWebEngineUrlRequestInfo>
10 : :
11 [ + + + - : 6 : Q_LOGGING_CATEGORY(lcSanitizer, "mailjd.sanitizer")
+ - - - ]
12 : :
13 : : namespace {
14 : : // SEC-2026-07-21-06 / SEC-2026-07-21-07: Defense-in-depth interceptor that
15 : : // blocks every outgoing network request from the sanitizer profile. DOMPurify
16 : : // parses dirty HTML in a detached DOMParser document before stripping nodes;
17 : : // depending on the Chromium version that backs QtWebEngine, media/source
18 : : // elements in that transient parse tree could theoretically trigger resource
19 : : // loads. A live probe in the audit (Qt 6.8.2) observed zero requests, but this
20 : : // interceptor guarantees the property independently of the engine version and
21 : : // also limits the blast radius of a future DOMPurify/Chromium JS primitive.
22 : : class BlockAllRequestsInterceptor : public QWebEngineUrlRequestInterceptor {
23 : : public:
24 : 11 : explicit BlockAllRequestsInterceptor(QObject *parent = nullptr)
25 : 11 : : QWebEngineUrlRequestInterceptor(parent) {}
26 : :
27 : 11 : void interceptRequest(QWebEngineUrlRequestInfo &info) override {
28 : : // Only block external network schemes. Internal Chromium schemes (data:,
29 : : // blob:, about:, qrc:, chrome-extension:) are needed for the page to
30 : : // initialize and carry no network-exfiltration risk.
31 [ + - + - ]: 11 : const QString scheme = info.requestUrl().scheme();
32 [ + - + - ]: 33 : if (scheme == QLatin1String("http") || scheme == QLatin1String("https") ||
33 [ + - + - ]: 33 : scheme == QLatin1String("ws") || scheme == QLatin1String("wss") ||
34 [ + - - + : 33 : scheme == QLatin1String("ftp") || scheme == QLatin1String("file")) {
- + ]
35 [ # # ]: 0 : info.block(true);
36 : : }
37 : 11 : }
38 : : };
39 : : } // namespace
40 : :
41 : 11 : HtmlSanitizer::HtmlSanitizer(QObject *parent) : QObject(parent) {}
42 : :
43 : 22 : HtmlSanitizer::~HtmlSanitizer() = default;
44 : :
45 : 11 : void HtmlSanitizer::init() {
46 [ - + ]: 11 : if (m_page)
47 : 0 : return; // Already initialized
48 : :
49 : : // Use off-the-record profile (no persistent storage)
50 [ + - + - : 11 : auto *profile = new QWebEngineProfile(this);
- + - - ]
51 : :
52 : : // SEC-2026-07-21-06: Install a network-blocking request interceptor as a
53 : : // second barrier on top of AutoLoadImages=false and the CSP meta below. We
54 : : // only block external network schemes (http/https/ws/wss/ftp), not internal
55 : : // Chromium requests (data:, blob:, qrc:, about:) — those are needed for the
56 : : // page itself to initialize. This still guarantees that no dirty-HTML-driven
57 : : // resource can escape to the network.
58 [ + - + - : 11 : profile->setUrlRequestInterceptor(new BlockAllRequestsInterceptor(profile));
+ - - + -
- ]
59 : :
60 [ + - + - : 11 : m_page = new QWebEnginePage(profile, this);
- + - - ]
61 : :
62 : : // Enable JS on the sanitizer page (needed for DOMPurify)
63 [ + - + - ]: 11 : m_page->settings()->setAttribute(QWebEngineSettings::JavascriptEnabled, true);
64 : : // Disable everything else for security
65 [ + - + - ]: 11 : m_page->settings()->setAttribute(QWebEngineSettings::AutoLoadImages, false);
66 [ + - + - ]: 11 : m_page->settings()->setAttribute(QWebEngineSettings::LocalContentCanAccessRemoteUrls, false);
67 [ + - + - ]: 11 : m_page->settings()->setAttribute(QWebEngineSettings::LocalContentCanAccessFileUrls, false);
68 : : // SEC-2026-07-21-07: explicitly disable capabilities that are not needed for
69 : : // sanitizing, limiting the impact of a hypothetical JS primitive.
70 [ + - + - ]: 11 : m_page->settings()->setAttribute(QWebEngineSettings::JavascriptCanAccessClipboard, false);
71 [ + - + - ]: 11 : m_page->settings()->setAttribute(QWebEngineSettings::PluginsEnabled, false);
72 [ + - + - ]: 11 : m_page->settings()->setAttribute(QWebEngineSettings::WebGLEnabled, false);
73 [ + - + - ]: 11 : m_page->settings()->setAttribute(QWebEngineSettings::Accelerated2dCanvasEnabled, false);
74 [ + - + - ]: 11 : m_page->settings()->setAttribute(QWebEngineSettings::ScreenCaptureEnabled, false);
75 [ + - + - ]: 11 : m_page->settings()->setAttribute(QWebEngineSettings::WebRTCPublicInterfacesOnly, true);
76 [ + - + - ]: 11 : m_page->settings()->setAttribute(QWebEngineSettings::DnsPrefetchEnabled, false);
77 : :
78 : : // Load DOMPurify from Qt resources
79 [ + - ]: 11 : QFile jsFile(QStringLiteral(":/dompurify/purify.min.js"));
80 [ + - - + ]: 11 : if (!jsFile.open(QIODevice::ReadOnly)) {
81 [ # # # # : 0 : qCCritical(lcSanitizer) << "Failed to load DOMPurify resource";
# # # # ]
82 : 0 : return;
83 : : }
84 [ + - + - ]: 11 : QString dompurifyJs = QString::fromUtf8(jsFile.readAll());
85 : :
86 : : // SEC-2026-07-21-06: strict CSP — deny all resource loads except inline
87 : : // script (needed for DOMPurify). default-src 'none' blocks every fetch,
88 : : // image, font, media and frame; script-src 'unsafe-inline' is required
89 : : // because DOMPurify is inlined in the page. This is defense-in-depth: the
90 : : // block-all interceptor and AutoLoadImages=false already prevent loads, but
91 : : // the CSP also constrains any engine path that bypasses the interceptor.
92 : 22 : QString html = QStringLiteral(
93 : : "<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
94 : : "<meta http-equiv=\"Content-Security-Policy\" "
95 : : "content=\"default-src 'none'; script-src 'unsafe-inline'; "
96 : : "style-src 'unsafe-inline'; img-src data:;\">"
97 : : "<script>%1</script></head><body></body></html>")
98 [ + - ]: 11 : .arg(dompurifyJs);
99 : :
100 : : // Load the page and wait for it to finish
101 : 11 : connect(m_page, &QWebEnginePage::loadFinished, this,
102 [ + - ]: 11 : [this](bool ok) {
103 [ - + ]: 6 : if (!ok) {
104 [ # # # # : 0 : qCCritical(lcSanitizer) << "DOMPurify page load failed";
# # # # ]
105 : 0 : return;
106 : : }
107 : :
108 : : // Verify DOMPurify is available
109 [ + - ]: 6 : m_page->runJavaScript(
110 : 12 : QStringLiteral("typeof DOMPurify !== 'undefined'"),
111 : 12 : [this](const QVariant &result) {
112 [ + - ]: 6 : if (result.toBool()) {
113 [ + - + - : 12 : qCInfo(lcSanitizer) << "DOMPurify sanitizer ready";
+ - + + ]
114 : 6 : m_ready = true;
115 : 6 : emit ready();
116 : :
117 : : // Process any queued request
118 [ + + ]: 6 : if (m_pendingCallback) {
119 [ + - ]: 1 : sanitize(m_pendingHtml, std::move(m_pendingCallback));
120 : 1 : m_pendingHtml.clear();
121 : 1 : m_pendingCallback = nullptr;
122 : : }
123 : : } else {
124 [ # # # # : 0 : qCCritical(lcSanitizer) << "DOMPurify not found after page load";
# # # # ]
125 : : }
126 : 6 : });
127 : : });
128 : :
129 [ + - + - ]: 11 : m_page->setHtml(html);
130 [ + - ]: 11 : }
131 : :
132 : 32 : void HtmlSanitizer::sanitize(
133 : : const QString &dirtyHtml,
134 : : std::function<void(const QString &cleanHtml)> callback) {
135 : :
136 [ - + ]: 32 : if (!m_page) {
137 [ # # # # : 0 : qCWarning(lcSanitizer) << "Sanitizer not initialized, call init() first";
# # # # ]
138 [ # # ]: 0 : if (callback)
139 [ # # ]: 0 : callback(QString()); // Return empty to be safe
140 : 2 : return;
141 : : }
142 : :
143 [ + + ]: 32 : if (!m_ready) {
144 : : // Queue the request until DOMPurify is loaded
145 : 2 : m_pendingHtml = dirtyHtml;
146 : 2 : m_pendingCallback = std::move(callback);
147 : 2 : return;
148 : : }
149 : :
150 : : // Escape the HTML for safe embedding in a JS string literal.
151 : : // We use JSON-style escaping by wrapping in a template.
152 : 30 : QString escaped = dirtyHtml;
153 [ + - ]: 30 : escaped.replace(QLatin1Char('\\'), QStringLiteral("\\\\"));
154 [ + - ]: 30 : escaped.replace(QLatin1Char('`'), QStringLiteral("\\`"));
155 [ + - ]: 60 : escaped.replace(QStringLiteral("${"), QStringLiteral("\\${"));
156 : :
157 : : // DOMPurify config:
158 : : // - FORBID_TAGS: dangerous tags that should never appear in email
159 : : // - FORBID_ATTR: event handlers and other dangerous attributes
160 : : // - ALLOW_DATA_ATTR: false — no data-* attributes
161 : : // - ADD_ATTR: allow 'target' for links (mailto: etc.)
162 : 60 : QString js = QStringLiteral(
163 : : "(function() {"
164 : : " try {"
165 : : " var dirty = `%1`;"
166 : : " var clean = DOMPurify.sanitize(dirty, {"
167 : : " FORBID_TAGS: ['style', 'math', 'svg', 'form', 'input',"
168 : : " 'textarea', 'select', 'button', 'object',"
169 : : " 'embed', 'applet', 'base', 'meta', 'link'],"
170 : : " FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover',"
171 : : " 'onfocus', 'onblur', 'onsubmit'],"
172 : : " ALLOW_DATA_ATTR: false,"
173 : : " ADD_ATTR: ['target']"
174 : : " });"
175 : : " return clean;"
176 : : " } catch(e) {"
177 : : " return '';"
178 : : " }"
179 : : "})()")
180 [ + - ]: 30 : .arg(escaped);
181 : :
182 [ + - + - : 30 : m_page->runJavaScript(js, [callback](const QVariant &result) {
+ - ]
183 [ + - ]: 30 : QString clean = result.toString();
184 [ + - ]: 30 : if (callback)
185 [ + - ]: 30 : callback(clean);
186 : 30 : });
187 : 30 : }
|