MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - ui - HtmlSanitizer.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 87.2 % 78 68
Test Date: 2026-07-27 17:53:44 Functions: 100.0 % 10 10
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 40.4 % 198 80

             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 : }
        

Generated by: LCOV version 2.0-1