/* * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs * Copyright (C) 2026 Danilo M. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2 as * published by the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ #include #include "requestinterceptor.h" class TestInterceptor : public QObject { Q_OBJECT private slots: void blocksRemoteHttpByDefault(); void blocksRemoteHttpsByDefault(); void blocksFileUrlsAlways(); void allowsCidForCurrentMessage(); void blocksCidForForeignMessage(); void allowRemoteFlagPermitsHttpButNotFile(); void recordsThatSomethingWasBlocked(); void resetClearsBlockedFlag(); // Adversarial additions. void schemeIsCaseInsensitiveAndStillBlocked(); void qtmaildirSchemeIsCaseInsensitiveAllow(); void qtmaildirDocumentUrlIsAllowed(); void qtmaildirOtherPathIsBlocked(); void qtmaildirBlockedWhenNoDocumentUrlSet(); void resetForNewMessageDoesNotClearDocumentUrl(); void cidUrlDoesNotParseAsUserinfo(); void cidPercentEncodingDoesNotBypassAllowlist(); void javascriptSchemeBlocked(); void dataSchemeBlocked(); void blobSchemeBlocked(); void aboutSchemeBlocked(); void chromeSchemeBlocked(); void qrcSchemeBlocked(); void filesystemSchemeBlocked(); void protocolRelativeUrlBlocked(); void emptyUrlBlocked(); void blankUrlBlocked(); void colonOnlyUrlBlocked(); void fragmentOnlyUrlBlocked(); }; void TestInterceptor::blocksRemoteHttpByDefault() { RequestInterceptor interceptor; QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("http://tracker.example/pixel.gif")))); } void TestInterceptor::blocksRemoteHttpsByDefault() { RequestInterceptor interceptor; QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("https://cdn.example/style.css")))); } void TestInterceptor::blocksFileUrlsAlways() { RequestInterceptor interceptor; interceptor.setAllowRemote(true); // Even with remote content explicitly allowed, local files stay blocked: // a message must never read the filesystem. QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("file:///etc/passwd")))); } void TestInterceptor::allowsCidForCurrentMessage() { RequestInterceptor interceptor; interceptor.setAllowedCids({ QStringLiteral("logo@example.org") }); QVERIFY(interceptor.shouldAllow(QUrl(QStringLiteral("cid:logo@example.org")))); } void TestInterceptor::blocksCidForForeignMessage() { RequestInterceptor interceptor; interceptor.setAllowedCids({ QStringLiteral("logo@example.org") }); QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("cid:other@example.org")))); } void TestInterceptor::allowRemoteFlagPermitsHttpButNotFile() { RequestInterceptor interceptor; interceptor.setAllowRemote(true); QVERIFY(interceptor.shouldAllow(QUrl(QStringLiteral("https://cdn.example/img.png")))); QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("file:///etc/passwd")))); } void TestInterceptor::recordsThatSomethingWasBlocked() { RequestInterceptor interceptor; QVERIFY(!interceptor.blockedAnything()); interceptor.shouldAllow(QUrl(QStringLiteral("http://tracker.example/p.gif"))); // Drives the "Remote content blocked" banner in the message header. QVERIFY(interceptor.blockedAnything()); } void TestInterceptor::resetClearsBlockedFlag() { RequestInterceptor interceptor; interceptor.shouldAllow(QUrl(QStringLiteral("http://tracker.example/p.gif"))); QVERIFY(interceptor.blockedAnything()); interceptor.resetForNewMessage(); QVERIFY(!interceptor.blockedAnything()); // Remote permission never carries over to the next message. QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("https://cdn.example/x.png")))); } void TestInterceptor::schemeIsCaseInsensitiveAndStillBlocked() { // QUrl::scheme() normalizes to lowercase, so "HTTP://..." must still hit // the http branch (and be blocked without allowRemote), not fall through // unexpectedly to an allow path. RequestInterceptor interceptor; QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("HTTP://tracker.example/pixel.gif")))); QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("HTTPS://tracker.example/pixel.gif")))); interceptor.setAllowRemote(true); QVERIFY(interceptor.shouldAllow(QUrl(QStringLiteral("HTTP://tracker.example/pixel.gif")))); } void TestInterceptor::qtmaildirSchemeIsCaseInsensitiveAllow() { // QUrl normalizes scheme (and host, for authority-form URLs) to lowercase // on parse, so a differently-cased spelling of the exact document URL // still compares equal via QUrl::operator== (verified empirically: // QUrl("qtmaildir://message") == QUrl("QTMAILDIR://message") is true). RequestInterceptor interceptor; interceptor.setDocumentUrl(QUrl(QStringLiteral("qtmaildir://body/index.html"))); QVERIFY(interceptor.shouldAllow(QUrl(QStringLiteral("QTMAILDIR://body/index.html")))); } void TestInterceptor::qtmaildirDocumentUrlIsAllowed() { RequestInterceptor interceptor; const QUrl doc(QStringLiteral("qtmaildir://message")); interceptor.setDocumentUrl(doc); QVERIFY(interceptor.shouldAllow(doc)); } void TestInterceptor::qtmaildirOtherPathIsBlocked() { // Defense in depth: the qtmaildir: scheme is trusted only for the exact // document URL the application itself set, never for the whole scheme. // A hostile message body can put any qtmaildir: URL in or // ; none of these variants may pass. RequestInterceptor interceptor; interceptor.setDocumentUrl(QUrl(QStringLiteral("qtmaildir://message"))); // Path traversal off the document URL. QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("qtmaildir://message/../etc")))); // A different qtmaildir origin entirely. QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("qtmaildir://other")))); // Opaque (non-authority) form of the scheme. QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("qtmaildir:whatever")))); // A sub-path of the document URL is still not the document URL itself. QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("qtmaildir://message/cid/foo")))); QVERIFY(interceptor.blockedAnything()); } void TestInterceptor::qtmaildirBlockedWhenNoDocumentUrlSet() { // Fail closed: if MessageView forgets to call setDocumentUrl(), nothing // on the qtmaildir: scheme should be reachable, not even the URL that // would otherwise be the legitimate document. RequestInterceptor interceptor; QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("qtmaildir://message")))); } void TestInterceptor::resetForNewMessageDoesNotClearDocumentUrl() { // The document/base URL is a property of the view's current navigation, // not of an individual message's content, so switching to a new message // (resetForNewMessage) must not force MessageView to re-supply it. RequestInterceptor interceptor; const QUrl doc(QStringLiteral("qtmaildir://message")); interceptor.setDocumentUrl(doc); interceptor.shouldAllow(QUrl(QStringLiteral("http://tracker.example/p.gif"))); QVERIFY(interceptor.blockedAnything()); interceptor.resetForNewMessage(); QVERIFY(!interceptor.blockedAnything()); QCOMPARE(interceptor.documentUrl(), doc); QVERIFY(interceptor.shouldAllow(doc)); } void TestInterceptor::cidUrlDoesNotParseAsUserinfo() { // Pin down QUrl's actual parsing of a cid: URL containing '@', so a // future Qt version change would be caught here rather than silently // breaking the allowlist comparison in shouldAllow(). QUrl url(QStringLiteral("cid:logo@example.org")); QCOMPARE(url.path(), QStringLiteral("logo@example.org")); QCOMPARE(url.host(), QString()); QCOMPARE(url.userName(), QString()); RequestInterceptor interceptor; interceptor.setAllowedCids({ QStringLiteral("logo@example.org") }); QVERIFY(interceptor.shouldAllow(url)); } void TestInterceptor::cidPercentEncodingDoesNotBypassAllowlist() { // QUrl::path() returns the percent-DECODED form (verified empirically: // QUrl("cid:%6Cogo@example.org").path() == "logo@example.org", and this // holds for full-string encodings too). A percent-encoded spelling of an // allowed id therefore compares equal to that same allowed id, which is // correct URI equivalence, not a bypass: decoding cannot turn a foreign // id into a *different* allowed id's literal string, only into its own // canonical form. What matters for the security boundary is that a // genuinely foreign id, encoded or not, is still rejected. RequestInterceptor interceptor; interceptor.setAllowedCids({ QStringLiteral("logo@example.org") }); QVERIFY(interceptor.shouldAllow(QUrl(QStringLiteral("cid:%6Cogo@example.org")))); QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("cid:other@example.org")))); QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("cid:%6Fther@example.org")))); } void TestInterceptor::javascriptSchemeBlocked() { RequestInterceptor interceptor; interceptor.setAllowRemote(true); QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("javascript:alert(1)")))); } void TestInterceptor::dataSchemeBlocked() { RequestInterceptor interceptor; interceptor.setAllowRemote(true); QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("data:text/html,")))); } void TestInterceptor::blobSchemeBlocked() { RequestInterceptor interceptor; interceptor.setAllowRemote(true); QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("blob:https://example.org/uuid")))); } void TestInterceptor::aboutSchemeBlocked() { RequestInterceptor interceptor; QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("about:blank")))); } void TestInterceptor::chromeSchemeBlocked() { RequestInterceptor interceptor; QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("chrome://settings")))); } void TestInterceptor::qrcSchemeBlocked() { RequestInterceptor interceptor; QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("qrc:/icons/foo.png")))); } void TestInterceptor::filesystemSchemeBlocked() { RequestInterceptor interceptor; QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("filesystem:https://example.org/temporary/foo")))); } void TestInterceptor::protocolRelativeUrlBlocked() { RequestInterceptor interceptor; interceptor.setAllowRemote(true); QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("//tracker.example/pixel.gif")))); } void TestInterceptor::emptyUrlBlocked() { RequestInterceptor interceptor; QVERIFY(!interceptor.shouldAllow(QUrl())); } void TestInterceptor::blankUrlBlocked() { RequestInterceptor interceptor; QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("")))); } void TestInterceptor::colonOnlyUrlBlocked() { RequestInterceptor interceptor; QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral(":")))); } void TestInterceptor::fragmentOnlyUrlBlocked() { RequestInterceptor interceptor; QVERIFY(!interceptor.shouldAllow(QUrl(QStringLiteral("#fragment")))); } QTEST_MAIN(TestInterceptor) #include "test_interceptor.moc"