diff options
Diffstat (limited to '')
-rw-r--r-- | src/plugins/lua/spf.lua | 242 |
1 files changed, 242 insertions, 0 deletions
diff --git a/src/plugins/lua/spf.lua b/src/plugins/lua/spf.lua new file mode 100644 index 0000000..5e15128 --- /dev/null +++ b/src/plugins/lua/spf.lua @@ -0,0 +1,242 @@ +--[[ +Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com> + +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. +]]-- + +local N = "spf" +local lua_util = require "lua_util" +local rspamd_spf = require "rspamd_spf" +local bit = require "bit" +local rspamd_logger = require "rspamd_logger" + +if confighelp then + rspamd_config:add_example(nil, N, + 'Performs SPF checks', + [[ +spf { + # Enable module + enabled = true + # Number of elements in the cache of parsed SPF records + spf_cache_size = 2048; + # Default max expire for an element in this cache + spf_cache_expire = 1d; + # Whitelist IPs from checks + whitelist = "/path/to/some/file"; + # Maximum number of recursive DNS subrequests (e.g. includes chanin length) + max_dns_nesting = 10; + # Maximum count of DNS requests per record + max_dns_requests = 30; + # Minimum TTL enforced for all elements in SPF records + min_cache_ttl = 5m; + # Disable all IPv6 lookups + disable_ipv6 = false; + # Use IP address from a received header produced by this relay (using by attribute) + external_relay = ["192.168.1.1"]; +} + ]]) + return +end + +local symbols = { + fail = "R_SPF_FAIL", + softfail = "R_SPF_SOFTFAIL", + neutral = "R_SPF_NEUTRAL", + allow = "R_SPF_ALLOW", + dnsfail = "R_SPF_DNSFAIL", + permfail = "R_SPF_PERMFAIL", + na = "R_SPF_NA", +} + +local default_config = { + spf_cache_size = 2048, + max_dns_nesting = 10, + max_dns_requests = 30, + whitelist = nil, + min_cache_ttl = 60 * 5, + disable_ipv6 = false, + symbols = symbols, + external_relay = nil, +} + +local local_config = rspamd_config:get_all_opt('spf') +local auth_and_local_conf = lua_util.config_check_local_or_authed(rspamd_config, N, + false, false) + +if local_config then + local_config = lua_util.override_defaults(default_config, local_config) +else + local_config = default_config +end + +local function spf_check_callback(task) + + local ip + + if local_config.external_relay then + -- Search received headers to get header produced by an external relay + local rh = task:get_received_headers() or {} + local found = false + + for i, hdr in ipairs(rh) do + if hdr.real_ip and local_config.external_relay:get_key(hdr.real_ip) then + -- We can use the next header as a source of IP address + if rh[i + 1] then + local nhdr = rh[i + 1] + lua_util.debugm(N, task, 'found external relay %s at received header number %s -> %s', + local_config.external_relay, i, nhdr.real_ip) + + if nhdr.real_ip then + ip = nhdr.real_ip + found = true + end + end + + break + end + end + if not found then + ip = task:get_from_ip() + rspamd_logger.warnx(task, + "cannot find external relay for SPF checks in received headers; use the original IP: %s", + tostring(ip)) + end + else + ip = task:get_from_ip() + end + + local function flag_to_symbol(fl) + if bit.band(fl, rspamd_spf.flags.temp_fail) ~= 0 then + return local_config.symbols.dnsfail + elseif bit.band(fl, rspamd_spf.flags.perm_fail) ~= 0 then + return local_config.symbols.permfail + elseif bit.band(fl, rspamd_spf.flags.na) ~= 0 then + return local_config.symbols.na + end + + return 'SPF_UNKNOWN' + end + + local function policy_decode(res) + if res == rspamd_spf.policy.fail then + return local_config.symbols.fail, '-' + elseif res == rspamd_spf.policy.pass then + return local_config.symbols.allow, '+' + elseif res == rspamd_spf.policy.soft_fail then + return local_config.symbols.softfail, '~' + elseif res == rspamd_spf.policy.neutral then + return local_config.symbols.neutral, '?' + end + + return 'SPF_UNKNOWN', '?' + end + + local function spf_resolved_cb(record, flags, err) + lua_util.debugm(N, task, 'got spf results: %s flags, %s err', + flags, err) + + if record then + local result, flag_or_policy, error_or_addr = record:check_ip(ip) + + lua_util.debugm(N, task, + 'checked ip %s: result=%s, flag_or_policy=%s, error_or_addr=%s', + ip, flags, err, error_or_addr) + + if result then + local sym, code = policy_decode(flag_or_policy) + local opt = string.format('%s%s', code, error_or_addr.str or '???') + if bit.band(flags, rspamd_spf.flags.cached) ~= 0 then + opt = opt .. ':c' + rspamd_logger.infox(task, + "use cached record for %s (0x%s) in LRU cache for %s seconds", + record:get_domain(), + record:get_digest(), + record:get_ttl() - math.floor(task:get_timeval(true) - + record:get_timestamp())); + end + task:insert_result(sym, 1.0, opt) + else + local sym = flag_to_symbol(flag_or_policy) + task:insert_result(sym, 1.0, error_or_addr) + end + else + local sym = flag_to_symbol(flags) + task:insert_result(sym, 1.0, err) + end + end + + if ip then + if local_config.whitelist and ip and local_config.whitelist:get_key(ip) then + rspamd_logger.infox(task, 'whitelisted SPF checks from %s', + tostring(ip)) + return + end + + if lua_util.is_skip_local_or_authed(task, auth_and_local_conf, ip) then + rspamd_logger.infox(task, 'skip SPF checks for local networks and authorized users') + return + end + + rspamd_spf.resolve(task, spf_resolved_cb) + else + lua_util.debugm(N, task, "spf checks are not possible as no source IP address is defined") + end + + -- FIXME: we actually need to set this variable when we really checked SPF + -- However, the old C module has set it all the times + -- Hence, we follow the same rule for now. It should be better designed at some day + local mpool = task:get_mempool() + local dmarc_checks = mpool:get_variable('dmarc_checks', 'double') or 0 + dmarc_checks = dmarc_checks + 1 + mpool:set_variable('dmarc_checks', dmarc_checks) +end + +-- Register all symbols and init rspamd_spf library +rspamd_spf.config(local_config) +local sym_id = rspamd_config:register_symbol { + name = 'SPF_CHECK', + type = 'callback', + flags = 'fine,empty', + groups = { 'policies', 'spf' }, + score = 0.0, + callback = spf_check_callback, + -- We can merely estimate timeout here, as it is possible to construct an SPF record that would cause + -- many DNS requests. But we won't like to set the maximum value for that all the time, as + -- the majority of requests will typically have 1-4 subrequests + augmentations = { string.format("timeout=%f", rspamd_config:get_dns_timeout() * 4 or 0.0) }, +} + +if local_config.whitelist then + local lua_maps = require "lua_maps" + + local_config.whitelist = lua_maps.map_add_from_ucl(local_config.whitelist, + "radix", "SPF whitelist map") +end + +if local_config.external_relay then + local lua_maps = require "lua_maps" + + local_config.external_relay = lua_maps.map_add_from_ucl(local_config.external_relay, + "radix", "External IP SPF map") +end + +for _, sym in pairs(local_config.symbols) do + rspamd_config:register_symbol { + name = sym, + type = 'virtual', + parent = sym_id, + groups = { 'policies', 'spf' }, + } +end + + |