ui/HtmlSanitizer.cpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #include "HtmlSanitizer.h" | ||
| 2 | #include "util/Utf8ByteCount.h" | ||
| 3 | |||
| 4 | #include <QFile> | ||
| 5 | #include <QCoreApplication> | ||
| 6 | #include <utility> | ||
| 7 | #include <QCryptographicHash> | ||
| 8 | #include <QTimer> | ||
| 9 | #include <QJsonArray> | ||
| 10 | #include <QJsonDocument> | ||
| 11 | #include <QJsonObject> | ||
| 12 | #include <QLoggingCategory> | ||
| 13 | #include <QPointer> | ||
| 14 | #include <QFutureWatcher> | ||
| 15 | #include <QWebEnginePage> | ||
| 16 | #include <QWebEngineProfile> | ||
| 17 | #include <QWebEngineSettings> | ||
| 18 | #include <QWebEngineUrlRequestInterceptor> | ||
| 19 | #include <QWebEngineUrlRequestInfo> | ||
| 20 | #include <QtConcurrent> | ||
| 21 | |||
| 22 |
4/7✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 8 taken 52 times.
✓ Branch 4 → 5 taken 2 times.
✗ Branch 4 → 8 not taken.
✓ Branch 5 → 6 taken 2 times.
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 12 not taken.
|
54 | Q_LOGGING_CATEGORY(lcSanitizer, "mailjd.sanitizer") |
| 23 | |||
| 24 | namespace { | ||
| 25 | // SEC-2026-07-21-06 / SEC-2026-07-21-07: Defense-in-depth interceptor that | ||
| 26 | // blocks every outgoing network request from the sanitizer profile. DOMPurify | ||
| 27 | // parses dirty HTML in a detached DOMParser document before stripping nodes; | ||
| 28 | // depending on the Chromium version that backs QtWebEngine, media/source | ||
| 29 | // elements in that transient parse tree could theoretically trigger resource | ||
| 30 | // loads. A live probe in the audit (Qt 6.8.2) observed zero requests, but this | ||
| 31 | // interceptor guarantees the property independently of the engine version and | ||
| 32 | // also limits the blast radius of a future DOMPurify/Chromium JS primitive. | ||
| 33 | class BlockAllRequestsInterceptor : public QWebEngineUrlRequestInterceptor { | ||
| 34 | public: | ||
| 35 | 12 | explicit BlockAllRequestsInterceptor(QObject *parent = nullptr) | |
| 36 | 12 | : QWebEngineUrlRequestInterceptor(parent) {} | |
| 37 | |||
| 38 | 11 | void interceptRequest(QWebEngineUrlRequestInfo &info) override { | |
| 39 | // Only block external network schemes. Internal Chromium schemes (data:, | ||
| 40 | // blob:, about:, qrc:, chrome-extension:) are needed for the page to | ||
| 41 | // initialize and carry no network-exfiltration risk. | ||
| 42 |
2/2✓ Branch 2 → 3 taken 11 times.
✓ Branch 3 → 4 taken 11 times.
|
11 | const QString scheme = info.requestUrl().scheme(); |
| 43 |
2/4✓ Branch 10 → 11 taken 11 times.
✗ Branch 10 → 23 not taken.
✓ Branch 13 → 14 taken 11 times.
✗ Branch 13 → 23 not taken.
|
33 | if (scheme == QLatin1String("http") || scheme == QLatin1String("https") || |
| 44 |
2/4✓ Branch 16 → 17 taken 11 times.
✗ Branch 16 → 23 not taken.
✓ Branch 19 → 20 taken 11 times.
✗ Branch 19 → 23 not taken.
|
33 | scheme == QLatin1String("ws") || scheme == QLatin1String("wss") || |
| 45 |
3/6✓ Branch 7 → 8 taken 11 times.
✗ Branch 7 → 23 not taken.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 11 times.
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 27 taken 11 times.
|
33 | scheme == QLatin1String("ftp") || scheme == QLatin1String("file")) { |
| 46 | ✗ | info.block(true); | |
| 47 | } | ||
| 48 | 11 | } | |
| 49 | }; | ||
| 50 | } // namespace | ||
| 51 | |||
| 52 |
2/2✓ Branch 3 → 4 taken 12 times.
✓ Branch 11 → 12 taken 12 times.
|
12 | HtmlSanitizer::HtmlSanitizer(QObject *parent) : QObject(parent) { |
| 53 |
1/1✓ Branch 12 → 13 taken 12 times.
|
12 | m_serializationPool.setMaxThreadCount(1); |
| 54 |
1/1✓ Branch 13 → 14 taken 12 times.
|
12 | m_serializationPool.setExpiryTimeout(-1); |
| 55 |
1/1✓ Branch 14 → 15 taken 12 times.
|
12 | m_idleRelease.setSingleShot(true); |
| 56 |
1/1✓ Branch 15 → 16 taken 12 times.
|
12 | connect(&m_idleRelease, &QTimer::timeout, this, [this] { |
| 57 |
1/2✓ Branch 3 → 4 taken 3 times.
✗ Branch 3 → 8 not taken.
|
3 | if (m_state != InitializationState::Ready || m_activeRequestId || |
| 58 |
4/8✓ Branch 2 → 3 taken 3 times.
✗ Branch 2 → 8 not taken.
✓ Branch 5 → 6 taken 3 times.
✗ Branch 5 → 8 not taken.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 3 times.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 3 times.
|
6 | !m_pendingRequests.isEmpty() || !m_readyRequests.isEmpty()) return; |
| 59 | 3 | m_state = InitializationState::Dormant; | |
| 60 | 3 | releasePage(); | |
| 61 | }); | ||
| 62 |
1/1✓ Branch 18 → 19 taken 12 times.
|
12 | connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, |
| 63 | 12 | this, &HtmlSanitizer::shutdown); | |
| 64 | 12 | } | |
| 65 | |||
| 66 | 19 | HtmlSanitizer::~HtmlSanitizer() { shutdown(); } | |
| 67 | |||
| 68 | ✗ | HtmlSanitizer *HtmlSanitizer::shared() { | |
| 69 | ✗ | constexpr auto name = "mailjd-shared-html-sanitizer"; | |
| 70 | ✗ | auto *app = QCoreApplication::instance(); | |
| 71 | ✗ | if (auto *existing = app->findChild<HtmlSanitizer *>(QString::fromLatin1(name), Qt::FindDirectChildrenOnly)) | |
| 72 | ✗ | return existing; | |
| 73 | ✗ | auto *service = new HtmlSanitizer(app); | |
| 74 | ✗ | service->setObjectName(QString::fromLatin1(name)); | |
| 75 | ✗ | service->init(); | |
| 76 | ✗ | return service; | |
| 77 | } | ||
| 78 | |||
| 79 | 15 | void HtmlSanitizer::shutdown() { | |
| 80 |
2/2✓ Branch 2 → 3 taken 3 times.
✓ Branch 2 → 4 taken 12 times.
|
15 | if (m_state == InitializationState::Destroying) return; |
| 81 | 12 | m_state = InitializationState::Destroying; | |
| 82 | 12 | m_idleRelease.stop(); | |
| 83 |
1/2✗ Branch 6 → 7 not taken.
✓ Branch 6 → 10 taken 12 times.
|
12 | if (m_activeInput) m_activeInput->cancelled->store(true); |
| 84 | 12 | failAllRequests(); | |
| 85 |
1/1✓ Branch 12 → 13 taken 12 times.
|
12 | m_serializationPool.waitForDone(); |
| 86 | 12 | m_outputCache.clear(); | |
| 87 | 12 | releasePage(); | |
| 88 | } | ||
| 89 | |||
| 90 | 507 | void HtmlSanitizer::scheduleIdleRelease() { | |
| 91 |
6/6✓ Branch 3 → 4 taken 404 times.
✓ Branch 3 → 7 taken 103 times.
✓ Branch 5 → 6 taken 348 times.
✓ Branch 5 → 7 taken 56 times.
✓ Branch 8 → 9 taken 348 times.
✓ Branch 8 → 14 taken 159 times.
|
507 | if (m_readyRequests.isEmpty() && !m_idleRelease.isActive()) |
| 92 |
2/2✓ Branch 10 → 11 taken 346 times.
✓ Branch 10 → 12 taken 2 times.
|
348 | m_idleRelease.start(m_warmHolders.isEmpty() ? IdleRetentionMs : HeldRetentionMs); |
| 93 | 507 | } | |
| 94 | |||
| 95 | 4 | void HtmlSanitizer::holdWarm(const QObject *holder, bool hold) { | |
| 96 |
1/2✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 4 times.
|
4 | if (!holder) return; |
| 97 |
2/2✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 31 taken 2 times.
|
4 | if (hold) { |
| 98 |
1/2✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 2 times.
|
2 | if (m_warmHolders.contains(holder)) return; |
| 99 |
1/1✓ Branch 8 → 9 taken 2 times.
|
2 | m_warmHolders.insert(holder); |
| 100 |
1/1✓ Branch 9 → 10 taken 2 times.
|
2 | connect(holder, &QObject::destroyed, this, [this, holder] { holdWarm(holder, false); }); |
| 101 | // Re-arm with the held retention. | ||
| 102 |
1/2✗ Branch 12 → 13 not taken.
✓ Branch 12 → 15 taken 2 times.
|
2 | if (m_idleRelease.isActive()) { |
| 103 | ✗ | m_idleRelease.stop(); | |
| 104 | ✗ | scheduleIdleRelease(); | |
| 105 | } | ||
| 106 |
7/7✓ Branch 15 → 16 taken 2 times.
✓ Branch 19 → 20 taken 2 times.
✓ Branch 20 → 21 taken 2 times.
✓ Branch 22 → 23 taken 2 times.
✓ Branch 23 → 24 taken 2 times.
✓ Branch 27 → 17 taken 2 times.
✓ Branch 27 → 28 taken 2 times.
|
4 | qCDebug(lcSanitizer) << "Sanitizer held warm by" << m_warmHolders.size() << "reader(s)"; |
| 107 |
2/2✓ Branch 28 → 29 taken 1 time.
✓ Branch 28 → 30 taken 1 time.
|
2 | if (m_state == InitializationState::Dormant) init(); |
| 108 | 2 | return; | |
| 109 | } | ||
| 110 |
1/2✗ Branch 32 → 33 not taken.
✓ Branch 32 → 34 taken 2 times.
|
2 | if (!m_warmHolders.remove(holder)) return; |
| 111 | 2 | disconnect(holder, &QObject::destroyed, this, nullptr); | |
| 112 |
5/10✓ Branch 36 → 37 taken 2 times.
✗ Branch 36 → 42 not taken.
✓ Branch 37 → 38 taken 2 times.
✗ Branch 37 → 42 not taken.
✓ Branch 38 → 39 taken 2 times.
✗ Branch 38 → 42 not taken.
✓ Branch 40 → 41 taken 2 times.
✗ Branch 40 → 42 not taken.
✓ Branch 43 → 44 taken 2 times.
✗ Branch 43 → 46 not taken.
|
4 | if (m_warmHolders.isEmpty() && m_state == InitializationState::Ready && !m_activeRequestId && |
| 113 | 2 | m_pendingRequests.isEmpty()) { | |
| 114 | 2 | m_idleRelease.stop(); | |
| 115 | 2 | scheduleIdleRelease(); | |
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | 16 | void HtmlSanitizer::releasePage() { | |
| 120 | 16 | auto *page = std::exchange(m_page, nullptr); | |
| 121 | 16 | auto *profile = std::exchange(m_profile, nullptr); | |
| 122 |
2/2✓ Branch 4 → 5 taken 12 times.
✓ Branch 4 → 6 taken 4 times.
|
16 | if (page) QObject::disconnect(page, nullptr, this, nullptr); |
| 123 | // JavaScript completion may run during page destruction. Its page/state | ||
| 124 | // guard must reject that completion before accessing Chromium again. | ||
| 125 |
2/2✓ Branch 6 → 7 taken 12 times.
✓ Branch 6 → 8 taken 4 times.
|
16 | delete page; |
| 126 |
2/2✓ Branch 8 → 9 taken 12 times.
✓ Branch 8 → 11 taken 4 times.
|
16 | if (profile) { |
| 127 | 12 | profile->setParent(nullptr); | |
| 128 | 12 | profile->deleteLater(); | |
| 129 | } | ||
| 130 | 16 | } | |
| 131 | |||
| 132 | 13 | void HtmlSanitizer::init() { | |
| 133 |
3/4✓ Branch 2 → 3 taken 13 times.
✗ Branch 2 → 4 not taken.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 12 times.
|
13 | if (m_page || m_state == InitializationState::Destroying) |
| 134 | 1 | return; | |
| 135 |
1/1✓ Branch 5 → 6 taken 12 times.
|
12 | m_idleRelease.stop(); |
| 136 | |||
| 137 | 12 | m_state = InitializationState::Loading; | |
| 138 | |||
| 139 | // Use off-the-record profile (no persistent storage) | ||
| 140 |
3/6✓ Branch 6 → 7 taken 12 times.
✓ Branch 7 → 8 taken 12 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 12 times.
✗ Branch 125 → 126 not taken.
✗ Branch 125 → 127 not taken.
|
12 | auto *profile = new QWebEngineProfile(this); |
| 141 | 12 | m_profile = profile; | |
| 142 |
1/1✓ Branch 10 → 11 taken 12 times.
|
12 | profile->setHttpCacheType(QWebEngineProfile::NoCache); |
| 143 | |||
| 144 | // SEC-2026-07-21-06: Install a network-blocking request interceptor as a | ||
| 145 | // second barrier on top of AutoLoadImages=false and the CSP meta below. We | ||
| 146 | // only block external network schemes (http/https/ws/wss/ftp), not internal | ||
| 147 | // Chromium requests (data:, blob:, qrc:, about:) — those are needed for the | ||
| 148 | // page itself to initialize. This still guarantees that no dirty-HTML-driven | ||
| 149 | // resource can escape to the network. | ||
| 150 |
4/7✓ Branch 11 → 12 taken 12 times.
✓ Branch 12 → 13 taken 12 times.
✓ Branch 13 → 14 taken 12 times.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 12 times.
✗ Branch 128 → 129 not taken.
✗ Branch 128 → 130 not taken.
|
12 | profile->setUrlRequestInterceptor(new BlockAllRequestsInterceptor(profile)); |
| 151 | |||
| 152 |
3/6✓ Branch 16 → 17 taken 12 times.
✓ Branch 17 → 18 taken 12 times.
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 12 times.
✗ Branch 131 → 132 not taken.
✗ Branch 131 → 133 not taken.
|
12 | m_page = new QWebEnginePage(profile, this); |
| 153 | |||
| 154 | // Enable JS on the sanitizer page (needed for DOMPurify) | ||
| 155 |
2/2✓ Branch 20 → 21 taken 12 times.
✓ Branch 21 → 22 taken 12 times.
|
12 | m_page->settings()->setAttribute(QWebEngineSettings::JavascriptEnabled, true); |
| 156 | // Disable everything else for security | ||
| 157 |
2/2✓ Branch 22 → 23 taken 12 times.
✓ Branch 23 → 24 taken 12 times.
|
12 | m_page->settings()->setAttribute(QWebEngineSettings::AutoLoadImages, false); |
| 158 |
2/2✓ Branch 24 → 25 taken 12 times.
✓ Branch 25 → 26 taken 12 times.
|
12 | m_page->settings()->setAttribute(QWebEngineSettings::LocalContentCanAccessRemoteUrls, false); |
| 159 |
2/2✓ Branch 26 → 27 taken 12 times.
✓ Branch 27 → 28 taken 12 times.
|
12 | m_page->settings()->setAttribute(QWebEngineSettings::LocalContentCanAccessFileUrls, false); |
| 160 | // SEC-2026-07-21-07: explicitly disable capabilities that are not needed for | ||
| 161 | // sanitizing, limiting the impact of a hypothetical JS primitive. | ||
| 162 |
2/2✓ Branch 28 → 29 taken 12 times.
✓ Branch 29 → 30 taken 12 times.
|
12 | m_page->settings()->setAttribute(QWebEngineSettings::JavascriptCanAccessClipboard, false); |
| 163 |
2/2✓ Branch 30 → 31 taken 12 times.
✓ Branch 31 → 32 taken 12 times.
|
12 | m_page->settings()->setAttribute(QWebEngineSettings::PluginsEnabled, false); |
| 164 |
2/2✓ Branch 32 → 33 taken 12 times.
✓ Branch 33 → 34 taken 12 times.
|
12 | m_page->settings()->setAttribute(QWebEngineSettings::WebGLEnabled, false); |
| 165 |
2/2✓ Branch 34 → 35 taken 12 times.
✓ Branch 35 → 36 taken 12 times.
|
12 | m_page->settings()->setAttribute(QWebEngineSettings::Accelerated2dCanvasEnabled, false); |
| 166 |
2/2✓ Branch 36 → 37 taken 12 times.
✓ Branch 37 → 38 taken 12 times.
|
12 | m_page->settings()->setAttribute(QWebEngineSettings::ScreenCaptureEnabled, false); |
| 167 |
2/2✓ Branch 38 → 39 taken 12 times.
✓ Branch 39 → 40 taken 12 times.
|
12 | m_page->settings()->setAttribute(QWebEngineSettings::WebRTCPublicInterfacesOnly, true); |
| 168 |
2/2✓ Branch 40 → 41 taken 12 times.
✓ Branch 41 → 42 taken 12 times.
|
12 | m_page->settings()->setAttribute(QWebEngineSettings::DnsPrefetchEnabled, false); |
| 169 | |||
| 170 | // Load DOMPurify from Qt resources | ||
| 171 |
1/1✓ Branch 45 → 46 taken 12 times.
|
12 | QFile jsFile(QStringLiteral(":/dompurify/purify.min.js")); |
| 172 |
2/3✓ Branch 49 → 50 taken 12 times.
✗ Branch 50 → 51 not taken.
✓ Branch 50 → 58 taken 12 times.
|
12 | if (!jsFile.open(QIODevice::ReadOnly)) { |
| 173 | ✗ | failInitialization(QStringLiteral("Failed to load DOMPurify resource")); | |
| 174 | ✗ | return; | |
| 175 | } | ||
| 176 |
2/2✓ Branch 58 → 59 taken 12 times.
✓ Branch 59 → 60 taken 12 times.
|
12 | QString dompurifyJs = QString::fromUtf8(jsFile.readAll()); |
| 177 | // The CSS filter and cid: resolver applied after DOMPurify (shared with | ||
| 178 | // the fuzz target sanitizer_css). | ||
| 179 |
1/1✓ Branch 64 → 65 taken 12 times.
|
12 | QFile postprocessFile(QStringLiteral(":/sanitizer/postprocess.js")); |
| 180 |
2/3✓ Branch 68 → 69 taken 12 times.
✗ Branch 69 → 70 not taken.
✓ Branch 69 → 77 taken 12 times.
|
12 | if (!postprocessFile.open(QIODevice::ReadOnly)) { |
| 181 | ✗ | failInitialization(QStringLiteral("Failed to load the sanitizer post-processing resource")); | |
| 182 | ✗ | return; | |
| 183 | } | ||
| 184 |
4/4✓ Branch 77 → 78 taken 12 times.
✓ Branch 78 → 79 taken 12 times.
✓ Branch 81 → 82 taken 12 times.
✓ Branch 82 → 83 taken 12 times.
|
12 | dompurifyJs += QLatin1Char('\n') + QString::fromUtf8(postprocessFile.readAll()); |
| 185 | |||
| 186 | // SEC-2026-07-21-06: strict CSP — deny all resource loads except inline | ||
| 187 | // script (needed for DOMPurify). default-src 'none' blocks every fetch, | ||
| 188 | // image, font, media and frame; script-src 'unsafe-inline' is required | ||
| 189 | // because DOMPurify is inlined in the page. This is defense-in-depth: the | ||
| 190 | // block-all interceptor and AutoLoadImages=false already prevent loads, but | ||
| 191 | // the CSP also constrains any engine path that bypasses the interceptor. | ||
| 192 | 24 | QString html = QStringLiteral( | |
| 193 | "<!DOCTYPE html><html><head><meta charset=\"utf-8\">" | ||
| 194 | "<meta http-equiv=\"Content-Security-Policy\" " | ||
| 195 | "content=\"default-src 'none'; script-src 'unsafe-inline'; " | ||
| 196 | "style-src 'unsafe-inline'; img-src data:;\">" | ||
| 197 | "<script>%1</script></head><body></body></html>") | ||
| 198 |
1/1✓ Branch 90 → 91 taken 12 times.
|
12 | .arg(dompurifyJs); |
| 199 | |||
| 200 | // Load the page and wait for it to finish | ||
| 201 |
1/1✓ Branch 93 → 94 taken 12 times.
|
12 | const QPointer<HtmlSanitizer> guard(this); |
| 202 |
1/1✓ Branch 94 → 95 taken 12 times.
|
12 | const QPointer<QWebEnginePage> pageGuard(m_page); |
| 203 | // S106-041: a renderer that dies takes the pending JavaScript completion | ||
| 204 | // with it. Without recovery the active request never completed and every | ||
| 205 | // later sanitize queued behind it until restart. | ||
| 206 |
1/1✓ Branch 96 → 97 taken 12 times.
|
12 | connect(m_page, &QWebEnginePage::renderProcessTerminated, this, |
| 207 | 24 | [this, pageGuard](QWebEnginePage::RenderProcessTerminationStatus status, int code) { | |
| 208 | ✗ | if (!pageGuard || m_page != pageGuard) return; | |
| 209 | ✗ | qCWarning(lcSanitizer) << "Sanitizer renderer ended" << int(status) << code; | |
| 210 | ✗ | recoverFromLostPage(); | |
| 211 | }); | ||
| 212 | 12 | connect(m_page, &QWebEnginePage::loadFinished, this, | |
| 213 |
1/3✓ Branch 101 → 102 taken 12 times.
✗ Branch 187 → 188 not taken.
✗ Branch 187 → 189 not taken.
|
24 | [this, guard, pageGuard](bool ok) { |
| 214 |
4/8✓ Branch 3 → 4 taken 11 times.
✗ Branch 3 → 9 not taken.
✓ Branch 5 → 6 taken 11 times.
✗ Branch 5 → 9 not taken.
✓ Branch 7 → 8 taken 11 times.
✗ Branch 7 → 9 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 11 times.
|
22 | if (!guard || !pageGuard || m_page != pageGuard || |
| 215 |
1/2✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 11 times.
|
11 | m_state != InitializationState::Loading) return; |
| 216 |
1/2✗ Branch 13 → 14 not taken.
✓ Branch 13 → 21 taken 11 times.
|
11 | if (!ok) { |
| 217 | ✗ | failInitialization(QStringLiteral("DOMPurify page load failed")); | |
| 218 | ✗ | return; | |
| 219 | } | ||
| 220 | |||
| 221 | // Verify DOMPurify is available | ||
| 222 |
1/1✓ Branch 27 → 28 taken 11 times.
|
11 | m_page->runJavaScript( |
| 223 | 22 | QStringLiteral("typeof DOMPurify !== 'undefined'"), | |
| 224 |
1/3✓ Branch 23 → 24 taken 11 times.
✗ Branch 50 → 51 not taken.
✗ Branch 50 → 52 not taken.
|
22 | [this, guard, pageGuard](const QVariant &result) { |
| 225 |
4/8✓ Branch 3 → 4 taken 11 times.
✗ Branch 3 → 9 not taken.
✓ Branch 5 → 6 taken 11 times.
✗ Branch 5 → 9 not taken.
✓ Branch 7 → 8 taken 11 times.
✗ Branch 7 → 9 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 11 times.
|
22 | if (!guard || !pageGuard || m_page != pageGuard || |
| 226 |
1/2✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 11 times.
|
11 | m_state != InitializationState::Loading) return; |
| 227 |
1/2✓ Branch 14 → 15 taken 11 times.
✗ Branch 14 → 29 not taken.
|
11 | if (result.toBool()) { |
| 228 |
5/5✓ Branch 15 → 16 taken 11 times.
✓ Branch 19 → 20 taken 11 times.
✓ Branch 20 → 21 taken 11 times.
✓ Branch 24 → 17 taken 11 times.
✓ Branch 24 → 25 taken 11 times.
|
22 | qCInfo(lcSanitizer) << "DOMPurify sanitizer ready"; |
| 229 | 11 | m_state = InitializationState::Ready; | |
| 230 | 11 | emit ready(); | |
| 231 |
1/2✓ Branch 27 → 28 taken 11 times.
✗ Branch 27 → 36 not taken.
|
11 | if (guard) drainPendingRequests(); |
| 232 | } else { | ||
| 233 | ✗ | failInitialization( | |
| 234 | ✗ | QStringLiteral("DOMPurify not found after page load")); | |
| 235 | } | ||
| 236 | }); | ||
| 237 | }); | ||
| 238 | |||
| 239 |
2/2✓ Branch 104 → 105 taken 12 times.
✓ Branch 105 → 106 taken 12 times.
|
12 | m_page->setHtml(html); |
| 240 | 12 | } | |
| 241 | |||
| 242 | 396 | void HtmlSanitizer::sanitize( | |
| 243 | const QString &dirtyHtml, | ||
| 244 | std::function<void(const QString &cleanHtml)> callback) { | ||
| 245 |
1/1✓ Branch 6 → 7 taken 396 times.
|
396 | sanitize(dirtyHtml, {}, std::move(callback)); |
| 246 | 396 | } | |
| 247 | |||
| 248 | 724 | void HtmlSanitizer::sanitize( | |
| 249 | const QString &dirtyHtml, const QHash<QString, QString> &inlineImages, | ||
| 250 | std::function<void(const QString &cleanHtml)> callback, QObject *owner, | ||
| 251 | const QByteArray &cacheIdentity) { | ||
| 252 | |||
| 253 |
3/3✓ Branch 2 → 3 taken 163 times.
✓ Branch 2 → 4 taken 561 times.
✓ Branch 3 → 4 taken 163 times.
|
724 | if (owner) cancel(owner); |
| 254 | |||
| 255 |
1/2✗ Branch 4 → 5 not taken.
✓ Branch 4 → 22 taken 724 times.
|
724 | if (m_state == InitializationState::Uninitialized) { |
| 256 | ✗ | qCWarning(lcSanitizer) << "Sanitizer not initialized, call init() first"; | |
| 257 | ✗ | if (callback) | |
| 258 | ✗ | callback(QString()); // Return empty to be safe | |
| 259 | 151 | return; | |
| 260 | } | ||
| 261 | |||
| 262 |
1/2✓ Branch 22 → 23 taken 724 times.
✗ Branch 22 → 24 not taken.
|
724 | if (m_state == InitializationState::Failed || |
| 263 |
2/2✓ Branch 23 → 24 taken 1 time.
✓ Branch 23 → 31 taken 723 times.
|
724 | m_state == InitializationState::Destroying) { |
| 264 |
1/2✓ Branch 25 → 26 taken 1 time.
✗ Branch 25 → 30 not taken.
|
1 | if (callback) |
| 265 |
1/1✓ Branch 27 → 28 taken 1 time.
|
1 | callback(QString()); |
| 266 | 1 | return; | |
| 267 | } | ||
| 268 | |||
| 269 |
1/1✓ Branch 32 → 33 taken 723 times.
|
723 | qsizetype byteSize = MailJD::utf8ByteCountUpTo(dirtyHtml, MaxQueuedBytes); |
| 270 | 723 | for (auto it = inlineImages.cbegin(); | |
| 271 |
5/6✓ Branch 43 → 44 taken 733 times.
✗ Branch 43 → 48 not taken.
✓ Branch 46 → 47 taken 10 times.
✓ Branch 46 → 48 taken 723 times.
✓ Branch 49 → 34 taken 10 times.
✓ Branch 49 → 50 taken 723 times.
|
733 | byteSize <= MaxQueuedBytes && it != inlineImages.cend(); ++it) { |
| 272 |
1/1✓ Branch 36 → 37 taken 10 times.
|
10 | const qsizetype keyBytes = MailJD::utf8ByteCountUpTo( |
| 273 | it.key(), MaxQueuedBytes - byteSize); | ||
| 274 |
1/2✗ Branch 37 → 38 not taken.
✓ Branch 37 → 39 taken 10 times.
|
10 | if (keyBytes > MaxQueuedBytes - byteSize) { byteSize = MaxQueuedBytes + 1; break; } |
| 275 | 10 | byteSize += keyBytes; | |
| 276 |
1/1✓ Branch 41 → 42 taken 10 times.
|
10 | const qsizetype valueBytes = MailJD::utf8ByteCountUpTo( |
| 277 | it.value(), MaxQueuedBytes - byteSize); | ||
| 278 | 10 | byteSize += valueBytes; | |
| 279 | } | ||
| 280 |
10/16✓ Branch 51 → 52 taken 519 times.
✓ Branch 51 → 53 taken 204 times.
✓ Branch 54 → 55 taken 204 times.
✓ Branch 57 → 58 taken 204 times.
✓ Branch 58 → 59 taken 204 times.
✓ Branch 58 → 60 taken 519 times.
✓ Branch 60 → 61 taken 204 times.
✓ Branch 60 → 62 taken 519 times.
✓ Branch 62 → 63 taken 204 times.
✓ Branch 62 → 64 taken 519 times.
✗ Branch 178 → 179 not taken.
✗ Branch 178 → 180 not taken.
✗ Branch 182 → 183 not taken.
✗ Branch 182 → 184 not taken.
✗ Branch 186 → 187 not taken.
✗ Branch 186 → 188 not taken.
|
723 | const QByteArray identity = cacheIdentity.isEmpty() ? QByteArray{} : QByteArrayLiteral("html-policy-1:") + QCryptographicHash::hash(cacheIdentity, QCryptographicHash::Sha256); |
| 281 | 723 | QString cachedOutput; | |
| 282 |
8/8✓ Branch 64 → 65 taken 81 times.
✓ Branch 64 → 66 taken 642 times.
✓ Branch 65 → 66 taken 4 times.
✓ Branch 65 → 69 taken 77 times.
✓ Branch 67 → 68 taken 201 times.
✓ Branch 67 → 69 taken 445 times.
✓ Branch 70 → 71 taken 201 times.
✓ Branch 70 → 85 taken 522 times.
|
723 | if ((m_state == InitializationState::Ready || m_state == InitializationState::Dormant) && !identity.isEmpty()) { |
| 283 | 201 | if (const auto *entry = m_outputCache.object(identity); | |
| 284 |
8/8✓ Branch 72 → 73 taken 149 times.
✓ Branch 72 → 78 taken 52 times.
✓ Branch 74 → 75 taken 148 times.
✓ Branch 74 → 78 taken 1 time.
✓ Branch 76 → 77 taken 147 times.
✓ Branch 76 → 78 taken 1 time.
✓ Branch 79 → 80 taken 147 times.
✓ Branch 79 → 85 taken 54 times.
|
201 | entry && entry->html == dirtyHtml && entry->inlineImages == inlineImages) { |
| 285 | 147 | cachedOutput = entry->clean; | |
| 286 |
1/2✓ Branch 81 → 82 taken 147 times.
✗ Branch 81 → 85 not taken.
|
147 | if (byteSize <= MaxQueuedBytes) |
| 287 |
1/1✓ Branch 83 → 84 taken 147 times.
|
147 | byteSize += MailJD::utf8ByteCountUpTo(cachedOutput, |
| 288 | MaxQueuedBytes - byteSize); | ||
| 289 | } | ||
| 290 | } | ||
| 291 |
6/6✓ Branch 87 → 88 taken 166 times.
✓ Branch 87 → 89 taken 557 times.
✓ Branch 90 → 91 taken 715 times.
✓ Branch 90 → 92 taken 8 times.
✓ Branch 94 → 95 taken 38 times.
✓ Branch 94 → 112 taken 685 times.
|
1438 | if (m_pendingRequests.size() + m_readyRequests.size() + (m_activeRequestId != 0 ? 1 : 0) >= MaxQueuedRequests || |
| 292 |
2/2✓ Branch 91 → 92 taken 30 times.
✓ Branch 91 → 93 taken 685 times.
|
715 | byteSize > MaxQueuedBytes - m_pendingBytes) { |
| 293 |
5/5✓ Branch 95 → 96 taken 38 times.
✓ Branch 99 → 100 taken 38 times.
✓ Branch 100 → 101 taken 38 times.
✓ Branch 104 → 97 taken 38 times.
✓ Branch 104 → 105 taken 38 times.
|
76 | qCWarning(lcSanitizer) << "Sanitizer retained-work limit exceeded"; |
| 294 |
1/2✓ Branch 106 → 107 taken 38 times.
✗ Branch 106 → 111 not taken.
|
38 | if (callback) |
| 295 |
1/1✓ Branch 108 → 109 taken 38 times.
|
38 | callback(QString()); |
| 296 | 38 | return; | |
| 297 | } | ||
| 298 | |||
| 299 |
1/1✓ Branch 112 → 113 taken 685 times.
|
685 | m_idleRelease.stop(); |
| 300 | 685 | const quint64 requestId = m_nextRequestId++; | |
| 301 |
1/2✓ Branch 114 → 115 taken 685 times.
✗ Branch 114 → 128 not taken.
|
685 | if (callback) { |
| 302 |
2/2✓ Branch 115 → 116 taken 163 times.
✓ Branch 115 → 126 taken 522 times.
|
685 | if (owner) { |
| 303 |
1/1✓ Branch 116 → 117 taken 163 times.
|
163 | const QPointer<QObject> guard(owner); |
| 304 |
2/4✓ Branch 120 → 121 taken 163 times.
✓ Branch 121 → 122 taken 163 times.
✗ Branch 204 → 205 not taken.
✗ Branch 204 → 206 not taken.
|
163 | m_callbacks.insert(requestId, [guard, callback = std::move(callback)](const QString &clean) { |
| 305 |
2/2✓ Branch 3 → 4 taken 60 times.
✓ Branch 3 → 5 taken 2 times.
|
62 | if (guard) callback(clean); |
| 306 | 62 | }); | |
| 307 |
1/1✓ Branch 127 → 128 taken 522 times.
|
685 | } else m_callbacks.insert(requestId, std::move(callback)); |
| 308 | } | ||
| 309 | |||
| 310 |
2/12✓ Branch 130 → 131 taken 685 times.
✓ Branch 133 → 134 taken 685 times.
✗ Branch 212 → 213 not taken.
✗ Branch 212 → 214 not taken.
✗ Branch 215 → 216 not taken.
✗ Branch 215 → 217 not taken.
✗ Branch 218 → 219 not taken.
✗ Branch 218 → 220 not taken.
✗ Branch 221 → 222 not taken.
✗ Branch 221 → 223 not taken.
✗ Branch 224 → 225 not taken.
✗ Branch 224 → 226 not taken.
|
685 | PendingRequest request{requestId, dirtyHtml, inlineImages, byteSize, owner, owner != nullptr, identity, cachedOutput}; |
| 311 | 685 | m_pendingBytes += byteSize; | |
| 312 |
2/2✓ Branch 135 → 136 taken 112 times.
✓ Branch 135 → 140 taken 573 times.
|
685 | if (!cachedOutput.isNull()) { |
| 313 |
1/1✓ Branch 137 → 138 taken 112 times.
|
112 | m_readyRequests.insert(requestId, std::move(request)); |
| 314 |
1/1✓ Branch 138 → 139 taken 112 times.
|
112 | QTimer::singleShot(0, this, [this, requestId] { |
| 315 | 112 | const auto ready = m_readyRequests.constFind(requestId); | |
| 316 |
2/2✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 7 taken 110 times.
|
112 | if (ready == m_readyRequests.cend()) return; |
| 317 | 110 | const QString clean = ready->cachedOutput; | |
| 318 | 110 | ++m_cacheHits; | |
| 319 |
1/1✓ Branch 9 → 10 taken 110 times.
|
110 | completeRequest(requestId, clean); |
| 320 | 110 | }); | |
| 321 | 112 | return; | |
| 322 | } | ||
| 323 |
3/3✓ Branch 140 → 141 taken 156 times.
✓ Branch 140 → 143 taken 417 times.
✓ Branch 142 → 145 taken 156 times.
|
573 | if (owner) m_pendingRequests.prepend(std::move(request)); |
| 324 |
1/1✓ Branch 144 → 145 taken 417 times.
|
417 | else m_pendingRequests.enqueue(std::move(request)); |
| 325 |
3/3✓ Branch 145 → 146 taken 3 times.
✓ Branch 145 → 147 taken 570 times.
✓ Branch 146 → 147 taken 3 times.
|
573 | if (m_state == InitializationState::Dormant) init(); |
| 326 |
1/1✓ Branch 147 → 148 taken 573 times.
|
573 | drainPendingRequests(); |
| 327 | 985 | } | |
| 328 | |||
| 329 | 164 | void HtmlSanitizer::cancel(QObject *owner) { | |
| 330 |
1/2✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 164 times.
|
164 | if (!owner) return; |
| 331 |
2/2✓ Branch 17 → 5 taken 99 times.
✓ Branch 17 → 18 taken 164 times.
|
263 | for (qsizetype i = m_pendingRequests.size(); i > 0; --i) { |
| 332 | 99 | const auto &request = m_pendingRequests[i - 1]; | |
| 333 |
6/8✓ Branch 6 → 7 taken 99 times.
✗ Branch 6 → 12 not taken.
✓ Branch 8 → 9 taken 99 times.
✗ Branch 8 → 11 not taken.
✓ Branch 10 → 11 taken 98 times.
✓ Branch 10 → 12 taken 1 time.
✓ Branch 13 → 14 taken 98 times.
✓ Branch 13 → 16 taken 1 time.
|
99 | if (request.owned && (!request.owner || request.owner == owner)) { |
| 334 | 98 | m_pendingBytes -= request.byteSize; | |
| 335 | 98 | m_callbacks.remove(request.id); | |
| 336 | 98 | m_pendingRequests.removeAt(i - 1); | |
| 337 | } | ||
| 338 | } | ||
| 339 |
3/3✓ Branch 18 → 19 taken 164 times.
✓ Branch 40 → 20 taken 1 time.
✓ Branch 40 → 41 taken 164 times.
|
165 | for (auto it = m_readyRequests.begin(); it != m_readyRequests.end();) { |
| 340 |
4/8✓ Branch 21 → 22 taken 1 time.
✗ Branch 21 → 29 not taken.
✓ Branch 24 → 25 taken 1 time.
✗ Branch 24 → 28 not taken.
✓ Branch 27 → 28 taken 1 time.
✗ Branch 27 → 29 not taken.
✓ Branch 30 → 31 taken 1 time.
✗ Branch 30 → 37 not taken.
|
1 | if (it->owned && (!it->owner || it->owner == owner)) { |
| 341 | 1 | m_pendingBytes -= it->byteSize; | |
| 342 |
1/1✓ Branch 33 → 34 taken 1 time.
|
1 | m_callbacks.remove(it->id); |
| 343 |
1/1✓ Branch 35 → 36 taken 1 time.
|
1 | it = m_readyRequests.erase(it); |
| 344 | ✗ | } else ++it; | |
| 345 | } | ||
| 346 |
2/2✓ Branch 42 → 43 taken 99 times.
✓ Branch 42 → 49 taken 65 times.
|
164 | if (m_activeOwner == owner) { |
| 347 |
1/2✓ Branch 44 → 45 taken 99 times.
✗ Branch 44 → 48 not taken.
|
99 | if (m_activeInput) m_activeInput->cancelled->store(true); |
| 348 | 99 | m_callbacks.remove(m_activeRequestId); | |
| 349 | } | ||
| 350 |
5/6✓ Branch 49 → 50 taken 62 times.
✓ Branch 49 → 53 taken 102 times.
✓ Branch 51 → 52 taken 62 times.
✗ Branch 51 → 53 not taken.
✓ Branch 54 → 55 taken 62 times.
✓ Branch 54 → 56 taken 102 times.
|
164 | if (!m_activeRequestId && m_pendingRequests.isEmpty()) drainPendingRequests(); |
| 351 | } | ||
| 352 | |||
| 353 | 405 | void HtmlSanitizer::dispatchRequest(PendingRequest request) { | |
| 354 | 405 | m_activeRequestId = request.id; | |
| 355 | 405 | m_activeBytes = request.byteSize; | |
| 356 | 405 | m_activeOwner = request.owner; | |
| 357 |
2/4✓ Branch 3 → 4 taken 405 times.
✗ Branch 3 → 5 not taken.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 9 taken 405 times.
|
405 | if (m_state != InitializationState::Ready || !m_page) { |
| 358 | ✗ | completeRequest(request.id, QString()); | |
| 359 | ✗ | return; | |
| 360 | } | ||
| 361 | |||
| 362 | // A preceding active request may have populated the cache while this | ||
| 363 | // consumer waited. Recheck before dispatch to share that immutable result. | ||
| 364 |
5/6✓ Branch 10 → 11 taken 405 times.
✗ Branch 10 → 14 not taken.
✓ Branch 12 → 13 taken 56 times.
✓ Branch 12 → 14 taken 349 times.
✓ Branch 15 → 16 taken 56 times.
✓ Branch 15 → 30 taken 349 times.
|
405 | if (request.cachedOutput.isNull() && !request.cacheIdentity.isEmpty()) { |
| 365 | 56 | if (const auto *entry = m_outputCache.object(request.cacheIdentity); | |
| 366 |
8/8✓ Branch 17 → 18 taken 3 times.
✓ Branch 17 → 23 taken 53 times.
✓ Branch 19 → 20 taken 2 times.
✓ Branch 19 → 23 taken 1 time.
✓ Branch 21 → 22 taken 1 time.
✓ Branch 21 → 23 taken 1 time.
✓ Branch 24 → 25 taken 1 time.
✓ Branch 24 → 30 taken 55 times.
|
56 | entry && entry->html == request.html && entry->inlineImages == request.inlineImages) { |
| 367 |
1/1✓ Branch 26 → 27 taken 1 time.
|
1 | const qsizetype extra = MailJD::utf8ByteCountUpTo( |
| 368 | 1 | entry->clean, MaxQueuedBytes - m_pendingBytes); | |
| 369 |
1/2✓ Branch 27 → 28 taken 1 time.
✗ Branch 27 → 30 not taken.
|
1 | if (extra <= MaxQueuedBytes - m_pendingBytes) { |
| 370 | 1 | request.cachedOutput = entry->clean; | |
| 371 | 1 | request.byteSize += extra; | |
| 372 | 1 | m_activeBytes += extra; | |
| 373 | 1 | m_pendingBytes += extra; | |
| 374 | } | ||
| 375 | } | ||
| 376 | } | ||
| 377 | |||
| 378 | 405 | m_activeInput = request; | |
| 379 |
2/2✓ Branch 32 → 33 taken 1 time.
✓ Branch 32 → 39 taken 404 times.
|
405 | if (!request.cachedOutput.isNull()) { |
| 380 | 1 | ++m_cacheHits; | |
| 381 | 1 | const quint64 id = request.id; | |
| 382 | 1 | const QString clean = request.cachedOutput; | |
| 383 |
1/1✓ Branch 35 → 36 taken 1 time.
|
2 | QTimer::singleShot(0, this, [this, id, clean] { completeRequest(id, clean); }); |
| 384 | 1 | return; | |
| 385 | 1 | } | |
| 386 | |||
| 387 | 404 | const quint64 requestId = request.id; | |
| 388 |
2/5✓ Branch 40 → 41 taken 404 times.
✗ Branch 41 → 42 not taken.
✓ Branch 41 → 43 taken 404 times.
✗ Branch 64 → 65 not taken.
✗ Branch 64 → 66 not taken.
|
404 | auto *watcher = new QFutureWatcher<QString>(this); |
| 389 | 404 | connect(watcher, &QFutureWatcher<QString>::finished, this, | |
| 390 |
1/1✓ Branch 43 → 44 taken 404 times.
|
404 | [this, watcher, requestId] { |
| 391 |
1/1✓ Branch 2 → 3 taken 404 times.
|
404 | const QString js = watcher->result(); |
| 392 |
1/1✓ Branch 3 → 4 taken 404 times.
|
404 | watcher->deleteLater(); |
| 393 |
1/2✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 404 times.
|
404 | if (requestId != m_activeRequestId) return; |
| 394 |
3/4✓ Branch 7 → 8 taken 404 times.
✗ Branch 7 → 21 not taken.
✓ Branch 9 → 10 taken 403 times.
✓ Branch 9 → 21 taken 1 time.
|
404 | if (m_state != InitializationState::Ready || !m_page || js.isEmpty() || |
| 395 |
5/8✓ Branch 6 → 7 taken 404 times.
✗ Branch 6 → 21 not taken.
✓ Branch 11 → 12 taken 403 times.
✗ Branch 11 → 21 not taken.
✓ Branch 15 → 16 taken 403 times.
✗ Branch 15 → 21 not taken.
✓ Branch 23 → 24 taken 2 times.
✓ Branch 23 → 28 taken 402 times.
|
1211 | !m_activeInput || m_activeInput->cancelled->load() || |
| 396 |
4/4✓ Branch 17 → 18 taken 55 times.
✓ Branch 17 → 22 taken 348 times.
✓ Branch 20 → 21 taken 1 time.
✓ Branch 20 → 22 taken 54 times.
|
403 | (m_activeInput->owned && !m_activeInput->owner)) { |
| 397 |
1/1✓ Branch 25 → 26 taken 2 times.
|
2 | completeRequest(requestId, QString()); |
| 398 | 2 | return; | |
| 399 | } | ||
| 400 |
1/1✓ Branch 28 → 29 taken 402 times.
|
402 | const QPointer<HtmlSanitizer> guard(this); |
| 401 | 402 | ++m_dispatchedRequests; | |
| 402 |
2/4✓ Branch 30 → 31 taken 402 times.
✓ Branch 31 → 32 taken 402 times.
✗ Branch 50 → 51 not taken.
✗ Branch 50 → 52 not taken.
|
402 | m_page->runJavaScript(js, [guard, requestId](const QVariant &result) { |
| 403 |
3/4✓ Branch 3 → 4 taken 402 times.
✗ Branch 3 → 9 not taken.
✓ Branch 5 → 6 taken 402 times.
✓ Branch 6 → 7 taken 402 times.
|
402 | if (guard) guard->completeRequest(requestId, result.toString()); |
| 404 | 402 | }); | |
| 405 | // S106-041: a completion that never arrives (hung or lost renderer) | ||
| 406 | // ends the request after a generous budget and replaces the page. | ||
| 407 |
1/1✓ Branch 34 → 35 taken 402 times.
|
402 | QTimer::singleShot(m_completionTimeoutMs, this, [this, requestId] { |
| 408 |
2/2✓ Branch 2 → 3 taken 269 times.
✓ Branch 2 → 4 taken 1 time.
|
270 | if (requestId != m_activeRequestId) return; |
| 409 |
5/5✓ Branch 4 → 5 taken 1 time.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 9 → 10 taken 1 time.
✓ Branch 13 → 6 taken 1 time.
✓ Branch 13 → 14 taken 1 time.
|
2 | qCWarning(lcSanitizer) << "Sanitizer completion lost; replacing the page"; |
| 410 | 1 | recoverFromLostPage(); | |
| 411 | }); | ||
| 412 |
2/2✓ Branch 38 → 39 taken 402 times.
✓ Branch 38 → 41 taken 2 times.
|
404 | }); |
| 413 |
1/1✓ Branch 49 → 50 taken 404 times.
|
404 | watcher->setFuture(QtConcurrent::run(&m_serializationPool, |
| 414 |
1/5✓ Branch 48 → 49 taken 404 times.
✗ Branch 73 → 74 not taken.
✗ Branch 73 → 75 not taken.
✗ Branch 76 → 77 not taken.
✗ Branch 76 → 78 not taken.
|
1212 | [html = request.html, images = request.inlineImages, |
| 415 | 404 | cancelled = request.cancelled]() -> QString { | |
| 416 |
1/2✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 404 times.
|
404 | if (cancelled->load()) return {}; |
| 417 | // Serialize untrusted input as JSON. This avoids executable template-string | ||
| 418 | // interpolation for backticks, ${...}, quotes, and Unicode separators. | ||
| 419 |
1/1✓ Branch 6 → 7 taken 404 times.
|
404 | QJsonObject inlineImages; |
| 420 |
2/2✓ Branch 16 → 8 taken 9 times.
✓ Branch 16 → 17 taken 404 times.
|
413 | for (auto it = images.cbegin(); it != images.cend(); ++it) |
| 421 |
2/2✓ Branch 9 → 10 taken 9 times.
✓ Branch 11 → 12 taken 9 times.
|
9 | inlineImages.insert(it.key(), it.value()); |
| 422 | const QByteArray serialized = | ||
| 423 |
3/5✓ Branch 20 → 21 taken 404 times.
✓ Branch 25 → 26 taken 808 times.
✓ Branch 25 → 27 taken 404 times.
✗ Branch 65 → 66 not taken.
✗ Branch 65 → 67 not taken.
|
1616 | QJsonDocument(QJsonArray{html, inlineImages}) |
| 424 |
1/1✓ Branch 21 → 22 taken 404 times.
|
404 | .toJson(QJsonDocument::Compact); |
| 425 |
5/6✓ Branch 29 → 30 taken 403 times.
✓ Branch 29 → 32 taken 1 time.
✗ Branch 31 → 32 not taken.
✓ Branch 31 → 33 taken 403 times.
✓ Branch 34 → 35 taken 1 time.
✓ Branch 34 → 36 taken 403 times.
|
404 | if (cancelled->load() || serialized.isEmpty()) return {}; |
| 426 | |||
| 427 | // DOMPurify config: | ||
| 428 | // - FORBID_TAGS: dangerous tags that should never appear in email | ||
| 429 | // - FORBID_ATTR: event handlers. The inline style attribute stays allowed | ||
| 430 | // but is rewritten through the CSS declaration allowlist below — a | ||
| 431 | // blanket ban (418caf9) destroyed the layout of every real-world HTML | ||
| 432 | // mail, which is almost entirely table + inline-style based. | ||
| 433 | // - ALLOW_DATA_ATTR: false — no data-* attributes | ||
| 434 | // - ADD_ATTR: allow 'target' for links (mailto: etc.) | ||
| 435 | 806 | QString js = QStringLiteral( | |
| 436 | "(function() {" | ||
| 437 | " try {" | ||
| 438 | " var payload = %1;" | ||
| 439 | " var dirty = payload[0];" | ||
| 440 | " var cidImages = payload[1];" | ||
| 441 | " var clean = DOMPurify.sanitize(dirty, {" | ||
| 442 | " FORBID_TAGS: ['style', 'math', 'svg', 'form', 'input'," | ||
| 443 | " 'textarea', 'select', 'button', 'object'," | ||
| 444 | " 'embed', 'applet', 'base', 'meta', 'link']," | ||
| 445 | " FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'," | ||
| 446 | " 'onfocus', 'onblur', 'onsubmit']," | ||
| 447 | " ALLOW_DATA_ATTR: false," | ||
| 448 | " ADD_ATTR: ['target']" | ||
| 449 | " });" | ||
| 450 | " var template = document.createElement('template');" | ||
| 451 | " template.innerHTML = clean;" | ||
| 452 | // resources/sanitizer/postprocess.js: CSS declaration allowlist and | ||
| 453 | // cid: resolution. The template document fragment is inert, so | ||
| 454 | // nothing here can trigger a load while the filter runs. | ||
| 455 | " template.content.querySelectorAll('[style]').forEach(function(el) {" | ||
| 456 | " var kept = mailjdFilterStyle(el.getAttribute('style'));" | ||
| 457 | " if (kept.length) el.setAttribute('style', kept);" | ||
| 458 | " else el.removeAttribute('style');" | ||
| 459 | " });" | ||
| 460 | " template.content.querySelectorAll('img[src]').forEach(function(img) {" | ||
| 461 | " var resolved = mailjdResolveCid(img.getAttribute('src'), cidImages);" | ||
| 462 | " if (resolved !== null) img.setAttribute('src', resolved);" | ||
| 463 | " });" | ||
| 464 | " return template.innerHTML;" | ||
| 465 | " } catch(e) {" | ||
| 466 | " return '';" | ||
| 467 | " }" | ||
| 468 | "})()") | ||
| 469 |
2/2✓ Branch 40 → 41 taken 403 times.
✓ Branch 41 → 42 taken 403 times.
|
403 | .arg(QString::fromUtf8(serialized)); |
| 470 | |||
| 471 |
1/2✗ Branch 47 → 48 not taken.
✓ Branch 47 → 49 taken 403 times.
|
403 | return cancelled->load() ? QString{} : js; |
| 472 |
3/7✓ Branch 17 → 18 taken 404 times.
✓ Branch 18 → 19 taken 404 times.
✓ Branch 19 → 20 taken 404 times.
✗ Branch 68 → 69 not taken.
✗ Branch 68 → 72 not taken.
✗ Branch 70 → 71 not taken.
✗ Branch 70 → 72 not taken.
|
808 | })); |
| 473 | } | ||
| 474 | |||
| 475 | 586 | void HtmlSanitizer::completeRequest(quint64 id, const QString &cleanHtml) { | |
| 476 |
3/3✓ Branch 2 → 3 taken 586 times.
✓ Branch 5 → 6 taken 111 times.
✓ Branch 5 → 10 taken 475 times.
|
586 | if (auto ready = m_readyRequests.find(id); ready != m_readyRequests.end()) { |
| 477 | 111 | m_pendingBytes -= ready->byteSize; | |
| 478 |
1/1✓ Branch 8 → 9 taken 111 times.
|
111 | m_readyRequests.erase(ready); |
| 479 | } | ||
| 480 |
2/2✓ Branch 10 → 11 taken 405 times.
✓ Branch 10 → 55 taken 181 times.
|
586 | if (id == m_activeRequestId) { |
| 481 |
2/2✓ Branch 16 → 17 taken 403 times.
✓ Branch 16 → 27 taken 2 times.
|
810 | if (m_activeInput && !m_activeInput->cancelled->load() && |
| 482 |
4/4✓ Branch 19 → 20 taken 56 times.
✓ Branch 19 → 27 taken 347 times.
✓ Branch 22 → 23 taken 55 times.
✓ Branch 22 → 27 taken 1 time.
|
459 | !m_activeInput->cacheIdentity.isEmpty() && |
| 483 |
5/8✓ Branch 12 → 13 taken 405 times.
✗ Branch 12 → 27 not taken.
✓ Branch 24 → 25 taken 55 times.
✗ Branch 24 → 27 not taken.
✓ Branch 25 → 26 taken 55 times.
✗ Branch 25 → 27 not taken.
✓ Branch 28 → 29 taken 55 times.
✓ Branch 28 → 53 taken 350 times.
|
866 | m_activeInput->cachedOutput.isNull() && !cleanHtml.isEmpty() && m_state == InitializationState::Ready) { |
| 484 | 55 | const auto &input = *m_activeInput; | |
| 485 | 55 | qsizetype bytes = 512 + input.cacheIdentity.size() + 2 * (input.html.size() + cleanHtml.size()); | |
| 486 |
2/2✓ Branch 41 → 34 taken 4 times.
✓ Branch 41 → 42 taken 55 times.
|
59 | for (auto it = input.inlineImages.cbegin(); it != input.inlineImages.cend(); ++it) |
| 487 | 4 | bytes += 128 + 2 * (it.key().size() + it.value().size()); | |
| 488 |
1/2✓ Branch 43 → 44 taken 55 times.
✗ Branch 43 → 53 not taken.
|
55 | if (bytes <= m_outputCache.maxCost()) |
| 489 |
1/4✗ Branch 49 → 50 not taken.
✓ Branch 49 → 52 taken 55 times.
✗ Branch 74 → 75 not taken.
✗ Branch 74 → 77 not taken.
|
55 | m_outputCache.insert(input.cacheIdentity, |
| 490 |
2/6✓ Branch 44 → 45 taken 55 times.
✓ Branch 48 → 49 taken 55 times.
✗ Branch 68 → 69 not taken.
✗ Branch 68 → 70 not taken.
✗ Branch 71 → 72 not taken.
✗ Branch 71 → 73 not taken.
|
55 | new CachedDocument{input.html, input.inlineImages, cleanHtml}, int(bytes)); |
| 491 | } | ||
| 492 | 405 | m_activeInput.reset(); | |
| 493 | 405 | m_pendingBytes -= m_activeBytes; | |
| 494 | 405 | m_activeBytes = 0; | |
| 495 | 405 | m_activeRequestId = 0; | |
| 496 |
1/1✓ Branch 54 → 55 taken 405 times.
|
405 | m_activeOwner = nullptr; |
| 497 | } | ||
| 498 |
1/1✓ Branch 55 → 56 taken 586 times.
|
586 | auto callback = m_callbacks.take(id); |
| 499 |
1/1✓ Branch 56 → 57 taken 586 times.
|
586 | const QPointer<HtmlSanitizer> guard(this); |
| 500 |
3/3✓ Branch 58 → 59 taken 584 times.
✓ Branch 58 → 60 taken 2 times.
✓ Branch 59 → 60 taken 584 times.
|
586 | if (callback) callback(cleanHtml); |
| 501 |
2/3✓ Branch 61 → 62 taken 586 times.
✗ Branch 61 → 63 not taken.
✓ Branch 62 → 63 taken 586 times.
|
586 | if (guard) drainPendingRequests(); |
| 502 | 586 | } | |
| 503 | |||
| 504 | 1233 | void HtmlSanitizer::drainPendingRequests() { | |
| 505 |
4/4✓ Branch 2 → 3 taken 1077 times.
✓ Branch 2 → 4 taken 156 times.
✓ Branch 3 → 4 taken 166 times.
✓ Branch 3 → 5 taken 911 times.
|
1233 | if (m_state != InitializationState::Ready || m_activeRequestId) return; |
| 506 |
2/2✓ Branch 6 → 7 taken 505 times.
✓ Branch 6 → 9 taken 406 times.
|
911 | if (m_pendingRequests.isEmpty()) { |
| 507 | 505 | scheduleIdleRelease(); | |
| 508 | 505 | return; | |
| 509 | } | ||
| 510 |
2/2✓ Branch 29 → 10 taken 406 times.
✓ Branch 29 → 30 taken 1 time.
|
407 | while (!m_pendingRequests.isEmpty()) { |
| 511 |
1/1✓ Branch 10 → 11 taken 406 times.
|
406 | auto request = m_pendingRequests.dequeue(); |
| 512 |
6/6✓ Branch 11 → 12 taken 58 times.
✓ Branch 11 → 15 taken 348 times.
✓ Branch 13 → 14 taken 1 time.
✓ Branch 13 → 15 taken 57 times.
✓ Branch 16 → 17 taken 1 time.
✓ Branch 16 → 19 taken 405 times.
|
406 | if (request.owned && !request.owner) { |
| 513 | 1 | m_pendingBytes -= request.byteSize; | |
| 514 |
1/1✓ Branch 17 → 18 taken 1 time.
|
1 | m_callbacks.remove(request.id); |
| 515 | 1 | continue; | |
| 516 | } | ||
| 517 |
1/1✓ Branch 21 → 22 taken 405 times.
|
405 | dispatchRequest(std::move(request)); |
| 518 | 405 | return; | |
| 519 | 406 | } | |
| 520 | 1 | drainPendingRequests(); | |
| 521 | } | ||
| 522 | |||
| 523 | 1 | void HtmlSanitizer::recoverFromLostPage() { | |
| 524 | // The active request ends empty (shown as "could not be sanitized"); the | ||
| 525 | // page is released and the next request starts a fresh one (Dormant). | ||
| 526 |
1/2✓ Branch 2 → 3 taken 1 time.
✗ Branch 2 → 13 not taken.
|
1 | if (m_activeRequestId) { |
| 527 |
1/2✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 8 not taken.
|
1 | if (m_activeInput) m_activeInput->cancelled->store(true); |
| 528 | 1 | const quint64 lost = m_activeRequestId; | |
| 529 | 1 | m_state = InitializationState::Dormant; | |
| 530 | 1 | releasePage(); | |
| 531 |
1/1✓ Branch 10 → 11 taken 1 time.
|
1 | completeRequest(lost, QString()); |
| 532 | } else { | ||
| 533 | ✗ | m_state = InitializationState::Dormant; | |
| 534 | ✗ | releasePage(); | |
| 535 | } | ||
| 536 |
1/2✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 1 time.
|
1 | if (!m_pendingRequests.isEmpty()) init(); |
| 537 | 1 | } | |
| 538 | |||
| 539 | 2 | void HtmlSanitizer::failInitialization(const QString &reason) { | |
| 540 |
5/5✓ Branch 2 → 3 taken 2 times.
✓ Branch 6 → 7 taken 2 times.
✓ Branch 7 → 8 taken 2 times.
✓ Branch 11 → 4 taken 2 times.
✓ Branch 11 → 12 taken 2 times.
|
4 | qCCritical(lcSanitizer) << reason; |
| 541 | 2 | m_state = InitializationState::Failed; | |
| 542 | 2 | m_outputCache.clear(); | |
| 543 | 2 | failAllRequests(); | |
| 544 | 2 | } | |
| 545 | |||
| 546 | 14 | void HtmlSanitizer::failAllRequests() { | |
| 547 |
1/2✗ Branch 3 → 4 not taken.
✓ Branch 3 → 7 taken 14 times.
|
14 | if (m_activeInput) m_activeInput->cancelled->store(true); |
| 548 | // Complete queued requests in submission order. Move any already-dispatched | ||
| 549 | // callbacks out afterwards so page destruction also has exactly-once | ||
| 550 | // semantics even though QWebEngine may suppress JavaScript completions. | ||
| 551 |
2/2✓ Branch 15 → 8 taken 69 times.
✓ Branch 15 → 16 taken 14 times.
|
83 | while (!m_pendingRequests.isEmpty()) { |
| 552 |
1/1✓ Branch 8 → 9 taken 69 times.
|
69 | const PendingRequest request = m_pendingRequests.dequeue(); |
| 553 | 69 | m_pendingBytes -= request.byteSize; | |
| 554 |
1/1✓ Branch 10 → 11 taken 69 times.
|
69 | completeRequest(request.id, QString()); |
| 555 | 69 | } | |
| 556 |
2/2✓ Branch 24 → 17 taken 1 time.
✓ Branch 24 → 25 taken 14 times.
|
15 | while (!m_readyRequests.isEmpty()) |
| 557 |
1/1✓ Branch 20 → 21 taken 1 time.
|
1 | completeRequest(m_readyRequests.cbegin().key(), QString()); |
| 558 |
1/3✗ Branch 25 → 26 not taken.
✓ Branch 25 → 30 taken 14 times.
✗ Branch 27 → 28 not taken.
|
14 | if (m_activeRequestId) completeRequest(m_activeRequestId, QString()); |
| 559 | 14 | m_pendingBytes = 0; | |
| 560 | |||
| 561 | 14 | auto inFlightCallbacks = std::move(m_callbacks); | |
| 562 | 14 | m_callbacks.clear(); | |
| 563 |
2/4✓ Branch 33 → 34 taken 14 times.
✗ Branch 36 → 37 not taken.
✗ Branch 46 → 35 not taken.
✓ Branch 46 → 47 taken 14 times.
|
14 | for (auto callback : inFlightCallbacks) { |
| 564 | ✗ | if (callback) | |
| 565 | ✗ | callback(QString()); | |
| 566 | ✗ | } | |
| 567 | 14 | } | |
| 568 |