ui/ExternalContentInterceptor.cpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #include "ExternalContentInterceptor.h" | ||
| 2 | #include "util/HostMatch.h" | ||
| 3 | #include "util/AddressClass.h" | ||
| 4 | |||
| 5 | #include <QLoggingCategory> | ||
| 6 | #include <QMutexLocker> | ||
| 7 | #include <QSet> | ||
| 8 | #include <QUrl> | ||
| 9 | |||
| 10 | ✗ | Q_LOGGING_CATEGORY(lcInterceptor, "mailjd.interceptor") | |
| 11 | |||
| 12 | 810 | ExternalContentInterceptor::ExternalContentInterceptor(QObject *parent) | |
| 13 |
1/1✓ Branch 6 → 7 taken 810 times.
|
810 | : QWebEngineUrlRequestInterceptor(parent) {} |
| 14 | |||
| 15 | 2 | void ExternalContentInterceptor::setBlockExternal(bool block) { | |
| 16 | 2 | QMutexLocker locker(&m_mutex); | |
| 17 | 2 | m_blockExternal = block; | |
| 18 | 2 | m_hasBlocked = false; | |
| 19 | 2 | m_blockedDomains.clear(); // T-122: Reset on new mail | |
| 20 | 2 | } | |
| 21 | |||
| 22 | 3 | bool ExternalContentInterceptor::isBlocking() const { | |
| 23 | 3 | QMutexLocker locker(&m_mutex); | |
| 24 | 6 | return m_blockExternal; | |
| 25 | 3 | } | |
| 26 | |||
| 27 | 9486 | void ExternalContentInterceptor::setDocumentPolicy(const QUrl &document, bool blockExternal, | |
| 28 | const QStringList &domains) { | ||
| 29 | 9486 | QMutexLocker locker(&m_mutex); | |
| 30 | 9486 | m_documentScoped = true; | |
| 31 | 9486 | m_document = document; | |
| 32 | 9486 | m_blockExternal = blockExternal; | |
| 33 | 9486 | m_hasBlocked = false; | |
| 34 | 9486 | m_blockedDomains.clear(); | |
| 35 | 9486 | m_whitelistedDomains.clear(); | |
| 36 |
1/4✗ Branch 9 → 10 not taken.
✗ Branch 10 → 11 not taken.
✗ Branch 14 → 8 not taken.
✓ Branch 14 → 15 taken 9486 times.
|
9486 | for (const auto &domain : domains) m_whitelistedDomains.insert(domain.toLower()); |
| 37 | 9486 | } | |
| 38 | |||
| 39 | // T-122: Whitelist support | ||
| 40 | 9 | void ExternalContentInterceptor::setWhitelist(const QStringList &domains, | |
| 41 | const QStringList &senders) { | ||
| 42 | Q_UNUSED(senders); | ||
| 43 | 9 | QMutexLocker locker(&m_mutex); | |
| 44 | 9 | m_whitelistedDomains.clear(); | |
| 45 |
2/2✓ Branch 12 → 6 taken 10 times.
✓ Branch 12 → 13 taken 9 times.
|
19 | for (const auto &d : domains) |
| 46 |
2/2✓ Branch 7 → 8 taken 10 times.
✓ Branch 8 → 9 taken 10 times.
|
10 | m_whitelistedDomains.insert(d.toLower()); |
| 47 | 9 | } | |
| 48 | |||
| 49 | 3 | void ExternalContentInterceptor::setSenderEmail(const QString &email) { | |
| 50 | Q_UNUSED(email); | ||
| 51 | 3 | } | |
| 52 | |||
| 53 | 16 | bool ExternalContentInterceptor::isAllowedByWhitelist( | |
| 54 | const QUrl &requestUrl) const { | ||
| 55 | 16 | QMutexLocker locker(&m_mutex); | |
| 56 |
1/1✓ Branch 3 → 4 taken 16 times.
|
32 | return isAllowedByWhitelistLocked(requestUrl); |
| 57 | 16 | } | |
| 58 | |||
| 59 | 1 | void ExternalContentInterceptor::resetBlockedDomains() { | |
| 60 | 1 | QMutexLocker locker(&m_mutex); | |
| 61 | 1 | m_blockedDomains.clear(); | |
| 62 | 1 | } | |
| 63 | |||
| 64 | 2 | QSet<QString> ExternalContentInterceptor::blockedDomains() const { | |
| 65 | 2 | QMutexLocker locker(&m_mutex); | |
| 66 | 2 | return m_blockedDomains; | |
| 67 | 2 | } | |
| 68 | |||
| 69 | ✗ | void ExternalContentInterceptor::interceptRequest( | |
| 70 | QWebEngineUrlRequestInfo &info) { | ||
| 71 | ✗ | bool emitBlocked = false; | |
| 72 | ✗ | bool emitLink = false; | |
| 73 | ✗ | QUrl signalUrl; | |
| 74 | ✗ | QUrl blockedUrl; | |
| 75 | ✗ | QUrl blockedDocument; | |
| 76 | |||
| 77 | { | ||
| 78 | // T-616/SEC-15: Guard shared state access — this runs on Chromium IO thread. | ||
| 79 | // Signals are emitted after the mutex is released; connected UI slots may | ||
| 80 | // synchronously call getters such as blockedDomains(). | ||
| 81 | ✗ | QMutexLocker locker(&m_mutex); | |
| 82 | |||
| 83 | ✗ | if (m_documentScoped) { | |
| 84 | ✗ | const auto url = info.requestUrl(); | |
| 85 | ✗ | const auto type = info.resourceType(); | |
| 86 | ✗ | if (url == QUrl(QStringLiteral("about:blank")) && | |
| 87 | ✗ | type == QWebEngineUrlRequestInfo::ResourceTypeMainFrame) return; | |
| 88 | ✗ | const bool document = !m_document.isEmpty() && url == m_document && | |
| 89 | ✗ | type == QWebEngineUrlRequestInfo::ResourceTypeMainFrame; | |
| 90 | ✗ | const bool image = !m_document.isEmpty() && info.firstPartyUrl() == m_document && | |
| 91 | ✗ | type == QWebEngineUrlRequestInfo::ResourceTypeImage && | |
| 92 | ✗ | (url.scheme() == "data" || url.scheme() == "http" || url.scheme() == "https"); | |
| 93 | ✗ | if (!document && !image) { info.block(true); return; } | |
| 94 | // The leased document itself (HtmlDocumentDelivery's own scheme) is | ||
| 95 | // the reader's; only its images go through the checks below. | ||
| 96 | ✗ | if (document) return; | |
| 97 | ✗ | } | |
| 98 | |||
| 99 | // T-350: Intercept link click navigations (main frame only). | ||
| 100 | // Block the navigation and emit signal so MailView opens it in the | ||
| 101 | // system browser. This replaces MailWebEnginePage::acceptNavigationRequest | ||
| 102 | // and eliminates the need for setPage() (which caused a freeze). | ||
| 103 | ✗ | if (info.navigationType() == QWebEngineUrlRequestInfo::NavigationTypeLink && | |
| 104 | ✗ | info.resourceType() == QWebEngineUrlRequestInfo::ResourceTypeMainFrame) { | |
| 105 | ✗ | info.block(true); | |
| 106 | ✗ | signalUrl = info.requestUrl(); | |
| 107 | ✗ | emitLink = true; | |
| 108 | } else { | ||
| 109 | ✗ | QString scheme = info.requestUrl().scheme().toLower(); | |
| 110 | ✗ | if (scheme != QStringLiteral("http") && scheme != QStringLiteral("https")) { | |
| 111 | // S106-042: deny by default — only the engine-internal schemes a | ||
| 112 | // rendered mail needs (inline data: images, blob:, about:, qrc:) pass; | ||
| 113 | // ws/wss/ftp/file and every other scheme used to pass unblocked. | ||
| 114 | ✗ | if (!isEngineInternalScheme(scheme)) | |
| 115 | ✗ | info.block(true); | |
| 116 | ✗ | return; | |
| 117 | } | ||
| 118 | |||
| 119 | ✗ | const bool publicHost = AddressClass::isPublicLiteralOrName(info.requestUrl().host()); | |
| 120 | ✗ | if (!m_blockExternal && publicHost) return; | |
| 121 | |||
| 122 | // T-122/MED-16: Only the requested resource domain is trusted here. | ||
| 123 | // The displayed From sender/domain is spoofable without message auth | ||
| 124 | // results, so it must not auto-unblock external content. | ||
| 125 | ✗ | QString requestDomain = info.requestUrl().host().toLower(); | |
| 126 | ✗ | if (publicHost && isAllowedByWhitelistLocked(info.requestUrl())) | |
| 127 | ✗ | return; | |
| 128 | |||
| 129 | // Block and track | ||
| 130 | ✗ | info.block(true); | |
| 131 | ✗ | m_blockedDomains.insert(requestDomain); // T-122: Track blocked domain | |
| 132 | ✗ | blockedUrl = info.requestUrl(); | |
| 133 | |||
| 134 | // Signal once per page load to show the info bar | ||
| 135 | ✗ | if (!m_hasBlocked) { | |
| 136 | ✗ | m_hasBlocked = true; | |
| 137 | ✗ | emitBlocked = true; | |
| 138 | ✗ | blockedDocument = m_document; | |
| 139 | } | ||
| 140 | ✗ | } | |
| 141 | ✗ | } | |
| 142 | |||
| 143 | ✗ | if (emitLink) | |
| 144 | ✗ | emit linkClicked(signalUrl); | |
| 145 | |||
| 146 | ✗ | if (!blockedUrl.isEmpty()) | |
| 147 | ✗ | qCInfo(lcInterceptor) << "Blocked external request"; | |
| 148 | |||
| 149 | ✗ | if (emitBlocked) { | |
| 150 | ✗ | emit externalContentBlocked(); | |
| 151 | ✗ | if (!blockedDocument.isEmpty()) emit documentExternalContentBlocked(blockedDocument); | |
| 152 | } | ||
| 153 | ✗ | } | |
| 154 | |||
| 155 | #ifdef MAILJD_UNIT_TEST | ||
| 156 | 1 | void ExternalContentInterceptor::simulateBlockedExternalRequestForTest( | |
| 157 | const QUrl &requestUrl) { | ||
| 158 | 1 | bool emitBlocked = false; | |
| 159 | { | ||
| 160 | 1 | QMutexLocker locker(&m_mutex); | |
| 161 |
3/3✓ Branch 4 → 5 taken 1 time.
✓ Branch 5 → 6 taken 1 time.
✓ Branch 6 → 7 taken 1 time.
|
1 | m_blockedDomains.insert(requestUrl.host().toLower()); |
| 162 |
1/2✓ Branch 9 → 10 taken 1 time.
✗ Branch 9 → 11 not taken.
|
1 | if (!m_hasBlocked) { |
| 163 | 1 | m_hasBlocked = true; | |
| 164 | 1 | emitBlocked = true; | |
| 165 | } | ||
| 166 | 1 | } | |
| 167 | |||
| 168 |
1/2✓ Branch 12 → 13 taken 1 time.
✗ Branch 12 → 14 not taken.
|
1 | if (emitBlocked) |
| 169 | 1 | emit externalContentBlocked(); | |
| 170 | 1 | } | |
| 171 | #endif | ||
| 172 | |||
| 173 | 12 | bool ExternalContentInterceptor::isEngineInternalScheme(const QString &scheme) { | |
| 174 | 1 | static const QSet<QString> internal = {QStringLiteral("data"), QStringLiteral("blob"), | |
| 175 |
5/10✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 28 taken 11 times.
✓ Branch 4 → 5 taken 1 time.
✗ Branch 4 → 28 not taken.
✓ Branch 21 → 22 taken 4 times.
✓ Branch 21 → 23 taken 1 time.
✗ Branch 34 → 35 not taken.
✗ Branch 34 → 36 not taken.
✗ Branch 55 → 56 not taken.
✗ Branch 55 → 57 not taken.
|
18 | QStringLiteral("about"), QStringLiteral("qrc")}; |
| 176 |
1/1✓ Branch 28 → 29 taken 12 times.
|
12 | return internal.contains(scheme.toLower()); |
| 177 | 5 | } | |
| 178 | |||
| 179 | 16 | bool ExternalContentInterceptor::isAllowedByWhitelistLocked( | |
| 180 | const QUrl &requestUrl) const { | ||
| 181 |
2/2✓ Branch 3 → 4 taken 16 times.
✓ Branch 4 → 5 taken 16 times.
|
16 | const QString requestDomain = requestUrl.host().toLower(); |
| 182 |
3/3✓ Branch 6 → 7 taken 16 times.
✓ Branch 7 → 8 taken 2 times.
✓ Branch 7 → 9 taken 14 times.
|
16 | if (!AddressClass::isPublicLiteralOrName(requestDomain)) |
| 183 | 2 | return false; | |
| 184 |
2/2✓ Branch 10 → 11 taken 4 times.
✓ Branch 10 → 12 taken 10 times.
|
14 | if (m_whitelistedDomains.contains(requestDomain)) |
| 185 | 4 | return true; | |
| 186 | // 67.A4: A whitelist entry also covers its subdomains — the user who | ||
| 187 | // allowed "example.com" expects images from "img.example.com" / a CDN | ||
| 188 | // host of the same domain to load. The dot boundary prevents | ||
| 189 | // "evilexample.com" from matching the entry "example.com". | ||
| 190 | // MED-16 still holds: only user-curated whitelist entries are | ||
| 191 | // consulted; the spoofable From sender never auto-unblocks anything. | ||
| 192 |
2/2✓ Branch 20 → 14 taken 10 times.
✓ Branch 20 → 21 taken 8 times.
|
18 | for (const auto &entry : m_whitelistedDomains) { |
| 193 |
3/3✓ Branch 15 → 16 taken 10 times.
✓ Branch 16 → 17 taken 2 times.
✓ Branch 16 → 18 taken 8 times.
|
10 | if (HostMatch::isHostOrSubdomain(requestDomain, entry)) |
| 194 | 2 | return true; | |
| 195 | } | ||
| 196 | 8 | return false; | |
| 197 | 16 | } | |
| 198 |