|
|
@@ -195,8 +195,8 @@ local function dmarc_report(task, spf_ok, dkim_ok, disposition, |
|
|
|
return res |
|
|
|
end |
|
|
|
|
|
|
|
local function dmarc_callback(task) |
|
|
|
local function maybe_force_action(disposition) |
|
|
|
local function maybe_force_action(task, disposition) |
|
|
|
if disposition then |
|
|
|
local force_action = dmarc_actions[disposition] |
|
|
|
if force_action then |
|
|
|
-- Don't do anything if pre-result has been already set |
|
|
@@ -204,363 +204,489 @@ local function dmarc_callback(task) |
|
|
|
task:set_pre_result(force_action, 'Action set by DMARC') |
|
|
|
end |
|
|
|
end |
|
|
|
local from = task:get_from(2) |
|
|
|
local hfromdom = ((from or E)[1] or E).domain |
|
|
|
local dmarc_domain, spf_domain |
|
|
|
local ip_addr = task:get_ip() |
|
|
|
local dkim_results = {} |
|
|
|
local dmarc_checks = task:get_mempool():get_variable('dmarc_checks', 'int') or 0 |
|
|
|
end |
|
|
|
|
|
|
|
if dmarc_checks ~= 2 then |
|
|
|
rspamd_logger.infox(task, "skip DMARC checks as either SPF or DKIM were not checked"); |
|
|
|
return |
|
|
|
end |
|
|
|
--[[ |
|
|
|
-- Used to check dmarc record, check elements and produce dmarc policy processed |
|
|
|
-- result. |
|
|
|
-- Returns: |
|
|
|
-- false,false - record is garbadge |
|
|
|
-- false,error_message - record is invalid |
|
|
|
-- true,policy_table - record is valid and parsed |
|
|
|
]] |
|
|
|
local function dmarc_check_record(task, record, is_tld) |
|
|
|
local failed_policy |
|
|
|
local result = { |
|
|
|
dmarc_policy = 'none' |
|
|
|
} |
|
|
|
|
|
|
|
local elts = dmarc_grammar:match(record) |
|
|
|
lua_util.debugm(N, task, "got DMARC record: %s, tld_flag=%s, processed=%s", |
|
|
|
record, is_tld, elts) |
|
|
|
|
|
|
|
if elts then |
|
|
|
local dkim_pol = elts['adkim'] |
|
|
|
if dkim_pol then |
|
|
|
if dkim_pol == 's' then |
|
|
|
result.strict_dkim = true |
|
|
|
elseif dkim_pol ~= 'r' then |
|
|
|
failed_policy = 'adkim tag has invalid value: ' .. dkim_pol |
|
|
|
return false,failed_policy |
|
|
|
end |
|
|
|
end |
|
|
|
|
|
|
|
if ((not check_authed and task:get_user()) or |
|
|
|
(not check_local and ip_addr and ip_addr:is_local())) then |
|
|
|
rspamd_logger.infox(task, "skip DMARC checks for local networks and authorized users"); |
|
|
|
return |
|
|
|
end |
|
|
|
if hfromdom and hfromdom ~= '' and not (from or E)[2] then |
|
|
|
dmarc_domain = rspamd_util.get_tld(hfromdom) |
|
|
|
elseif (from or E)[2] then |
|
|
|
task:insert_result(dmarc_symbols['na'], 1.0, 'Duplicate From header') |
|
|
|
return maybe_force_action('na') |
|
|
|
elseif (from or E)[1] then |
|
|
|
task:insert_result(dmarc_symbols['na'], 1.0, 'No domain in From header') |
|
|
|
return maybe_force_action('na') |
|
|
|
local spf_pol = elts['aspf'] |
|
|
|
if spf_pol then |
|
|
|
if spf_pol == 's' then |
|
|
|
result.strict_spf = true |
|
|
|
elseif spf_pol ~= 'r' then |
|
|
|
failed_policy = 'aspf tag has invalid value: ' .. spf_pol |
|
|
|
return false,failed_policy |
|
|
|
end |
|
|
|
end |
|
|
|
|
|
|
|
local policy = elts['p'] |
|
|
|
if policy then |
|
|
|
if (policy == 'reject') then |
|
|
|
result.dmarc_policy = 'reject' |
|
|
|
elseif (policy == 'quarantine') then |
|
|
|
result.dmarc_policy = 'quarantine' |
|
|
|
elseif (policy ~= 'none') then |
|
|
|
failed_policy = 'p tag has invalid value: ' .. policy |
|
|
|
return false,failed_policy |
|
|
|
end |
|
|
|
end |
|
|
|
|
|
|
|
-- Adjust policy if we are in tld mode |
|
|
|
local subdomain_policy = elts['sp'] |
|
|
|
if elts['sp'] and is_tld then |
|
|
|
result.subdomain_policy = elts['sp'] |
|
|
|
|
|
|
|
if (subdomain_policy == 'reject') then |
|
|
|
result.dmarc_policy = 'reject' |
|
|
|
elseif (subdomain_policy == 'quarantine') then |
|
|
|
result.dmarc_policy = 'quarantine' |
|
|
|
elseif (subdomain_policy == 'none') then |
|
|
|
result.dmarc_policy = 'none' |
|
|
|
elseif (subdomain_policy ~= 'none') then |
|
|
|
failed_policy = 'sp tag has invalid value: ' .. subdomain_policy |
|
|
|
return false,failed_policy |
|
|
|
end |
|
|
|
end |
|
|
|
result.pct = elts['pct'] |
|
|
|
if result.pct then |
|
|
|
result.pct = tonumber(result.pct) |
|
|
|
end |
|
|
|
|
|
|
|
if elts.rua then |
|
|
|
result.rua = elts['rua'] |
|
|
|
end |
|
|
|
else |
|
|
|
task:insert_result(dmarc_symbols['na'], 1.0, 'No From header') |
|
|
|
return maybe_force_action('na') |
|
|
|
return false,false -- Ignore garbadge |
|
|
|
end |
|
|
|
|
|
|
|
local function dmarc_report_cb(err) |
|
|
|
if not err then |
|
|
|
rspamd_logger.infox(task, '<%1> dmarc report saved for %2', |
|
|
|
task:get_message_id(), hfromdom) |
|
|
|
return true, result |
|
|
|
end |
|
|
|
|
|
|
|
local function dmarc_validate_policy(task, policy, hdrfromdom) |
|
|
|
local reason = {} |
|
|
|
|
|
|
|
-- Check dkim and spf symbols |
|
|
|
local spf_ok = false |
|
|
|
local dkim_ok = false |
|
|
|
local spf_tmpfail = false |
|
|
|
local dkim_tmpfail = false |
|
|
|
|
|
|
|
local spf_domain = ((task:get_from(1) or E)[1] or E).domain |
|
|
|
|
|
|
|
if not spf_domain or spf_domain == '' then |
|
|
|
spf_domain = task:get_helo() or '' |
|
|
|
end |
|
|
|
|
|
|
|
if task:has_symbol(symbols['spf_allow_symbol']) then |
|
|
|
if policy.strict_spf then |
|
|
|
if rspamd_util.strequal_caseless(spf_domain, hdrfromdom) then |
|
|
|
spf_ok = true |
|
|
|
else |
|
|
|
table.insert(reason, "SPF not aligned (strict)") |
|
|
|
end |
|
|
|
else |
|
|
|
rspamd_logger.errx(task, '<%1> dmarc report is not saved for %2: %3', |
|
|
|
task:get_message_id(), hfromdom, err) |
|
|
|
local spf_tld = rspamd_util.get_tld(spf_domain) |
|
|
|
if rspamd_util.strequal_caseless(spf_tld, policy.domain) then |
|
|
|
spf_ok = true |
|
|
|
else |
|
|
|
table.insert(reason, "SPF not aligned (relaxed)") |
|
|
|
end |
|
|
|
end |
|
|
|
else |
|
|
|
if task:has_symbol(symbols['spf_tempfail_symbol']) then |
|
|
|
if policy.strict_spf then |
|
|
|
if rspamd_util.strequal_caseless(spf_domain, hdrfromdom) then |
|
|
|
spf_tmpfail = true |
|
|
|
end |
|
|
|
else |
|
|
|
local spf_tld = rspamd_util.get_tld(spf_domain) |
|
|
|
if rspamd_util.strequal_caseless(spf_tld, policy.domain) then |
|
|
|
spf_tmpfail = true |
|
|
|
end |
|
|
|
end |
|
|
|
end |
|
|
|
|
|
|
|
table.insert(reason, "No valid SPF") |
|
|
|
end |
|
|
|
|
|
|
|
local function dmarc_dns_cb(_, to_resolve, results, err) |
|
|
|
|
|
|
|
local lookup_domain = string.sub(to_resolve, 8) |
|
|
|
if err and (err ~= 'requested record is not found' and err ~= 'no records with this name') then |
|
|
|
task:insert_result(dmarc_symbols['dnsfail'], 1.0, lookup_domain .. ' : ' .. err) |
|
|
|
return maybe_force_action('dnsfail') |
|
|
|
elseif err and (err == 'requested record is not found' or err == 'no records with this name') and |
|
|
|
lookup_domain == dmarc_domain then |
|
|
|
task:insert_result(dmarc_symbols['na'], 1.0, lookup_domain) |
|
|
|
return maybe_force_action('na') |
|
|
|
end |
|
|
|
local opts = ((task:get_symbol('DKIM_TRACE') or E)[1] or E).options |
|
|
|
local dkim_results = { |
|
|
|
pass = {}, |
|
|
|
temperror = {}, |
|
|
|
permerror = {}, |
|
|
|
fail = {}, |
|
|
|
} |
|
|
|
|
|
|
|
if not results then |
|
|
|
if lookup_domain ~= dmarc_domain then |
|
|
|
local resolve_name = '_dmarc.' .. dmarc_domain |
|
|
|
task:get_resolver():resolve_txt({ |
|
|
|
task=task, |
|
|
|
name = resolve_name, |
|
|
|
callback = dmarc_dns_cb, |
|
|
|
forced = true}) |
|
|
|
return |
|
|
|
end |
|
|
|
|
|
|
|
task:insert_result(dmarc_symbols['na'], 1.0, lookup_domain) |
|
|
|
return maybe_force_action('na') |
|
|
|
end |
|
|
|
if opts then |
|
|
|
dkim_results.pass = {} |
|
|
|
local dkim_violated |
|
|
|
|
|
|
|
local pct |
|
|
|
local reason = {} |
|
|
|
local strict_spf = false |
|
|
|
local strict_dkim = false |
|
|
|
local dmarc_policy = 'none' |
|
|
|
local found_policy = false |
|
|
|
local failed_policy |
|
|
|
local rua |
|
|
|
|
|
|
|
for _,r in ipairs(results) do |
|
|
|
if failed_policy then break end |
|
|
|
local function try() |
|
|
|
local elts = dmarc_grammar:match(r) |
|
|
|
if not elts then |
|
|
|
return |
|
|
|
for _,opt in ipairs(opts) do |
|
|
|
local check_res = string.sub(opt, -1) |
|
|
|
local domain = string.sub(opt, 1, -3) |
|
|
|
|
|
|
|
if check_res == '+' then |
|
|
|
table.insert(dkim_results.pass, domain) |
|
|
|
|
|
|
|
if policy.strict_dkim then |
|
|
|
if rspamd_util.strequal_caseless(hdrfromdom, domain) then |
|
|
|
dkim_ok = true |
|
|
|
else |
|
|
|
dkim_violated = "DKIM not aligned (strict)" |
|
|
|
end |
|
|
|
else |
|
|
|
if found_policy then |
|
|
|
failed_policy = 'Multiple policies defined in DNS' |
|
|
|
return |
|
|
|
local dkim_tld = rspamd_util.get_tld(domain) |
|
|
|
|
|
|
|
if rspamd_util.strequal_caseless(dkim_tld, policy.domain) then |
|
|
|
dkim_ok = true |
|
|
|
else |
|
|
|
found_policy = true |
|
|
|
dkim_violated = "DKIM not aligned (relaxed)" |
|
|
|
end |
|
|
|
end |
|
|
|
|
|
|
|
if elts then |
|
|
|
local dkim_pol = elts['adkim'] |
|
|
|
if dkim_pol then |
|
|
|
if dkim_pol == 's' then |
|
|
|
strict_dkim = true |
|
|
|
elseif dkim_pol ~= 'r' then |
|
|
|
failed_policy = 'adkim tag has invalid value: ' .. dkim_pol |
|
|
|
return |
|
|
|
elseif check_res == '?' then |
|
|
|
-- Check for dkim tempfail |
|
|
|
if not dkim_ok then |
|
|
|
if policy.strict_dkim then |
|
|
|
if rspamd_util.strequal_caseless(hdrfromdom, domain) then |
|
|
|
dkim_tmpfail = true |
|
|
|
end |
|
|
|
end |
|
|
|
else |
|
|
|
local dkim_tld = rspamd_util.get_tld(domain) |
|
|
|
|
|
|
|
local spf_pol = elts['aspf'] |
|
|
|
if spf_pol then |
|
|
|
if spf_pol == 's' then |
|
|
|
strict_spf = true |
|
|
|
elseif spf_pol ~= 'r' then |
|
|
|
failed_policy = 'aspf tag has invalid value: ' .. spf_pol |
|
|
|
return |
|
|
|
if rspamd_util.strequal_caseless(dkim_tld, policy.domain) then |
|
|
|
dkim_tmpfail = true |
|
|
|
end |
|
|
|
end |
|
|
|
end |
|
|
|
table.insert(dkim_results.temperror, domain) |
|
|
|
elseif check_res == '-' then |
|
|
|
table.insert(dkim_results.fail, domain) |
|
|
|
else |
|
|
|
table.insert(dkim_results.permerror, domain) |
|
|
|
end |
|
|
|
end |
|
|
|
|
|
|
|
local policy = elts['p'] |
|
|
|
if policy then |
|
|
|
if (policy == 'reject') then |
|
|
|
dmarc_policy = 'reject' |
|
|
|
elseif (policy == 'quarantine') then |
|
|
|
dmarc_policy = 'quarantine' |
|
|
|
elseif (policy ~= 'none') then |
|
|
|
failed_policy = 'p tag has invalid value: ' .. policy |
|
|
|
return |
|
|
|
end |
|
|
|
end |
|
|
|
if not dkim_ok and dkim_violated then |
|
|
|
table.insert(reason, dkim_violated) |
|
|
|
end |
|
|
|
else |
|
|
|
table.insert(reason, "No valid DKIM") |
|
|
|
end |
|
|
|
|
|
|
|
local subdomain_policy = elts['sp'] |
|
|
|
if subdomain_policy and lookup_domain == dmarc_domain then |
|
|
|
if (subdomain_policy == 'reject') then |
|
|
|
if dmarc_domain ~= hfromdom then |
|
|
|
dmarc_policy = 'reject' |
|
|
|
end |
|
|
|
elseif (subdomain_policy == 'quarantine') then |
|
|
|
if dmarc_domain ~= hfromdom then |
|
|
|
dmarc_policy = 'quarantine' |
|
|
|
end |
|
|
|
elseif (subdomain_policy == 'none') then |
|
|
|
if dmarc_domain ~= hfromdom then |
|
|
|
dmarc_policy = 'none' |
|
|
|
end |
|
|
|
elseif (subdomain_policy ~= 'none') then |
|
|
|
failed_policy = 'sp tag has invalid value: ' .. subdomain_policy |
|
|
|
return |
|
|
|
end |
|
|
|
end |
|
|
|
lua_util.debugm(N, task, "validated dmarc policy for %s: %s; dkim_ok=%s, dkim_tempfail=%s, spf_ok=%s, spf_tempfail=%s", |
|
|
|
policy.domain, policy.dmarc_policy, |
|
|
|
dkim_ok, dkim_tmpfail, |
|
|
|
spf_ok, spf_tmpfail) |
|
|
|
|
|
|
|
pct = elts['pct'] |
|
|
|
if pct then |
|
|
|
pct = tonumber(pct) |
|
|
|
end |
|
|
|
local disposition = 'none' |
|
|
|
local sampled_out = false |
|
|
|
|
|
|
|
if not rua then |
|
|
|
rua = elts['rua'] |
|
|
|
end |
|
|
|
local function handle_dmarc_failure(what, reason_str) |
|
|
|
if not policy.pct or policy.pct == 100 then |
|
|
|
task:insert_result(what, 1.0, |
|
|
|
policy.domain .. ' : ' .. reason_str, policy.dmarc_policy) |
|
|
|
disposition = "quarantine" |
|
|
|
else |
|
|
|
if (math.random(100) > policy.pct) then |
|
|
|
if (not no_sampling_domains or |
|
|
|
not no_sampling_domains:get_key(policy.domain)) then |
|
|
|
task:insert_result(dmarc_symbols['softfail'], 1.0, |
|
|
|
policy.domain .. ' : ' .. reason_str, policy.dmarc_policy, "sampled_out") |
|
|
|
sampled_out = true |
|
|
|
else |
|
|
|
task:insert_result(what, 1.0, |
|
|
|
policy.domain .. ' : ' .. reason_str, policy.dmarc_policy, "local_policy") |
|
|
|
disposition = what |
|
|
|
end |
|
|
|
else |
|
|
|
task:insert_result(dmarc_symbols[what], 1.0, |
|
|
|
policy.domain .. ' : ' .. reason_str, policy.dmarc_policy) |
|
|
|
disposition = what |
|
|
|
end |
|
|
|
try() |
|
|
|
end |
|
|
|
|
|
|
|
if not found_policy then |
|
|
|
if lookup_domain ~= dmarc_domain then |
|
|
|
local resolve_name = '_dmarc.' .. dmarc_domain |
|
|
|
task:get_resolver():resolve_txt({ |
|
|
|
task=task, |
|
|
|
name = resolve_name, |
|
|
|
callback = dmarc_dns_cb, |
|
|
|
forced = true}) |
|
|
|
maybe_force_action(task, disposition) |
|
|
|
end |
|
|
|
|
|
|
|
return |
|
|
|
if spf_ok or dkim_ok then |
|
|
|
--[[ |
|
|
|
https://tools.ietf.org/html/rfc7489#section-6.6.2 |
|
|
|
DMARC evaluation can only yield a "pass" result after one of the |
|
|
|
underlying authentication mechanisms passes for an aligned |
|
|
|
identifier. |
|
|
|
]]-- |
|
|
|
task:insert_result(dmarc_symbols['allow'], 1.0, policy.domain, |
|
|
|
policy.dmarc_policy) |
|
|
|
else |
|
|
|
--[[ |
|
|
|
https://tools.ietf.org/html/rfc7489#section-6.6.2 |
|
|
|
|
|
|
|
If neither passes and one or both of them fail due to a |
|
|
|
temporary error, the Receiver evaluating the message is unable to |
|
|
|
conclude that the DMARC mechanism had a permanent failure; they |
|
|
|
therefore cannot apply the advertised DMARC policy. |
|
|
|
]]-- |
|
|
|
if spf_tmpfail or dkim_tmpfail then |
|
|
|
task:insert_result(dmarc_symbols['dnsfail'], 1.0, policy.domain.. |
|
|
|
' : ' .. 'SPF/DKIM temp error', policy.dmarc_policy) |
|
|
|
else |
|
|
|
-- We can now check the failed policy and maybe send report data elt |
|
|
|
local reason_str = table.concat(reason, ',') |
|
|
|
|
|
|
|
if policy.dmarc_policy == 'quarantine' then |
|
|
|
handle_dmarc_failure('quarantine', reason_str) |
|
|
|
elseif policy.dmarc_policy == 'reject' then |
|
|
|
handle_dmarc_failure('reject', reason_str) |
|
|
|
else |
|
|
|
task:insert_result(dmarc_symbols['na'], 1.0, lookup_domain) |
|
|
|
return maybe_force_action('na') |
|
|
|
task:insert_result(dmarc_symbols['softfail'], 1.0, |
|
|
|
policy.domain .. ' : ' .. reason_str, |
|
|
|
policy.dmarc_policy) |
|
|
|
end |
|
|
|
end |
|
|
|
end |
|
|
|
|
|
|
|
local res = 0.5 |
|
|
|
if failed_policy then |
|
|
|
task:insert_result(dmarc_symbols['badpolicy'], res, lookup_domain .. ' : ' .. failed_policy) |
|
|
|
return maybe_force_action('badpolicy') |
|
|
|
if policy.rua and redis_params and dmarc_reporting then |
|
|
|
if no_reporting_domains then |
|
|
|
if no_reporting_domains:get_key(policy.domain) or |
|
|
|
no_reporting_domains:get_key(rspamd_util.get_tld(policy.domain)) then |
|
|
|
rspamd_logger.infox(task, 'DMARC reporting suppressed for %1', policy.domain) |
|
|
|
return |
|
|
|
end |
|
|
|
end |
|
|
|
|
|
|
|
-- Check dkim and spf symbols |
|
|
|
local spf_ok = false |
|
|
|
local dkim_ok = false |
|
|
|
spf_domain = ((task:get_from(1) or E)[1] or E).domain |
|
|
|
if not spf_domain or spf_domain == '' then |
|
|
|
spf_domain = task:get_helo() or '' |
|
|
|
local function dmarc_report_cb(err) |
|
|
|
if not err then |
|
|
|
rspamd_logger.infox(task, '<%1> dmarc report saved for %2', |
|
|
|
task:get_message_id(), hdrfromdom) |
|
|
|
else |
|
|
|
rspamd_logger.errx(task, '<%1> dmarc report is not saved for %2: %3', |
|
|
|
task:get_message_id(), hdrfromdom, err) |
|
|
|
end |
|
|
|
end |
|
|
|
|
|
|
|
if task:has_symbol(symbols['spf_allow_symbol']) then |
|
|
|
if strict_spf and rspamd_util.strequal_caseless(spf_domain, hfromdom) then |
|
|
|
spf_ok = true |
|
|
|
elseif strict_spf then |
|
|
|
table.insert(reason, "SPF not aligned (strict)") |
|
|
|
end |
|
|
|
if not strict_spf then |
|
|
|
local spf_tld = rspamd_util.get_tld(spf_domain) |
|
|
|
if rspamd_util.strequal_caseless(spf_tld, dmarc_domain) then |
|
|
|
spf_ok = true |
|
|
|
else |
|
|
|
table.insert(reason, "SPF not aligned (relaxed)") |
|
|
|
end |
|
|
|
end |
|
|
|
local spf_result |
|
|
|
if spf_ok then |
|
|
|
spf_result = 'pass' |
|
|
|
elseif spf_tmpfail then |
|
|
|
spf_result = 'temperror' |
|
|
|
else |
|
|
|
table.insert(reason, "No valid SPF") |
|
|
|
end |
|
|
|
local das = task:get_symbol(symbols['dkim_allow_symbol']) |
|
|
|
if ((das or E)[1] or E).options then |
|
|
|
dkim_results.pass = {} |
|
|
|
for _,domain in ipairs(das[1]['options']) do |
|
|
|
table.insert(dkim_results.pass, domain) |
|
|
|
if strict_dkim and rspamd_util.strequal_caseless(hfromdom, domain) then |
|
|
|
dkim_ok = true |
|
|
|
elseif strict_dkim then |
|
|
|
table.insert(reason, "DKIM not aligned (strict)") |
|
|
|
end |
|
|
|
if not strict_dkim then |
|
|
|
local dkim_tld = rspamd_util.get_tld(domain) |
|
|
|
if rspamd_util.strequal_caseless(dkim_tld, dmarc_domain) then |
|
|
|
dkim_ok = true |
|
|
|
else |
|
|
|
table.insert(reason, "DKIM not aligned (relaxed)") |
|
|
|
end |
|
|
|
end |
|
|
|
if task:get_symbol(symbols.spf_deny_symbol) then |
|
|
|
spf_result = 'fail' |
|
|
|
elseif task:get_symbol(symbols.spf_softfail_symbol) then |
|
|
|
spf_result = 'softfail' |
|
|
|
elseif task:get_symbol(symbols.spf_neutral_symbol) then |
|
|
|
spf_result = 'neutral' |
|
|
|
elseif task:get_symbol(symbols.spf_permfail_symbol) then |
|
|
|
spf_result = 'permerror' |
|
|
|
else |
|
|
|
spf_result = 'none' |
|
|
|
end |
|
|
|
else |
|
|
|
table.insert(reason, "No valid DKIM") |
|
|
|
end |
|
|
|
|
|
|
|
local disposition = 'none' |
|
|
|
local sampled_out = false |
|
|
|
local spf_tmpfail, dkim_tmpfail |
|
|
|
|
|
|
|
if not (spf_ok or dkim_ok) then |
|
|
|
local reason_str = table.concat(reason, ", ") |
|
|
|
res = 1.0 |
|
|
|
spf_tmpfail = task:get_symbol(symbols['spf_tempfail_symbol']) |
|
|
|
dkim_tmpfail = task:get_symbol(symbols['dkim_tempfail_symbol']) |
|
|
|
if (spf_tmpfail or dkim_tmpfail) then |
|
|
|
if ((dkim_tmpfail or E)[1] or E).options then |
|
|
|
dkim_results.tempfail = {} |
|
|
|
for _,domain in ipairs(dkim_tmpfail[1]['options']) do |
|
|
|
table.insert(dkim_results.tempfail, domain) |
|
|
|
end |
|
|
|
end |
|
|
|
task:insert_result(dmarc_symbols['dnsfail'], 1.0, lookup_domain .. ' : ' .. 'SPF/DKIM temp error', dmarc_policy) |
|
|
|
return maybe_force_action('dnsfail') |
|
|
|
-- Prepare and send redis report element |
|
|
|
local period = os.date('%Y%m%d', |
|
|
|
task:get_date({format = 'connect', gmt = true})) |
|
|
|
local dmarc_domain_key = table.concat( |
|
|
|
{redis_keys.report_prefix, hdrfromdom, period}, redis_keys.join_char) |
|
|
|
local report_data = dmarc_report(task, |
|
|
|
spf_ok and 'pass' or 'fail', |
|
|
|
dkim_ok and 'pass' or 'fail', |
|
|
|
disposition, |
|
|
|
sampled_out, |
|
|
|
hdrfromdom, |
|
|
|
spf_domain, |
|
|
|
dkim_results, |
|
|
|
spf_result) |
|
|
|
|
|
|
|
local idx_key = table.concat({redis_keys.index_prefix, period}, |
|
|
|
redis_keys.join_char) |
|
|
|
|
|
|
|
if report_data then |
|
|
|
rspamd_redis.exec_redis_script(take_report_id, |
|
|
|
{task = task, is_write = true}, |
|
|
|
dmarc_report_cb, |
|
|
|
{idx_key, dmarc_domain_key}, |
|
|
|
{hdrfromdom, report_data}) |
|
|
|
end |
|
|
|
end |
|
|
|
end |
|
|
|
|
|
|
|
local function dmarc_callback(task) |
|
|
|
local from = task:get_from(2) |
|
|
|
local hfromdom = ((from or E)[1] or E).domain |
|
|
|
local dmarc_domain |
|
|
|
local ip_addr = task:get_ip() |
|
|
|
local dmarc_checks = task:get_mempool():get_variable('dmarc_checks', 'int') or 0 |
|
|
|
local seen_invalid = false |
|
|
|
|
|
|
|
if dmarc_checks ~= 2 then |
|
|
|
rspamd_logger.infox(task, "skip DMARC checks as either SPF or DKIM were not checked"); |
|
|
|
return |
|
|
|
end |
|
|
|
|
|
|
|
if ((not check_authed and task:get_user()) or |
|
|
|
(not check_local and ip_addr and ip_addr:is_local())) then |
|
|
|
rspamd_logger.infox(task, "skip DMARC checks for local networks and authorized users"); |
|
|
|
return |
|
|
|
end |
|
|
|
|
|
|
|
-- Do some initial sanity checks, detect tld domain if different |
|
|
|
if hfromdom and hfromdom ~= '' and not (from or E)[2] then |
|
|
|
dmarc_domain = rspamd_util.get_tld(hfromdom) |
|
|
|
elseif (from or E)[2] then |
|
|
|
task:insert_result(dmarc_symbols['na'], 1.0, 'Duplicate From header') |
|
|
|
return maybe_force_action(task, 'na') |
|
|
|
elseif (from or E)[1] then |
|
|
|
task:insert_result(dmarc_symbols['na'], 1.0, 'No domain in From header') |
|
|
|
return maybe_force_action(task,'na') |
|
|
|
else |
|
|
|
task:insert_result(dmarc_symbols['na'], 1.0, 'No From header') |
|
|
|
return maybe_force_action(task,'na') |
|
|
|
end |
|
|
|
|
|
|
|
|
|
|
|
local dns_checks_inflight = 0 |
|
|
|
local dmarc_domain_policy = {} |
|
|
|
local dmarc_tld_policy = {} |
|
|
|
|
|
|
|
local function process_dmarc_policy(policy, is_tld) |
|
|
|
lua_util.debugm(N, task, "validate DMARC policy (is_tld=%s): %s", |
|
|
|
is_tld, policy) |
|
|
|
if policy.err and policy.symbol then |
|
|
|
-- In case of fatal errors or final check for tld, we give up and |
|
|
|
-- insert result |
|
|
|
if is_tld or policy.fatal then |
|
|
|
task:insert_result(policy.symbol, 1.0, policy.err) |
|
|
|
maybe_force_action(task, policy.disposition) |
|
|
|
|
|
|
|
return true |
|
|
|
end |
|
|
|
if dmarc_policy == 'quarantine' then |
|
|
|
if not pct or pct == 100 then |
|
|
|
task:insert_result(dmarc_symbols['quarantine'], res, lookup_domain .. ' : ' .. reason_str, dmarc_policy) |
|
|
|
disposition = "quarantine" |
|
|
|
else |
|
|
|
if (math.random(100) > pct) then |
|
|
|
if (not no_sampling_domains or not no_sampling_domains:get_key(dmarc_domain)) then |
|
|
|
task:insert_result(dmarc_symbols['softfail'], res, lookup_domain .. ' : ' .. reason_str, dmarc_policy, "sampled_out") |
|
|
|
sampled_out = true |
|
|
|
else |
|
|
|
task:insert_result(dmarc_symbols['quarantine'], res, lookup_domain .. ' : ' .. reason_str, dmarc_policy, "local_policy") |
|
|
|
disposition = "quarantine" |
|
|
|
end |
|
|
|
elseif policy.dmarc_policy then |
|
|
|
dmarc_validate_policy(task, policy, hfromdom) |
|
|
|
|
|
|
|
return true -- We have a more specific version, use it |
|
|
|
end |
|
|
|
|
|
|
|
return false -- Missing record |
|
|
|
end |
|
|
|
|
|
|
|
local function gen_dmarc_cb(lookup_domain, is_tld) |
|
|
|
local policy_target = dmarc_domain_policy |
|
|
|
if is_tld then |
|
|
|
policy_target = dmarc_tld_policy |
|
|
|
end |
|
|
|
|
|
|
|
return function (_, _, results, err) |
|
|
|
dns_checks_inflight = dns_checks_inflight - 1 |
|
|
|
|
|
|
|
if not seen_invalid then |
|
|
|
policy_target.domain = lookup_domain |
|
|
|
|
|
|
|
if err then |
|
|
|
if (err ~= 'requested record is not found' and |
|
|
|
err ~= 'no records with this name') then |
|
|
|
policy_target.err = lookup_domain .. ' : ' .. err |
|
|
|
policy_target.symbol = dmarc_symbols['dnsfail'] |
|
|
|
else |
|
|
|
task:insert_result(dmarc_symbols['quarantine'], res, lookup_domain .. ' : ' .. reason_str, dmarc_policy) |
|
|
|
disposition = "quarantine" |
|
|
|
policy_target.err = lookup_domain |
|
|
|
policy_target.symbol = dmarc_symbols['na'] |
|
|
|
end |
|
|
|
end |
|
|
|
elseif dmarc_policy == 'reject' then |
|
|
|
if not pct or pct == 100 then |
|
|
|
task:insert_result(dmarc_symbols['reject'], res, lookup_domain .. ' : ' .. reason_str, dmarc_policy) |
|
|
|
disposition = "reject" |
|
|
|
else |
|
|
|
if (math.random(100) > pct) then |
|
|
|
if (not no_sampling_domains or not no_sampling_domains:get_key(dmarc_domain)) then |
|
|
|
task:insert_result(dmarc_symbols['quarantine'], res, lookup_domain .. ' : ' .. reason_str, dmarc_policy, "sampled_out") |
|
|
|
disposition = "quarantine" |
|
|
|
sampled_out = true |
|
|
|
local has_valid_policy = false |
|
|
|
|
|
|
|
for _,rec in ipairs(results) do |
|
|
|
local ret,results_or_err = dmarc_check_record(task, rec, is_tld) |
|
|
|
|
|
|
|
if not ret then |
|
|
|
if results_or_err then |
|
|
|
-- We have a fatal parsing error, give up |
|
|
|
policy_target.err = lookup_domain .. ' : ' .. results_or_err |
|
|
|
policy_target.symbol = dmarc_symbols['badpolicy'] |
|
|
|
policy_target.fatal = true |
|
|
|
seen_invalid = true |
|
|
|
end |
|
|
|
else |
|
|
|
task:insert_result(dmarc_symbols['reject'], res, lookup_domain .. ' : ' .. reason_str, dmarc_policy, "local_policy") |
|
|
|
disposition = "reject" |
|
|
|
if has_valid_policy then |
|
|
|
policy_target.err = lookup_domain .. ' : ' .. |
|
|
|
'Multiple policies defined in DNS' |
|
|
|
policy_target.symbol = dmarc_symbols['badpolicy'] |
|
|
|
policy_target.fatal = true |
|
|
|
seen_invalid = true |
|
|
|
end |
|
|
|
has_valid_policy = true |
|
|
|
|
|
|
|
for k,v in pairs(results_or_err) do |
|
|
|
policy_target[k] = v |
|
|
|
end |
|
|
|
end |
|
|
|
else |
|
|
|
task:insert_result(dmarc_symbols['reject'], res, lookup_domain .. ' : ' .. reason_str, dmarc_policy) |
|
|
|
disposition = "reject" |
|
|
|
end |
|
|
|
end |
|
|
|
else |
|
|
|
task:insert_result(dmarc_symbols['softfail'], res, lookup_domain .. ' : ' .. reason_str, dmarc_policy) |
|
|
|
end |
|
|
|
else |
|
|
|
task:insert_result(dmarc_symbols['allow'], res, lookup_domain, dmarc_policy) |
|
|
|
end |
|
|
|
|
|
|
|
if rua and redis_params and dmarc_reporting then |
|
|
|
|
|
|
|
if no_reporting_domains then |
|
|
|
if no_reporting_domains:get_key(dmarc_domain) or no_reporting_domains:get_key(rspamd_util.get_tld(dmarc_domain)) then |
|
|
|
rspamd_logger.infox(task, 'DMARC reporting suppressed for %1', dmarc_domain) |
|
|
|
return maybe_force_action(disposition) |
|
|
|
end |
|
|
|
end |
|
|
|
|
|
|
|
local spf_result |
|
|
|
if spf_ok then |
|
|
|
spf_result = 'pass' |
|
|
|
elseif spf_tmpfail then |
|
|
|
spf_result = 'temperror' |
|
|
|
else |
|
|
|
if task:get_symbol(symbols.spf_deny_symbol) then |
|
|
|
spf_result = 'fail' |
|
|
|
elseif task:get_symbol(symbols.spf_softfail_symbol) then |
|
|
|
spf_result = 'softfail' |
|
|
|
elseif task:get_symbol(symbols.spf_neutral_symbol) then |
|
|
|
spf_result = 'neutral' |
|
|
|
elseif task:get_symbol(symbols.spf_permfail_symbol) then |
|
|
|
spf_result = 'permerror' |
|
|
|
else |
|
|
|
spf_result = 'none' |
|
|
|
end |
|
|
|
end |
|
|
|
local dkim_deny = ((task:get_symbol(symbols.dkim_deny_symbol) or E)[1] or E).options |
|
|
|
if dkim_deny then |
|
|
|
dkim_results.fail = {} |
|
|
|
for _, domain in ipairs(dkim_deny) do |
|
|
|
table.insert(dkim_results.fail, domain) |
|
|
|
end |
|
|
|
end |
|
|
|
local dkim_permerror = ((task:get_symbol(symbols.dkim_permfail_symbol) or E)[1] or E).options |
|
|
|
if dkim_permerror then |
|
|
|
dkim_results.permerror = {} |
|
|
|
for _, domain in ipairs(dkim_permerror) do |
|
|
|
table.insert(dkim_results.permerror, domain) |
|
|
|
if dns_checks_inflight == 0 then |
|
|
|
lua_util.debugm(N, task, "finished DNS queries, validate policies") |
|
|
|
-- We have checked both tld and real domain (if different) |
|
|
|
if not process_dmarc_policy(dmarc_domain_policy, false) then |
|
|
|
-- Try tld policy as well |
|
|
|
process_dmarc_policy(dmarc_tld_policy, true) |
|
|
|
end |
|
|
|
end |
|
|
|
-- Prepare and send redis report element |
|
|
|
local period = os.date('%Y%m%d', task:get_date({format = 'connect', gmt = true})) |
|
|
|
local dmarc_domain_key = table.concat({redis_keys.report_prefix, hfromdom, period}, redis_keys.join_char) |
|
|
|
local report_data = dmarc_report(task, spf_ok and 'pass' or 'fail', dkim_ok and 'pass' or 'fail', disposition, sampled_out, |
|
|
|
hfromdom, spf_domain, dkim_results, spf_result) |
|
|
|
local idx_key = table.concat({redis_keys.index_prefix, period}, redis_keys.join_char) |
|
|
|
|
|
|
|
if report_data then |
|
|
|
rspamd_redis.exec_redis_script(take_report_id, {task = task, is_write = true}, dmarc_report_cb, |
|
|
|
{idx_key, dmarc_domain_key}, {hfromdom, report_data}) |
|
|
|
end |
|
|
|
end |
|
|
|
|
|
|
|
return maybe_force_action(disposition) |
|
|
|
|
|
|
|
end |
|
|
|
|
|
|
|
-- Do initial request |
|
|
|
local resolve_name = '_dmarc.' .. hfromdom |
|
|
|
|
|
|
|
task:get_resolver():resolve_txt({ |
|
|
|
task=task, |
|
|
|
name = resolve_name, |
|
|
|
callback = dmarc_dns_cb, |
|
|
|
forced = true}) |
|
|
|
callback = gen_dmarc_cb(hfromdom, false), |
|
|
|
forced = true |
|
|
|
}) |
|
|
|
dns_checks_inflight = dns_checks_inflight + 1 |
|
|
|
|
|
|
|
if dmarc_domain ~= hfromdom then |
|
|
|
resolve_name = '_dmarc.' .. dmarc_domain |
|
|
|
|
|
|
|
task:get_resolver():resolve_txt({ |
|
|
|
task=task, |
|
|
|
name = resolve_name, |
|
|
|
callback = gen_dmarc_cb(dmarc_domain, true), |
|
|
|
forced = true |
|
|
|
}) |
|
|
|
|
|
|
|
dns_checks_inflight = dns_checks_inflight + 1 |
|
|
|
end |
|
|
|
end |
|
|
|
|
|
|
|
|
|
|
|
local function try_opts(where) |
|
|
|
local ret = false |
|
|
|
local opts = rspamd_config:get_all_opt(where) |
|
|
@@ -595,6 +721,7 @@ if opts['symbols'] then |
|
|
|
end |
|
|
|
end |
|
|
|
|
|
|
|
-- XXX: rework this shitty code some day please |
|
|
|
if opts['reporting'] == true then |
|
|
|
redis_params = rspamd_parse_redis_server('dmarc') |
|
|
|
if not redis_params then |