diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-02 17:27:07 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-02 17:27:07 +0200 |
| commit | 35d3e4ff127b0213fa4d34f630c4a019e533a4df (patch) | |
| tree | 7cf50ed64ad5886ff909ab8ab2a26d99b2d90947 | |
| parent | b61ce428d81871fbbc6261f274f20785db9d1c38 (diff) | |
| download | qtmaildir-35d3e4ff127b0213fa4d34f630c4a019e533a4df.tar.gz qtmaildir-35d3e4ff127b0213fa4d34f630c4a019e533a4df.zip | |
feat: add MimeParser with GMime and safe attachment naming
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/mimeparser.cpp | 218 | ||||
| -rw-r--r-- | src/mimeparser.h | 64 | ||||
| -rw-r--r-- | tests/CMakeLists.txt | 3 | ||||
| -rw-r--r-- | tests/fixtures/alternative.eml | 16 | ||||
| -rw-r--r-- | tests/fixtures/attachment.eml | 18 | ||||
| -rw-r--r-- | tests/fixtures/encoded_subject.eml | 7 | ||||
| -rw-r--r-- | tests/fixtures/hostile_filename.eml | 17 | ||||
| -rw-r--r-- | tests/fixtures/inline_image.eml | 19 | ||||
| -rw-r--r-- | tests/fixtures/plain.eml | 12 | ||||
| -rw-r--r-- | tests/fixtures/truncated.eml | 11 | ||||
| -rw-r--r-- | tests/test_mimeparser.cpp | 171 |
12 files changed, 557 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d1ed29f..8dfd212 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,6 +1,7 @@ add_library(qtmaildir_lib STATIC keymap.cpp config.cpp + mimeparser.cpp ) target_include_directories(qtmaildir_lib diff --git a/src/mimeparser.cpp b/src/mimeparser.cpp new file mode 100644 index 0000000..7393a96 --- /dev/null +++ b/src/mimeparser.cpp @@ -0,0 +1,218 @@ +// gmime.h pulls in glib's gio headers, which declare a struct field named +// "signals". Qt's <QtCore/qnamespace.h> #defines "signals" to "Q_SIGNALS" +// (unless QT_NO_KEYWORDS is set), so gmime.h must be included before any Qt +// header in this translation unit to avoid a macro collision. +#include <gmime/gmime.h> + +#include "mimeparser.h" + +#include <QDir> +#include <QFile> +#include <QFileInfo> +#include <QRegularExpression> +#include <QUuid> + +namespace { + +/// GMime must be initialised exactly once per process. +void ensureGMimeInit() +{ + static bool initialised = false; + if (!initialised) { + g_mime_init(); + initialised = true; + } +} + +QString fromGChar(char *owned) +{ + if (!owned) + return {}; + const QString result = QString::fromUtf8(owned); + g_free(owned); + return result; +} + +QString headerText(GMimeMessage *message, const char *name) +{ + GMimeHeaderList *headers = g_mime_object_get_header_list( + GMIME_OBJECT(message)); + if (!headers) + return {}; + GMimeHeader *header = g_mime_header_list_get_header(headers, name); + if (!header) + return {}; + // get_value() returns the RFC 2047-decoded value. + return QString::fromUtf8(g_mime_header_get_value(header)); +} + +QByteArray decodePart(GMimePart *part) +{ + GMimeDataWrapper *content = g_mime_part_get_content(part); + if (!content) + return {}; + + GMimeStream *memStream = g_mime_stream_mem_new(); + g_mime_data_wrapper_write_to_stream(content, memStream); + g_mime_stream_flush(memStream); + + GByteArray *bytes = g_mime_stream_mem_get_byte_array( + GMIME_STREAM_MEM(memStream)); + QByteArray result(reinterpret_cast<const char *>(bytes->data), bytes->len); + + g_object_unref(memStream); + return result; +} + +/// Walks the MIME tree, filling the parsed message. +void collectParts(GMimeObject *object, ParsedMessage &out) +{ + if (GMIME_IS_MULTIPART(object)) { + GMimeMultipart *multipart = GMIME_MULTIPART(object); + const int count = g_mime_multipart_get_count(multipart); + for (int i = 0; i < count; ++i) + collectParts(g_mime_multipart_get_part(multipart, i), out); + return; + } + + if (GMIME_IS_MESSAGE_PART(object)) { + GMimeMessage *sub = g_mime_message_part_get_message( + GMIME_MESSAGE_PART(object)); + if (sub) + collectParts(g_mime_message_get_mime_part(sub), out); + return; + } + + if (!GMIME_IS_PART(object)) + return; + + GMimePart *part = GMIME_PART(object); + GMimeContentType *contentType = g_mime_object_get_content_type(object); + // g_mime_content_type_get_mime_type() returns a newly-allocated string + // that must be freed; fromGChar() takes ownership of it. + const QString mimeType = contentType + ? fromGChar(g_mime_content_type_get_mime_type(contentType)) + : QStringLiteral("application/octet-stream"); + + const char *disposition = g_mime_object_get_disposition(object); + const bool isAttachment = + disposition && g_ascii_strcasecmp(disposition, "attachment") == 0; + + const char *contentId = g_mime_part_get_content_id(part); + + if (isAttachment) { + Attachment attachment; + attachment.mimeType = mimeType; + attachment.data = decodePart(part); + const char *filename = g_mime_part_get_filename(part); + attachment.filename = filename + ? QString::fromUtf8(filename) + : QStringLiteral("attachment"); + out.attachments.append(attachment); + return; + } + + if (contentId) { + // Strip the angle brackets so the key matches a cid: URL body. + QString id = QString::fromUtf8(contentId); + if (id.startsWith(QLatin1Char('<')) && id.endsWith(QLatin1Char('>'))) + id = id.mid(1, id.size() - 2); + out.inlineParts.insert(id, InlinePart{ mimeType, decodePart(part) }); + return; + } + + if (mimeType == QLatin1String("text/plain") && out.plainBody.isEmpty()) { + out.plainBody = QString::fromUtf8(decodePart(part)); + } else if (mimeType == QLatin1String("text/html") && out.htmlBody.isEmpty()) { + out.htmlBody = QString::fromUtf8(decodePart(part)); + } +} + +} // namespace + +QString Attachment::safeFilename() const +{ + // Reduce to a basename: QFileInfo handles '/', and backslashes are stripped + // explicitly because a Windows-authored name can carry them. + QString name = filename; + name.replace(QLatin1Char('\\'), QLatin1Char('/')); + name = QFileInfo(name).fileName(); + + // A name of "..", "." or empty leaves nothing usable. + if (name.isEmpty() || name == QLatin1String(".") || name == QLatin1String("..")) + return QStringLiteral("attachment-%1").arg( + QUuid::createUuid().toString(QUuid::Id128).left(8)); + + return name; +} + +QString Attachment::saveTo(const QString &directory, QString *error) const +{ + const QDir dir(directory); + const QString target = dir.absoluteFilePath(safeFilename()); + + // Belt and braces: confirm the resolved path really is inside directory, + // so a future change to safeFilename() cannot silently reintroduce escape. + const QString canonicalDir = QDir(directory).absolutePath(); + if (!QFileInfo(target).absolutePath().startsWith(canonicalDir)) { + if (error) + *error = QStringLiteral("Refusing to write outside %1").arg(canonicalDir); + return {}; + } + + QFile file(target); + if (!file.open(QIODevice::WriteOnly)) { + if (error) + *error = file.errorString(); + return {}; + } + file.write(data); + file.close(); + return target; +} + +MimeParser::MimeParser() +{ + ensureGMimeInit(); +} + +ParsedMessage MimeParser::parse(const QString &filePath) const +{ + ParsedMessage out; + + FILE *fp = fopen(filePath.toLocal8Bit().constData(), "r"); + if (!fp) { + out.error = QStringLiteral("Cannot open %1").arg(filePath); + return out; + } + + GMimeStream *stream = g_mime_stream_file_new(fp); + GMimeParser *parser = g_mime_parser_new_with_stream(stream); + GMimeMessage *message = g_mime_parser_construct_message(parser, nullptr); + + g_object_unref(parser); + g_object_unref(stream); + + if (!message) { + out.error = QStringLiteral("Cannot parse %1").arg(filePath); + return out; + } + + out.subject = QString::fromUtf8( + g_mime_message_get_subject(message) ?: ""); + out.from = headerText(message, "From"); + out.to = headerText(message, "To"); + out.cc = headerText(message, "Cc"); + out.date = headerText(message, "Date"); + out.messageId = QString::fromUtf8( + g_mime_message_get_message_id(message) ?: ""); + + GMimeObject *body = g_mime_message_get_mime_part(message); + if (body) + collectParts(body, out); + + g_object_unref(message); + + out.ok = true; + return out; +} diff --git a/src/mimeparser.h b/src/mimeparser.h new file mode 100644 index 0000000..27a239f --- /dev/null +++ b/src/mimeparser.h @@ -0,0 +1,64 @@ +#pragma once + +#include <QByteArray> +#include <QHash> +#include <QList> +#include <QString> + +/// An inline part referenced by a cid: URL from the HTML body. +struct InlinePart +{ + QString mimeType; + QByteArray data; +}; + +struct Attachment +{ + QString filename; ///< As it appeared in the message. Untrusted. + QString mimeType; + QByteArray data; + + /// filename reduced to a basename safe to join onto a directory. + /// Attacker-controlled input: a filename may contain path separators or + /// "..", so anything that could escape the target directory is stripped. + /// Returns a generated name when nothing usable remains. + QString safeFilename() const; + + /// Writes the attachment into directory. Returns the full path written, or + /// an empty string on failure with *error set. + QString saveTo(const QString &directory, QString *error) const; +}; + +struct ParsedMessage +{ + bool ok = false; + QString error; + + QString subject; + QString from; + QString to; + QString cc; + QString date; + QString messageId; + + QString plainBody; + QString htmlBody; + + QHash<QString, InlinePart> inlineParts; ///< Keyed by Content-ID, no <>. + QList<Attachment> attachments; + + bool hasHtml() const { return !htmlBody.isEmpty(); } +}; + +/// Parses a single message file using GMime. +/// +/// Hand-rolling this would mean reimplementing RFC 2047 encoded words, RFC 2231 +/// parameter continuations, transfer encodings, and charset conversion, plus +/// tolerance for malformed real-world mail. +class MimeParser +{ +public: + MimeParser(); + + ParsedMessage parse(const QString &filePath) const; +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bec7621..13cb5da 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,3 +7,6 @@ endfunction() add_qtmaildir_test(keymap) add_qtmaildir_test(config) +add_qtmaildir_test(mimeparser) +target_compile_definitions(test_mimeparser PRIVATE + FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures") diff --git a/tests/fixtures/alternative.eml b/tests/fixtures/alternative.eml new file mode 100644 index 0000000..1c35d79 --- /dev/null +++ b/tests/fixtures/alternative.eml @@ -0,0 +1,16 @@ +From: Alice <alice@example.org> +Subject: Both parts +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <alt-1@example.org> +MIME-Version: 1.0 +Content-Type: multipart/alternative; boundary="BOUND" + +--BOUND +Content-Type: text/plain; charset=utf-8 + +plain version +--BOUND +Content-Type: text/html; charset=utf-8 + +<html><body><p>html version</p></body></html> +--BOUND-- diff --git a/tests/fixtures/attachment.eml b/tests/fixtures/attachment.eml new file mode 100644 index 0000000..a0410e4 --- /dev/null +++ b/tests/fixtures/attachment.eml @@ -0,0 +1,18 @@ +From: Alice <alice@example.org> +Subject: With attachment +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <att-1@example.org> +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="MIX" + +--MIX +Content-Type: text/plain; charset=utf-8 + +see attached +--MIX +Content-Type: text/plain; charset=utf-8; name="notes.txt" +Content-Disposition: attachment; filename="notes.txt" +Content-Transfer-Encoding: quoted-printable + +caf=C3=A9 notes +--MIX-- diff --git a/tests/fixtures/encoded_subject.eml b/tests/fixtures/encoded_subject.eml new file mode 100644 index 0000000..7dcb6f8 --- /dev/null +++ b/tests/fixtures/encoded_subject.eml @@ -0,0 +1,7 @@ +From: =?utf-8?B?w4RsaWNl?= <alice@example.org> +Subject: =?utf-8?Q?Caf=C3=A9_meeting?= +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <enc-1@example.org> +Content-Type: text/plain; charset=utf-8 + +body diff --git a/tests/fixtures/hostile_filename.eml b/tests/fixtures/hostile_filename.eml new file mode 100644 index 0000000..4dc79b6 --- /dev/null +++ b/tests/fixtures/hostile_filename.eml @@ -0,0 +1,17 @@ +From: Attacker <bad@example.org> +Subject: Hostile attachment name +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <evil-1@example.org> +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="EVIL" + +--EVIL +Content-Type: text/plain; charset=utf-8 + +body +--EVIL +Content-Type: text/plain; name="../../../../tmp/pwned.txt" +Content-Disposition: attachment; filename="../../../../tmp/pwned.txt" + +owned +--EVIL-- diff --git a/tests/fixtures/inline_image.eml b/tests/fixtures/inline_image.eml new file mode 100644 index 0000000..a9dd24b --- /dev/null +++ b/tests/fixtures/inline_image.eml @@ -0,0 +1,19 @@ +From: Alice <alice@example.org> +Subject: Inline image +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <cid-1@example.org> +MIME-Version: 1.0 +Content-Type: multipart/related; boundary="REL" + +--REL +Content-Type: text/html; charset=utf-8 + +<html><body><img src="cid:logo@example.org"></body></html> +--REL +Content-Type: image/png +Content-Transfer-Encoding: base64 +Content-ID: <logo@example.org> + +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9 +awAAAABJRU5ErkJggg== +--REL-- diff --git a/tests/fixtures/plain.eml b/tests/fixtures/plain.eml new file mode 100644 index 0000000..81f06b4 --- /dev/null +++ b/tests/fixtures/plain.eml @@ -0,0 +1,12 @@ +From: Alice <alice@example.org> +To: Bob <bob@example.net> +Subject: Plain hello +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <plain-1@example.org> +Content-Type: text/plain; charset=utf-8 + +Hello Bob. + +> quoted line +Regards, +Alice diff --git a/tests/fixtures/truncated.eml b/tests/fixtures/truncated.eml new file mode 100644 index 0000000..912a522 --- /dev/null +++ b/tests/fixtures/truncated.eml @@ -0,0 +1,11 @@ +From: Alice <alice@example.org> +Subject: Truncated +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <trunc-1@example.org> +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="CUT" + +--CUT +Content-Type: text/plain; charset=utf-8 + +this part never closes diff --git a/tests/test_mimeparser.cpp b/tests/test_mimeparser.cpp new file mode 100644 index 0000000..72bdff7 --- /dev/null +++ b/tests/test_mimeparser.cpp @@ -0,0 +1,171 @@ +#include <QtTest> +#include <QTemporaryDir> +#include <QDir> +#include "mimeparser.h" + +class TestMimeParser : public QObject +{ + Q_OBJECT +private slots: + void initTestCase(); + + void parsesPlainText(); + void prefersHtmlWhenAvailable(); + void fallsBackToPlainWhenHtmlDisabled(); + void collectsInlineCidParts(); + void decodesQuotedPrintableAttachment(); + void decodesEncodedHeaders(); + void malformedMessageDoesNotCrash(); + void missingFileIsReported(); + void hostileFilenameIsSanitised(); + void savedAttachmentMatchesBytes(); + +private: + QString fixture(const QString &name) const + { return m_fixtureDir + QLatin1Char('/') + name; } + + QString m_fixtureDir; +}; + +void TestMimeParser::initTestCase() +{ + // FIXTURE_DIR is defined by CMake so the test can run from any cwd. + m_fixtureDir = QStringLiteral(FIXTURE_DIR); + QVERIFY2(QDir(m_fixtureDir).exists(), "fixture directory missing"); +} + +void TestMimeParser::parsesPlainText() +{ + MimeParser parser; + const ParsedMessage msg = parser.parse(fixture(QStringLiteral("plain.eml"))); + + QVERIFY(msg.ok); + QCOMPARE(msg.subject, QStringLiteral("Plain hello")); + QCOMPARE(msg.from, QStringLiteral("Alice <alice@example.org>")); + QVERIFY(msg.plainBody.contains(QStringLiteral("Hello Bob."))); + QVERIFY(msg.htmlBody.isEmpty()); + QVERIFY(msg.attachments.isEmpty()); +} + +void TestMimeParser::prefersHtmlWhenAvailable() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("alternative.eml"))); + + QVERIFY(msg.ok); + QVERIFY(msg.htmlBody.contains(QStringLiteral("html version"))); + // The plain alternative is kept so the user can toggle to it. + QVERIFY(msg.plainBody.contains(QStringLiteral("plain version"))); + QVERIFY(msg.hasHtml()); +} + +void TestMimeParser::fallsBackToPlainWhenHtmlDisabled() +{ + MimeParser parser; + const ParsedMessage msg = parser.parse(fixture(QStringLiteral("plain.eml"))); + + QVERIFY(!msg.hasHtml()); + QVERIFY(!msg.plainBody.isEmpty()); +} + +void TestMimeParser::collectsInlineCidParts() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("inline_image.eml"))); + + QVERIFY(msg.ok); + QCOMPARE(msg.inlineParts.size(), 1); + // Content-ID angle brackets are stripped so it matches the cid: URL body. + QVERIFY(msg.inlineParts.contains(QStringLiteral("logo@example.org"))); + + const InlinePart part = msg.inlineParts.value(QStringLiteral("logo@example.org")); + QCOMPARE(part.mimeType, QStringLiteral("image/png")); + // Decoded 1x1 PNG starts with the PNG magic bytes. + QVERIFY(part.data.startsWith(QByteArray("\x89PNG", 4))); +} + +void TestMimeParser::decodesQuotedPrintableAttachment() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("attachment.eml"))); + + QVERIFY(msg.ok); + QCOMPARE(msg.attachments.size(), 1); + QCOMPARE(msg.attachments.first().filename, QStringLiteral("notes.txt")); + QCOMPARE(QString::fromUtf8(msg.attachments.first().data), + QStringLiteral("café notes")); +} + +void TestMimeParser::decodesEncodedHeaders() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("encoded_subject.eml"))); + + QVERIFY(msg.ok); + QCOMPARE(msg.subject, QStringLiteral("Café meeting")); + QVERIFY(msg.from.contains(QStringLiteral("Älice"))); +} + +void TestMimeParser::malformedMessageDoesNotCrash() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("truncated.eml"))); + + // GMime is tolerant: it recovers the headers and whatever body it found. + // The requirement is only that parsing terminates and reports something. + QCOMPARE(msg.subject, QStringLiteral("Truncated")); +} + +void TestMimeParser::missingFileIsReported() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("does_not_exist.eml"))); + + QVERIFY(!msg.ok); + QVERIFY(!msg.error.isEmpty()); +} + +void TestMimeParser::hostileFilenameIsSanitised() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("hostile_filename.eml"))); + + QVERIFY(msg.ok); + QCOMPARE(msg.attachments.size(), 1); + + // The raw header value is preserved for display... + QVERIFY(msg.attachments.first().filename.contains(QStringLiteral(".."))); + // ...but the name used on disk is reduced to a basename. + QCOMPARE(msg.attachments.first().safeFilename(), QStringLiteral("pwned.txt")); +} + +void TestMimeParser::savedAttachmentMatchesBytes() +{ + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("attachment.eml"))); + QVERIFY(msg.ok); + + QTemporaryDir dir; + QString error; + const QString written = + msg.attachments.first().saveTo(dir.path(), &error); + + QVERIFY2(!written.isEmpty(), qPrintable(error)); + // Never escapes the target directory. + QVERIFY(written.startsWith(dir.path())); + + QFile f(written); + QVERIFY(f.open(QIODevice::ReadOnly)); + QCOMPARE(f.readAll(), msg.attachments.first().data); +} + +QTEST_MAIN(TestMimeParser) +#include "test_mimeparser.moc" |
