]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Fix] url: canonicalise bare emails to slash-less mailto: vstakhov-mailto-dedup
authorVsevolod Stakhov <vsevolod@rspamd.com>
Fri, 5 Jun 2026 08:09:20 +0000 (09:09 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Fri, 5 Jun 2026 08:09:20 +0000 (09:09 +0100)
A bare email in text/HTML and the same address inside an explicit
mailto: URL were extracted as two separate emails. The '@' matcher (and
the HTML bare-email path) injected a literal "mailto://" prefix, while a
parsed mailto: URL is non-hierarchical and drops the // (RFC 6068,
a4ae51536). The URL/email khash dedup keys on the full url->string
(hash + a urllen guard ahead of rspamd_emails_cmp), so the two string
forms landed in different buckets and never collapsed -> the address was
duplicated.

Canonicalise both bare-email injection sites to the slash-less "mailto:"
form so every path yields the identical mailto:user@host string and the
existing dedup works:

- src/libserver/url.c: '@' matcher prefix "mailto://" -> "mailto:"
- src/libserver/html/html_url.cxx: same for bare emails in HTML

Adjust the extract_specific_urls scheme-strip helper to tolerate the
slash-less form, and add a regression test in test/lua/unit/url.lua.

src/libserver/html/html_url.cxx
src/libserver/url.c
test/lua/unit/lua_util.extract_specific_urls.lua
test/lua/unit/url.lua

index 2cdf03ee33b954e61641b321cd68da203eab76e3..e72f875990090596ee228edfa78398a7d8555273 100644 (file)
@@ -434,8 +434,13 @@ auto html_process_url(rspamd_mempool_t *pool, std::string_view &input, lua_State
                                        }
                                        else if (s[i] == '@') {
                                                /* Likely email prefix */
-                                               prefix = "mailto://";
-                                               dlen += sizeof("mailto://") - 1;
+                                               /*
+                                                * mailto: is non-hierarchical (RFC 6068); inject the
+                                                * bare scheme without // so it canonicalises to the
+                                                * same string as a parsed mailto: URL and dedups.
+                                                */
+                                               prefix = "mailto:";
+                                               dlen += sizeof("mailto:") - 1;
                                                no_prefix = TRUE;
                                        }
                                        else if (s[i] == ':' && i != 0) {
index 199e0e7374553f5cc8eb9bf811252abcae0f8e29..f389604982b1985a051d8631d52ebf9dcbe57263 100644 (file)
@@ -186,7 +186,12 @@ struct url_matcher static_matchers[] = {
         0},
        /* Likely emails */
        {
-               "@", "mailto://", url_email_start, url_email_end,
+               /*
+                * mailto: is non-hierarchical (RFC 6068); inject the bare scheme
+                * without // so a bare-text email canonicalises to the same string
+                * as an explicitly parsed mailto: URL and the two deduplicate.
+                */
+               "@", "mailto:", url_email_start, url_email_end,
                0}};
 
 struct rspamd_url_flag_name {
index a7e2f9f48ac8c5ba5553a3c49367c8bd71694cd7..eb74a25e41f9fa29d551cfb3c8787ab4eeced757 100644 (file)
@@ -67,7 +67,8 @@ Content-Type: text/html; charset="utf-8"
 
 local function prepare_actual_result(actual)
   return fun.totable(fun.map(
-      function(u) return u:get_raw():gsub('^%w+://', '') end,
+      -- strip the scheme; mailto: is non-hierarchical so the // is optional
+      function(u) return u:get_raw():gsub('^%w+:/*', '') end,
       actual
   ))
 end
index 83ee0fc476eda5d16bbc6ad93e5f1ec1ae649b8c..83ad439bd8a431172ecd3b52e212498099d13bae 100644 (file)
@@ -274,3 +274,49 @@ context("URL check functions", function()
     assert_equal(res[#res], '')
   end)
 end)
+
+context("URL email canonicalisation and dedup", function()
+  local rspamd_task = require "rspamd_task"
+  local rspamd_util = require "rspamd_util"
+  local test_helper = require "rspamd_test_helper"
+
+  local cfg = rspamd_util.config_from_ucl(test_helper.default_config(),
+      "INIT_URL,INIT_LIBS,INIT_SYMCACHE,INIT_VALIDATE,INIT_PRELOAD_MAPS")
+
+  local function emails_of(mime_type, body)
+    local msg = string.format(
+        "From: s@example.org\r\nTo: r@example.org\r\nSubject: t\r\n" ..
+        "MIME-Version: 1.0\r\nContent-Type: %s\r\n\r\n%s\r\n", mime_type, body)
+    local res, task = rspamd_task.load_from_string(msg, cfg)
+    assert(res, "failed to load message")
+    assert(task:process_message(), "failed to process message")
+    local out = {}
+    for _, u in ipairs(task:get_emails() or {}) do
+      out[#out + 1] = u:get_text()
+    end
+    return out
+  end
+
+  -- A bare-text email and the same address inside an explicit mailto: URL must
+  -- canonicalise to one mailto: form and deduplicate. Regression: the '@'
+  -- matcher injected a literal "mailto://" prefix while a parsed mailto: URL is
+  -- non-hierarchical (RFC 6068) and keeps no //, so the two string forms never
+  -- collapsed and the address was extracted twice.
+  test("bare email and explicit mailto dedupe (text/plain)", function()
+    local em = emails_of("text/plain", "addr@example.com<mailto:addr@example.com>")
+    assert_equal(1, #em, "expected a single email, got " .. #em)
+    assert_equal("mailto:addr@example.com", em[1])
+  end)
+
+  test("bare email and mailto href dedupe (text/html)", function()
+    local em = emails_of("text/html",
+        '<html><body>addr@example.com or <a href="mailto:addr@example.com">x</a></body></html>')
+    assert_equal(1, #em, "expected a single email, got " .. #em)
+    assert_equal("mailto:addr@example.com", em[1])
+  end)
+
+  test("distinct emails are not over-deduped", function()
+    local em = emails_of("text/plain", "a@example.com and b@example.org")
+    assert_equal(2, #em, "expected two distinct emails, got " .. #em)
+  end)
+end)