From: Vsevolod Stakhov Date: Mon, 15 Jun 2026 08:59:47 +0000 (+0100) Subject: [Fix] mx_check: loopback-only MX is LOCAL, not bogon X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=e7a8f3002b8e2e9cf91f3d71cbbdff7adb546e4e;p=thirdparty%2Frspamd.git [Fix] mx_check: loopback-only MX is LOCAL, not bogon 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 --- diff --git a/src/plugins/lua/mx_check.lua b/src/plugins/lua/mx_check.lua index 08d219a6d1..de37816a3b 100644 --- a/src/plugins/lua/mx_check.lua +++ b/src/plugins/lua/mx_check.lua @@ -23,8 +23,8 @@ end -- Three-layer Redis cache (d: / m: / i:) under -- :. 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 index 0000000000..74187ded4e --- /dev/null +++ b/test/functional/cases/170_mx_check_selfmx.robot @@ -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 diff --git a/test/functional/configs/mx_check-dns.conf b/test/functional/configs/mx_check-dns.conf index 48b5218083..27d60db755 100644 --- a/test/functional/configs/mx_check-dns.conf +++ b/test/functional/configs/mx_check-dns.conf @@ -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 index 0000000000..189fb8ea2c --- /dev/null +++ b/test/functional/configs/mx_check-selfmx.conf @@ -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; +} diff --git a/test/functional/configs/mx_check.conf b/test/functional/configs/mx_check.conf index 648cfba5e2..61f6e99284 100644 --- a/test/functional/configs/mx_check.conf +++ b/test/functional/configs/mx_check.conf @@ -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;