]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Fix] images: avoid quadratic Content-ID linking
authorVsevolod Stakhov <vsevolod@rspamd.com>
Wed, 22 Jul 2026 12:20:05 +0000 (13:20 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Wed, 22 Jul 2026 12:20:05 +0000 (13:20 +0100)
src/libmime/images.c
src/libserver/html/html.cxx
src/libserver/html/html.h
test/lua/unit/task.lua
test/rspamd_cxx_unit.cxx
test/rspamd_cxx_unit_images.hxx [new file with mode: 0644]

index fd3f5cf3158369f252cf7c4e950a6d2a72c839c7..678b0f292a8824f03454a2121405aa730d3433b8 100644 (file)
 #include "message.h"
 #include "libserver/html/html.h"
 
-#define msg_debug_images(...) rspamd_conditional_debug_fast(NULL, NULL,                                               \
-                                                                                                                       rspamd_images_log_id, "images", task->task_pool->tag.uid, \
-                                                                                                                       G_STRFUNC,                                                \
-                                                                                                                       __VA_ARGS__)
-
-INIT_LOG_MODULE(images)
-
 #ifdef USABLE_GD
 #include "gd.h"
 #include "hash.h"
@@ -674,13 +667,13 @@ rspamd_image_type_str(enum rspamd_image_type type)
 }
 
 static void
-rspamd_image_process_part(struct rspamd_task *task, struct rspamd_mime_part *part)
+rspamd_image_add_content_id(struct rspamd_task *task,
+                                                       struct rspamd_mime_part *part,
+                                                       GHashTable *cid_images)
 {
        struct rspamd_mime_header *rh;
-       struct rspamd_mime_text_part *tp;
-       struct html_image *himg;
        const char *cid;
-       unsigned int cid_len, i;
+       gsize cid_len;
        struct rspamd_image *img;
 
        img = (struct rspamd_image *) part->specific.img;
@@ -704,28 +697,9 @@ rspamd_image_process_part(struct rspamd_task *task, struct rspamd_mime_part *par
                                        cid_len--;
                                }
 
-                               PTR_ARRAY_FOREACH(MESSAGE_FIELD(task, text_parts), i, tp)
-                               {
-                                       if (IS_TEXT_PART_HTML(tp) && tp->html != NULL) {
-                                               himg = rspamd_html_find_embedded_image(tp->html, cid, cid_len);
-
-                                               if (himg != NULL) {
-                                                       img->html_image = himg;
-                                                       himg->embedded_image = img;
-
-                                                       msg_debug_images("found linked image by cid: <%s>",
-                                                                                        cid);
-
-                                                       if (himg->height == 0) {
-                                                               himg->height = img->height;
-                                                       }
-
-                                                       if (himg->width == 0) {
-                                                               himg->width = img->width;
-                                                       }
-                                               }
-                                       }
-                               }
+                               char *cid_copy = rspamd_mempool_strdup_len(task->task_pool,
+                                                                                                                  cid, cid_len);
+                               g_hash_table_replace(cid_images, cid_copy, img);
                        }
                }
        }
@@ -734,12 +708,27 @@ rspamd_image_process_part(struct rspamd_task *task, struct rspamd_mime_part *par
 void rspamd_images_link(struct rspamd_task *task)
 {
        struct rspamd_mime_part *part;
+       struct rspamd_mime_text_part *tp;
        unsigned int i;
+       GHashTable *cid_images;
+
+       cid_images = g_hash_table_new(g_str_hash, g_str_equal);
 
        PTR_ARRAY_FOREACH(MESSAGE_FIELD(task, parts), i, part)
        {
                if (part->part_type == RSPAMD_MIME_PART_IMAGE) {
-                       rspamd_image_process_part(task, part);
+                       rspamd_image_add_content_id(task, part, cid_images);
+               }
+       }
+
+       if (g_hash_table_size(cid_images) > 0) {
+               PTR_ARRAY_FOREACH(MESSAGE_FIELD(task, text_parts), i, tp)
+               {
+                       if (IS_TEXT_PART_HTML(tp) && tp->html != NULL) {
+                               rspamd_html_link_embedded_images(tp->html, cid_images);
+                       }
                }
        }
+
+       g_hash_table_destroy(cid_images);
 }
index 675ec7eeb69f05c5ad8ba78f65f46682492054ba..7a7116050a0ebc81d76122c05b3f18128fcf490a 100644 (file)
@@ -3375,6 +3375,33 @@ rspamd_html_find_embedded_image(void *html_content,
        return nullptr;
 }
 
+void rspamd_html_link_embedded_images(void *html_content,
+                                                                         GHashTable *cid_images)
+{
+       auto *hc = rspamd::html::html_content::from_ptr(html_content);
+
+       for (auto *html_image: hc->images) {
+               if (html_image->flags & RSPAMD_HTML_FLAG_IMAGE_EMBEDDED &&
+                       html_image->src != nullptr) {
+                       auto *parsed_image = static_cast<rspamd_image *>(
+                               g_hash_table_lookup(cid_images, html_image->src));
+
+                       if (parsed_image != nullptr) {
+                               parsed_image->html_image = html_image;
+                               html_image->embedded_image = parsed_image;
+
+                               if (html_image->height == 0) {
+                                       html_image->height = parsed_image->height;
+                               }
+
+                               if (html_image->width == 0) {
+                                       html_image->width = parsed_image->width;
+                               }
+                       }
+               }
+       }
+}
+
 bool rspamd_html_get_parsed_content(void *html_content, rspamd_ftok_t *dest)
 {
        auto *hc = rspamd::html::html_content::from_ptr(html_content);
index f256aae9dc800c2e673fb74a81a528bad9e6eaad..50dfcc1c66950d1d9bc218cf379ed69255eefd5f 100644 (file)
@@ -115,6 +115,14 @@ const char *rspamd_html_tag_name(void *tag, gsize *len);
 struct html_image *rspamd_html_find_embedded_image(void *html_content,
                                                                                                   const char *cid, gsize cid_len);
 
+/**
+ * Link all embedded HTML images using a Content-ID to rspamd_image hash table
+ * @param html_content
+ * @param cid_images
+ */
+void rspamd_html_link_embedded_images(void *html_content,
+                                                                         GHashTable *cid_images);
+
 /**
  * Stores parsed content in ftok_t structure
  * @param html_content
index 98687ea7afea5011a395aaefa5c2251843e0fa36..75badf5ed06f83196b7b34007c92fd0c9a8ca416 100644 (file)
@@ -330,6 +330,39 @@ Thank you,
     task:destroy()
   end)
 
+  test("Embedded image dimensions are linked by Content-ID", function()
+    local png = "\137PNG\r\n\26\n" ..
+        "\0\0\0\13IHDR" ..
+        "\0\0\1\64\0\0\0\240"
+    local msg = table.concat {
+      hdrs,
+      "Content-Type: multipart/related; boundary=LINKED-IMAGE\n",
+      "\n",
+      "--LINKED-IMAGE\n",
+      "Content-Type: text/html\n",
+      "\n",
+      "<img src=\"cid:shared-image\">\n",
+      "--LINKED-IMAGE\n",
+      "Content-Type: image/png\n",
+      "Content-ID: <shared-image>\n",
+      "Content-Transfer-Encoding: binary\n",
+      "\n",
+      png,
+      "\n--LINKED-IMAGE--\n",
+    }
+    local res, task = rspamd_task.load_from_string(msg, rspamd_config)
+    assert_true(res, "failed to load message")
+    task:process_message()
+
+    local text_parts = task:get_text_parts()
+    assert_equal(1, #text_parts)
+    local html_images = text_parts[1]:get_html():get_images()
+    assert_equal(1, #html_images)
+    assert_equal(320, html_images[1].width)
+    assert_equal(240, html_images[1].height)
+    task:destroy()
+  end)
+
   test("Part URLs are not deduplicated across MIME parts", function()
     local msg = table.concat {
       hdrs,
index f824d1f0b286eae264039fe4ca80dececc88871a..ee9a71d08905ea0d1da4890381d8f4194030fbcc 100644 (file)
@@ -28,6 +28,7 @@
 #include "rspamd_cxx_unit_rfc2047.hxx"
 #include "rspamd_cxx_unit_html_url_rewrite.hxx"
 #include "rspamd_cxx_unit_html_cta.hxx"
+#include "rspamd_cxx_unit_images.hxx"
 #include "rspamd_cxx_unit_upstream_token_bucket.hxx"
 #include "rspamd_cxx_unit_upstream_ring_hash.hxx"
 #include "rspamd_cxx_unit_upstream_round_robin.hxx"
diff --git a/test/rspamd_cxx_unit_images.hxx b/test/rspamd_cxx_unit_images.hxx
new file mode 100644 (file)
index 0000000..4a14525
--- /dev/null
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2026 Vsevolod Stakhov
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef RSPAMD_RSPAMD_CXX_UNIT_IMAGES_HXX
+#define RSPAMD_RSPAMD_CXX_UNIT_IMAGES_HXX
+
+#define DOCTEST_CONFIG_IMPLEMENTATION_IN_DLL
+#include "doctest/doctest.h"
+
+#include "libmime/images.h"
+#include "libserver/html/html.h"
+#include "libserver/html/html.hxx"
+#include "libutil/mem_pool.h"
+
+#include <string>
+
+TEST_SUITE("image_processing")
+{
+       TEST_CASE("bulk Content-ID linking covers repeated HTML images")
+       {
+               auto *pool = rspamd_mempool_new(rspamd_mempool_suggest_size(), nullptr, 0);
+               std::string html;
+               html.reserve(1024 * 32);
+
+               for (unsigned int i = 0; i < 1024; i++) {
+                       html += "<img src=\"cid:shared-image\">";
+               }
+
+               auto *input = g_byte_array_sized_new(html.size());
+               g_byte_array_append(input,
+                                                       reinterpret_cast<const guint8 *>(html.data()), html.size());
+               auto *parsed = rspamd_html_process_part(pool, input);
+               g_byte_array_free(input, TRUE);
+
+               struct rspamd_image mime_image = {};
+               mime_image.width = 320;
+               mime_image.height = 240;
+               auto *cid_images = g_hash_table_new(g_str_hash, g_str_equal);
+               g_hash_table_insert(cid_images,
+                                                       const_cast<char *>("shared-image"), &mime_image);
+
+               rspamd_html_link_embedded_images(parsed, cid_images);
+
+               auto *hc = rspamd::html::html_content::from_ptr(parsed);
+               REQUIRE(hc->images.size() == 1024);
+               for (const auto *html_image: hc->images) {
+                       CHECK(html_image->embedded_image == &mime_image);
+                       CHECK(html_image->width == mime_image.width);
+                       CHECK(html_image->height == mime_image.height);
+               }
+
+               g_hash_table_destroy(cid_images);
+               rspamd_mempool_delete(pool);
+       }
+}
+
+#endif