From: Vsevolod Stakhov Date: Sat, 7 Feb 2026 13:33:24 +0000 (+0000) Subject: [Test] Add MIME-in-message tests for /checkv3 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=b775b5a16d875c5592b34f07880fa8f8d7269b18;p=thirdparty%2Frspamd.git [Test] Add MIME-in-message tests for /checkv3 Verify that messages with their own MIME structure (multipart/alternative, multipart/mixed with attachments) are preserved intact when wrapped in the outer form-data envelope. Unit tests confirm inner MIME boundaries don't confuse the outer parser; functional tests confirm end-to-end symbol detection (R_PARTS_DIFFER, MIME_HTML_ONLY) works via /checkv3. --- diff --git a/test/functional/cases/001_merged/430_checkv3.robot b/test/functional/cases/001_merged/430_checkv3.robot index c355599445..744aab5235 100644 --- a/test/functional/cases/001_merged/430_checkv3.robot +++ b/test/functional/cases/001_merged/430_checkv3.robot @@ -5,6 +5,8 @@ Variables ${RSPAMD_TESTDIR}/lib/vars.py *** Variables *** ${GTUBE} ${RSPAMD_TESTDIR}/messages/gtube.eml +${ALT_RELATED} ${RSPAMD_TESTDIR}/messages/alternative-related.eml +${MIXED_RELATED_HTML} ${RSPAMD_TESTDIR}/messages/mixed-related-html-only.eml ${SETTINGS_NOSYMBOLS} {symbols_enabled = []} *** Test Cases *** @@ -35,6 +37,18 @@ checkv3 missing message part ${status} = Scan File V3 Single Part metadata {} application/json Should Be Equal As Integers ${status} 400 +checkv3 multipart/alternative MIME message + [Documentation] Message with own MIME boundaries (multipart/alternative) must parse correctly + Scan File V3 ${ALT_RELATED} + ... Settings={symbols_enabled = [R_PARTS_DIFFER]} + Expect Symbol R_PARTS_DIFFER + +checkv3 multipart/mixed MIME message + [Documentation] Message with multipart/mixed MIME structure and attachments + Scan File V3 ${MIXED_RELATED_HTML} + ... Settings={symbols_enabled = [MIME_HTML_ONLY]} + Expect Symbol MIME_HTML_ONLY + checkv3 malformed boundary [Documentation] Send body with wrong boundary, expect HTTP 400 Scan File V3 Expect Error ${GTUBE} 400 diff --git a/test/rspamd_cxx_unit_multipart.hxx b/test/rspamd_cxx_unit_multipart.hxx index 163d66a772..768290c099 100644 --- a/test/rspamd_cxx_unit_multipart.hxx +++ b/test/rspamd_cxx_unit_multipart.hxx @@ -234,6 +234,107 @@ TEST_SUITE("multipart_form") CHECK(result->parts[0].data == "This text mentions boundary as a word"); } + TEST_CASE("message with own MIME boundaries") + { + /* The message part contains a multipart/alternative email with its own + * MIME boundaries. The outer form-data boundary must not be confused + * by the inner MIME boundary markers. */ + std::string mime_message = + "From: test@example.com\r\n" + "To: rcpt@example.com\r\n" + "Subject: multipart test\r\n" + "MIME-Version: 1.0\r\n" + "Content-Type: multipart/alternative; boundary=\"inner-mime-boundary\"\r\n" + "\r\n" + "--inner-mime-boundary\r\n" + "Content-Type: text/plain; charset=\"UTF-8\"\r\n" + "\r\n" + "Plain text part\r\n" + "\r\n" + "--inner-mime-boundary\r\n" + "Content-Type: text/html; charset=\"UTF-8\"\r\n" + "\r\n" + "HTML part\r\n" + "\r\n" + "--inner-mime-boundary--\r\n"; + + std::string body = + "--outer-form-boundary\r\n" + "Content-Disposition: form-data; name=\"metadata\"\r\n" + "Content-Type: application/json\r\n" + "\r\n" + "{\"from\":\"test@example.com\"}\r\n" + "--outer-form-boundary\r\n" + "Content-Disposition: form-data; name=\"message\"\r\n" + "\r\n" + + mime_message + + "\r\n" + "--outer-form-boundary--\r\n"; + + auto result = rspamd::http::parse_multipart_form(body, "outer-form-boundary"); + REQUIRE(result.has_value()); + CHECK(result->parts.size() == 2); + + auto *meta = rspamd::http::find_part(*result, "metadata"); + REQUIRE(meta != nullptr); + CHECK(meta->data == "{\"from\":\"test@example.com\"}"); + + auto *msg = rspamd::http::find_part(*result, "message"); + REQUIRE(msg != nullptr); + /* The entire MIME message must be preserved intact */ + CHECK(msg->data == mime_message); + /* Verify inner MIME boundaries are present in the data */ + CHECK(msg->data.find("--inner-mime-boundary") != std::string_view::npos); + CHECK(msg->data.find("--inner-mime-boundary--") != std::string_view::npos); + CHECK(msg->data.find("Plain text part") != std::string_view::npos); + CHECK(msg->data.find("HTML part") != std::string_view::npos); + } + + TEST_CASE("message with nested multipart/mixed MIME") + { + /* A more complex case: multipart/mixed with attachments. + * The inner boundaries contain -- prefixes and look similar + * to form-data boundaries but must not interfere. */ + std::string mime_message = + "From: sender@test.com\r\n" + "Content-Type: multipart/mixed; boundary=\"----=_Part_123\"\r\n" + "\r\n" + "------=_Part_123\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "Body text\r\n" + "\r\n" + "------=_Part_123\r\n" + "Content-Type: application/pdf; name=\"doc.pdf\"\r\n" + "\r\n" + "fake-pdf-content\r\n" + "\r\n" + "------=_Part_123--\r\n"; + + std::string body = + "--formbnd\r\n" + "Content-Disposition: form-data; name=\"metadata\"\r\n" + "\r\n" + "{}\r\n" + "--formbnd\r\n" + "Content-Disposition: form-data; name=\"message\"\r\n" + "\r\n" + + mime_message + + "\r\n" + "--formbnd--\r\n"; + + auto result = rspamd::http::parse_multipart_form(body, "formbnd"); + REQUIRE(result.has_value()); + CHECK(result->parts.size() == 2); + + auto *msg = rspamd::http::find_part(*result, "message"); + REQUIRE(msg != nullptr); + /* Message data must contain the full MIME structure */ + CHECK(msg->data == mime_message); + CHECK(msg->data.find("------=_Part_123") != std::string_view::npos); + CHECK(msg->data.find("fake-pdf-content") != std::string_view::npos); + } + TEST_CASE("no headers in part") { /* Part has no Content-Disposition header, just raw data after boundary.