MailJDTourDownloadDocsQualityTestsCoverage

Which source lines and branches the tests execute. Lines 91.5 %, branches 75.0 % (minimum 85 % and 72 %). main @ 399ffe3d · 21 October 2026 · pipeline 7238.

GCC Code Coverage Report


Directory: src/
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 56.6% 69 / 0 / 122
Functions: 85.7% 12 / 0 / 14
Branches: 15.1% 30 / 0 / 199

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