]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Test] Add MIME-in-message tests for /checkv3
authorVsevolod Stakhov <vsevolod@rspamd.com>
Sat, 7 Feb 2026 13:33:24 +0000 (13:33 +0000)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Sat, 7 Feb 2026 13:33:24 +0000 (13:33 +0000)
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.

test/functional/cases/001_merged/430_checkv3.robot
test/rspamd_cxx_unit_multipart.hxx

index c35559944530d8cdae1bd451457eadde7825f023..744aab5235d6d483c6ee24dc7c7a936479494d45 100644 (file)
@@ -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
index 163d66a77255b753d05dba0342dfc2a0bd91ecc8..768290c09905f2232a89fde4c3e72a032764db7b 100644 (file)
@@ -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><body>HTML part</body></html>\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><body>HTML part</body></html>") != 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.