]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Fix] mx_check: loopback-only MX is LOCAL, not bogon
authorVsevolod Stakhov <vsevolod@rspamd.com>
Mon, 15 Jun 2026 08:59:47 +0000 (09:59 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Mon, 15 Jun 2026 10:25:28 +0000 (11:25 +0100)
A domain whose MX resolves only to loopback is hosted on the scanning
host itself -- a self-MX, typically the host's own FQDN mapped to
127.0.0.1 in /etc/hosts, which rspamd's resolver honours as a fake reply
that shadows public DNS. That made fully DMARC-aligned self-hosted mail
score MX_BOGON_ONLY (+8.0): the strongest "not spam infrastructure"
signal treated as the strongest spam signal.

Move 127.0.0.0/8 and ::1/128 from BOGON_CIDRS to LOCAL_CIDRS so a
loopback-only MX emits MX_LOCAL_ONLY (3.0) instead. test_mode now lifts
loopback out of the LOCAL set (was: bogon) so the probe path stays
exercisable against local listeners.

Add a regression test (170_mx_check_selfmx.robot, test_mode = false):
the existing suites run test_mode = true and cannot cover the production
loopback-classification path.

Closes #6101

src/plugins/lua/mx_check.lua
test/functional/cases/170_mx_check_selfmx.robot [new file with mode: 0644]
test/functional/configs/mx_check-dns.conf
test/functional/configs/mx_check-selfmx.conf [new file with mode: 0644]
test/functional/configs/mx_check.conf

index 08d219a6d1819463b5885f601bf5381e1d1211c2..de37816a3baa33efe6371c622b2ab9aa53d0269b 100644 (file)
@@ -23,8 +23,8 @@ end
 -- Three-layer Redis cache (d:<domain> / m:<mxhost> / i:<ip>) under
 -- <key_prefix>:. Two TCP probe shapes: plain connect-only, or connect +
 -- multi-line SMTP banner validation (verify_greeting / send_quit). Resolved
--- IPs are classified into PUBLIC / LOCAL (RFC1918, CGNAT, ULA) / BOGON
--- (loopback, TEST-NET, multicast, link-local, etc.) before any probe runs.
+-- IPs are classified into PUBLIC / LOCAL (loopback / self-MX, RFC1918, CGNAT,
+-- ULA) / BOGON (TEST-NET, multicast, link-local, etc.) before any probe runs.
 -- Optional trust/skip maps at each cache layer (exclude_domains, exclude_mxs,
 -- exclude_ips). Symbols at any cache layer can short-circuit further work.
 
@@ -102,8 +102,8 @@ local settings = {
   check_local = false,
 
   -- Testing only. When true, loopback (127/8, ::1) is treated as a normal
-  -- probeable address instead of a bogon, so the probe path can be exercised
-  -- against a local listener. NEVER enable this in production.
+  -- probeable (public) address instead of a self-MX LOCAL, so the probe path
+  -- can be exercised against a local listener. NEVER enable this in production.
   test_mode = false,
 
   -- Source domains. One probe + one symbol per unique domain;
@@ -177,6 +177,17 @@ local settings = {
 
 -- Static IP-class ranges; module-private radix maps built at config-load.
 local LOCAL_CIDRS = {
+  -- Loopback. An MX (or A-fallback target) resolving only to loopback means the
+  -- domain is hosted on this very machine -- a self-MX. The usual cause is the
+  -- host's own FQDN mapped to 127.0.0.1 in /etc/hosts (Debian / admin
+  -- convention), which rspamd's resolver honours as a fake reply that shadows
+  -- public DNS, so a published-and-routable MX shows up as loopback here. That
+  -- is the strongest possible "not spam infrastructure" signal, never a bogon;
+  -- classifying it LOCAL avoids a +8.0 false positive on DMARC-aligned mail.
+  -- Lifted out of this set under test_mode so the probe path can hit a local
+  -- listener. See https://github.com/rspamd/rspamd/issues/6101.
+  '127.0.0.0/8',
+  '::1/128',
   -- IPv4 RFC 1918
   '10.0.0.0/8',
   '172.16.0.0/12',
@@ -187,14 +198,12 @@ local LOCAL_CIDRS = {
   'fc00::/7',
 }
 
--- Loopback prefixes lifted out of BOGON_CIDRS at config-load when test_mode
--- is on, so the probe path can be exercised against a local listener.
+-- Loopback prefixes. Normally classified LOCAL (self-MX, see LOCAL_CIDRS);
+-- lifted out of the LOCAL set at config-load when test_mode is on so the probe
+-- path can be exercised against a local listener.
 local LOOPBACK_CIDRS = { ['127.0.0.0/8'] = true, ['::1/128'] = true }
 
 local BOGON_CIDRS = {
-  -- Loopback (dropped under test_mode)
-  '127.0.0.0/8',
-  '::1/128',
   -- Link-local (APIPA / IPv6 link-local)
   '169.254.0.0/16',
   'fe80::/10',
@@ -1753,11 +1762,11 @@ set_metric_all_sources(settings.symbol_mx_broken, 4.0,
 set_metric_all_sources(settings.symbol_mx_dns_fail, 0.0,
   'Transient DNS path failure (SERVFAIL/REFUSED/timeout); sender not at fault')
 set_metric_all_sources(settings.symbol_mx_local_only, 3.0,
-  'All resolved MX IPs are in private ranges (RFC1918 / CGNAT / ULA); no probe run')
+  'All resolved MX IPs are local (loopback / self-MX, RFC1918, CGNAT, ULA); no probe run')
 set_metric_all_sources(settings.symbol_mx_local_mix, 3.0,
-  'Some resolved MX IPs are in private ranges; public subset probed')
+  'Some resolved MX IPs are local (loopback / RFC1918 / CGNAT / ULA); public subset probed')
 set_metric_all_sources(settings.symbol_mx_bogon_only, 8.0,
-  'All resolved MX IPs are bogon / non-routable (loopback, TEST-NET, multicast, etc.); no probe run')
+  'All resolved MX IPs are bogon / non-routable (TEST-NET, multicast, link-local, etc.); no probe run')
 set_metric_all_sources(settings.symbol_mx_bogon_mix, 5.0,
   'Some resolved MX IPs are bogon / non-routable; public subset probed')
 set_metric_all_sources(settings.symbol_mx_skip, 0.0,
@@ -1787,25 +1796,26 @@ set_metric_all_sources(settings.symbol_mx_a_error, 0.0,
 set_metric_all_sources(settings.symbol_mx_a_invalid, 3.0,
   'A-fallback target accepted TCP but listener does not speak SMTP')
 
--- Static radix maps for IP-class classification. test_mode lifts loopback
--- out of the bogon set so the probe path stays exercisable against a local
--- listener; production must NEVER enable this.
-local bogon_cidrs = BOGON_CIDRS
+-- Static radix maps for IP-class classification. Loopback lives in the LOCAL
+-- set (self-MX, see LOCAL_CIDRS); test_mode lifts it out so loopback classifies
+-- as public and the probe path stays exercisable against a local listener.
+-- Production must NEVER enable test_mode.
+local local_cidrs = LOCAL_CIDRS
 if settings.test_mode then
   rspamd_logger.warnx(rspamd_config,
     'mx_check: test_mode is ON, loopback is treated as probeable; '
       .. 'do NOT use this in production')
-  bogon_cidrs = {}
-  for _, r in ipairs(BOGON_CIDRS) do
+  local_cidrs = {}
+  for _, r in ipairs(LOCAL_CIDRS) do
     if not LOOPBACK_CIDRS[r] then
-      bogon_cidrs[#bogon_cidrs + 1] = r
+      local_cidrs[#local_cidrs + 1] = r
     end
   end
 end
-local_ip_map = lua_maps.map_add_from_ucl(LOCAL_CIDRS, 'radix',
-  'mx_check LOCAL ranges (RFC1918, CGNAT, ULA)')
-bogon_ip_map = lua_maps.map_add_from_ucl(bogon_cidrs, 'radix',
-  'mx_check BOGON ranges (loopback, link-local, TEST-NET, multicast, etc.)')
+local_ip_map = lua_maps.map_add_from_ucl(local_cidrs, 'radix',
+  'mx_check LOCAL ranges (loopback / self-MX, RFC1918, CGNAT, ULA)')
+bogon_ip_map = lua_maps.map_add_from_ucl(BOGON_CIDRS, 'radix',
+  'mx_check BOGON ranges (link-local, TEST-NET, multicast, etc.)')
 
 if settings.exclude_domains then
   exclude_domains = lua_maps.map_add('mx_check', 'exclude_domains', 'glob',
diff --git a/test/functional/cases/170_mx_check_selfmx.robot b/test/functional/cases/170_mx_check_selfmx.robot
new file mode 100644 (file)
index 0000000..74187de
--- /dev/null
@@ -0,0 +1,34 @@
+*** Settings ***
+Suite Setup     Mx Selfmx Setup
+Suite Teardown  Mx Selfmx Teardown
+Library         Process
+Library         ${RSPAMD_TESTDIR}/lib/rspamd.py
+Resource        ${RSPAMD_TESTDIR}/lib/rspamd.robot
+Variables       ${RSPAMD_TESTDIR}/lib/vars.py
+
+*** Variables ***
+${CONFIG}          ${RSPAMD_TESTDIR}/configs/mx_check-selfmx.conf
+${MESSAGE}         ${RSPAMD_TESTDIR}/messages/spam_message.eml
+${REDIS_SCOPE}     Suite
+${RSPAMD_SCOPE}    Suite
+${RSPAMD_URL_TLD}  ${RSPAMD_TESTDIR}/../lua/unit/test_tld.dat
+${SETTINGS}        {symbols_enabled = [MX_INVALID]}
+
+*** Test Cases ***
+# Regression guard for issue #6101: a domain whose MX resolves only to loopback
+# (a self-MX, commonly the host's own FQDN in /etc/hosts) must classify as LOCAL
+# rather than BOGON, so legitimate self-hosted mail is not penalised +8.0.
+# Runs with test_mode = false (the production path); the test_mode = true configs
+# treat loopback as probeable and cannot cover this.
+Loopback-only MX classifies as LOCAL, not BOGON
+  Scan File  ${MESSAGE}  From=test@selfmx.test  Settings=${SETTINGS}
+  Expect Symbol  MX_LOCAL_ONLY
+  Do Not Expect Symbol  MX_BOGON_ONLY
+  Do Not Expect Symbol  MX_INVALID
+
+*** Keywords ***
+Mx Selfmx Setup
+  Rspamd Redis Setup
+
+Mx Selfmx Teardown
+  Rspamd Redis Teardown
index 48b5218083ebd4a5ec939849234f9d5874d84442..27d60db7559f703fcd14541fd30fca8f9fefabf0 100644 (file)
@@ -84,6 +84,22 @@ options {
         type = "a";
         replies = ["192.0.2.10"];
       },
+      {
+        # Self-MX: MX target resolves only to loopback. With test_mode off
+        # (the production default) loopback classifies as LOCAL, not BOGON --
+        # a domain hosted on this very machine is a self-MX, never spam infra.
+        # Exercised by 170_mx_check_selfmx.robot (test_mode = false), since the
+        # test_mode = true configs here treat loopback as probeable instead.
+        # Regression guard for https://github.com/rspamd/rspamd/issues/6101.
+        name = "selfmx.test";
+        type = "mx";
+        replies = ["10 mail.selfmx.test"];
+      },
+      {
+        name = "mail.selfmx.test";
+        type = "a";
+        replies = ["127.0.0.1"];
+      },
       {
         # MX hostname matches exclude_mxs (*.trusted.test) -> MX_WHITE.
         # No A record needed: the check short-circuits before resolving.
diff --git a/test/functional/configs/mx_check-selfmx.conf b/test/functional/configs/mx_check-selfmx.conf
new file mode 100644 (file)
index 0000000..189fb8e
--- /dev/null
@@ -0,0 +1,24 @@
+.include(duplicate=merge,priority=0) "{= env.TESTDIR =}/configs/plugins.conf"
+.include(duplicate=merge,priority=1) "{= env.TESTDIR =}/configs/mx_check-dns.conf"
+
+redis {
+  servers = "{= env.REDIS_ADDR =}:{= env.REDIS_PORT =}";
+}
+
+mx_check {
+  enabled = true;
+  # Use port 1 (guaranteed closed everywhere) so any accidental probe is a
+  # deterministic refusal; the self-MX case classifies LOCAL and never probes.
+  port = 1;
+  connect_timeout = 0.5;
+  read_timeout = 0.5;
+  greylist_invalid = false;
+  # test_mode OFF: exercise the production classification path. Loopback MX
+  # targets classify as LOCAL (self-MX), NOT bogon -- the behaviour that
+  # regressed in issue #6101. Kept separate from mx_check.conf, which runs
+  # test_mode = true to make loopback probeable against local listeners.
+  test_mode = false;
+  # Focus on envelope-from for deterministic single-symbol assertions.
+  check_mime_from = false;
+  check_reply_to = false;
+}
index 648cfba5e267db44597ca3fca1b19009e27f07f2..61f6e992842ff5506b8e18473c43404721755f5f 100644 (file)
@@ -13,7 +13,7 @@ mx_check {
   connect_timeout = 0.5;
   read_timeout = 0.5;
   greylist_invalid = false;
-  # test_mode lifts loopback out of the bogon set so the probe path can be
+  # test_mode lifts loopback out of the LOCAL set so the probe path can be
   # exercised against 127.0.0.1. Other reserved ranges (RFC1918, TEST-NET)
   # still classify, so MX_LOCAL_* / MX_BOGON_* stay testable too.
   test_mode = true;