/* * 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 "threadcidmap.h" /// The one piece of new security-relevant logic in the message pane: flattening /// every message's inline parts into a single namespaced map. Two messages in a /// thread commonly share a Content-ID, and getting this wrong shows one /// message's image inside another. class TestThreadCidMap : public QObject { Q_OBJECT private slots: void emptyThreadYieldsEmptyMap(); void singleMessagePartsAreNamespaced(); void sharedContentIdsDoNotCollide(); void allowedCidsMatchMapKeys(); void prefixWithBangIsSanitized(); void sanitizedPrefixesStayDistinct(); void hostileContentIdCannotForgeAnotherPrefix(); void messagesWithoutInlinePartsAreSkipped(); }; static ThreadRenderItem makeItem(const QString &prefix, const QStringList &contentIds) { ThreadRenderItem item; item.cidPrefix = prefix; for (const QString &id : contentIds) { InlinePart part; part.mimeType = QStringLiteral("image/png"); part.data = id.toUtf8(); // Stand-in payload, unique per id. item.message.inlineParts.insert(id, part); } return item; } void TestThreadCidMap::emptyThreadYieldsEmptyMap() { const ThreadCidMap map = buildThreadCidMap({}); QVERIFY(map.parts.isEmpty()); QVERIFY(map.allowedCids.isEmpty()); } void TestThreadCidMap::singleMessagePartsAreNamespaced() { const ThreadCidMap map = buildThreadCidMap( { makeItem(QStringLiteral("m0"), { QStringLiteral("logo@example.org") }) }); QCOMPARE(map.parts.size(), 1); QVERIFY(map.parts.contains(QStringLiteral("m0!logo@example.org"))); // The raw, un-namespaced id must not be servable: HtmlBuilder rewrites // every reference, so a request for the bare id is a forged one. QVERIFY(!map.parts.contains(QStringLiteral("logo@example.org"))); } void TestThreadCidMap::sharedContentIdsDoNotCollide() { // The case the namespacing exists for: two newsletters using cid:logo. const ThreadCidMap map = buildThreadCidMap({ makeItem(QStringLiteral("m0"), { QStringLiteral("logo@example.org") }), makeItem(QStringLiteral("m1"), { QStringLiteral("logo@example.org") }), }); QCOMPARE(map.parts.size(), 2); const InlinePart first = map.parts.value(QStringLiteral("m0!logo@example.org")); const InlinePart second = map.parts.value(QStringLiteral("m1!logo@example.org")); QCOMPARE(first.data, second.data); // Same stand-in payload by construction, QVERIFY(map.parts.contains(QStringLiteral("m0!logo@example.org"))); QVERIFY(map.parts.contains(QStringLiteral("m1!logo@example.org"))); } void TestThreadCidMap::allowedCidsMatchMapKeys() { // The interceptor allows a set of cids; the handler serves a map. If those // disagree, either an image 404s or one is servable that policy never // approved. const ThreadCidMap map = buildThreadCidMap({ makeItem(QStringLiteral("m0"), { QStringLiteral("a@x"), QStringLiteral("b@x") }), makeItem(QStringLiteral("m1"), { QStringLiteral("a@x") }), }); QCOMPARE(map.allowedCids.size(), map.parts.size()); for (const QString &key : map.parts.keys()) QVERIFY(map.allowedCids.contains(key)); } void TestThreadCidMap::prefixWithBangIsSanitized() { // Q_ASSERT is compiled out in release, so a malformed prefix from the // caller must degrade safely rather than corrupt the key space. const ThreadCidMap map = buildThreadCidMap( { makeItem(QStringLiteral("m0!evil"), { QStringLiteral("logo@x") }) }); QCOMPARE(map.parts.size(), 1); const QString key = map.parts.keys().first(); // Whatever the sanitizer produces, the invariant is that the key splits at // its FIRST '!' back to a prefix that itself contains no '!'. const int separator = key.indexOf(QLatin1Char('!')); QVERIFY(separator > 0); QVERIFY(!key.left(separator).contains(QLatin1Char('!'))); } void TestThreadCidMap::sanitizedPrefixesStayDistinct() { // Sanitizing must not merge two different messages into one key space: if // "a!b" and "a_b" both became "a_b", one message's image would resolve for // the other, which is the exact bug namespacing prevents. const ThreadCidMap map = buildThreadCidMap({ makeItem(QStringLiteral("m0!x"), { QStringLiteral("logo@x") }), makeItem(QStringLiteral("m0_x"), { QStringLiteral("logo@x") }), }); QCOMPARE(map.parts.size(), 2); } void TestThreadCidMap::hostileContentIdCannotForgeAnotherPrefix() { // The id half is attacker-controlled and may contain '!'. A message with // prefix m0 must not be able to name a key belonging to m1. const ThreadCidMap map = buildThreadCidMap({ makeItem(QStringLiteral("m0"), { QStringLiteral("m1!logo@x") }), makeItem(QStringLiteral("m1"), { QStringLiteral("logo@x") }), }); QCOMPARE(map.parts.size(), 2); QVERIFY(map.parts.contains(QStringLiteral("m0!m1!logo@x"))); QVERIFY(map.parts.contains(QStringLiteral("m1!logo@x"))); // Splitting at the first '!' is what keeps these apart. const QString forged = QStringLiteral("m0!m1!logo@x"); QCOMPARE(forged.left(forged.indexOf(QLatin1Char('!'))), QStringLiteral("m0")); } void TestThreadCidMap::messagesWithoutInlinePartsAreSkipped() { const ThreadCidMap map = buildThreadCidMap({ makeItem(QStringLiteral("m0"), {}), makeItem(QStringLiteral("m1"), { QStringLiteral("logo@x") }), makeItem(QStringLiteral("m2"), {}), }); QCOMPARE(map.parts.size(), 1); QVERIFY(map.parts.contains(QStringLiteral("m1!logo@x"))); } QTEST_MAIN(TestThreadCidMap) #include "test_threadcidmap.moc"