aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_threadcidmap.cpp
blob: 6087e5db546d6e05c96cdaf739a68c92d00075b0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
#include <QtTest>

#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"