You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

dmarc.lua 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. --[[
  2. Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
  3. Copyright (c) 2015-2016, Andrew Lewis <nerf@judo.za.org>
  4. Licensed under the Apache License, Version 2.0 (the "License");
  5. you may not use this file except in compliance with the License.
  6. You may obtain a copy of the License at
  7. http://www.apache.org/licenses/LICENSE-2.0
  8. Unless required by applicable law or agreed to in writing, software
  9. distributed under the License is distributed on an "AS IS" BASIS,
  10. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  11. See the License for the specific language governing permissions and
  12. limitations under the License.
  13. ]]--
  14. -- Common dmarc stuff
  15. local rspamd_logger = require "rspamd_logger"
  16. local lua_util = require "lua_util"
  17. local N = "dmarc"
  18. local exports = {}
  19. exports.default_settings = {
  20. auth_and_local_conf = false,
  21. symbols = {
  22. spf_allow_symbol = 'R_SPF_ALLOW',
  23. spf_deny_symbol = 'R_SPF_FAIL',
  24. spf_softfail_symbol = 'R_SPF_SOFTFAIL',
  25. spf_neutral_symbol = 'R_SPF_NEUTRAL',
  26. spf_tempfail_symbol = 'R_SPF_DNSFAIL',
  27. spf_permfail_symbol = 'R_SPF_PERMFAIL',
  28. spf_na_symbol = 'R_SPF_NA',
  29. dkim_allow_symbol = 'R_DKIM_ALLOW',
  30. dkim_deny_symbol = 'R_DKIM_REJECT',
  31. dkim_tempfail_symbol = 'R_DKIM_TEMPFAIL',
  32. dkim_na_symbol = 'R_DKIM_NA',
  33. dkim_permfail_symbol = 'R_DKIM_PERMFAIL',
  34. -- DMARC symbols
  35. allow = 'DMARC_POLICY_ALLOW',
  36. badpolicy = 'DMARC_BAD_POLICY',
  37. dnsfail = 'DMARC_DNSFAIL',
  38. na = 'DMARC_NA',
  39. reject = 'DMARC_POLICY_REJECT',
  40. softfail = 'DMARC_POLICY_SOFTFAIL',
  41. quarantine = 'DMARC_POLICY_QUARANTINE',
  42. },
  43. no_sampling_domains = nil,
  44. no_reporting_domains = nil,
  45. reporting = {
  46. report_local_controller = false, -- Store reports for local/controller scans (for testing only)
  47. redis_keys = {
  48. index_prefix = 'dmarc_idx',
  49. report_prefix = 'dmarc_rpt',
  50. join_char = ';',
  51. },
  52. helo = 'rspamd.localhost',
  53. smtp = '127.0.0.1',
  54. smtp_port = 25,
  55. retries = 2,
  56. from_name = 'Rspamd',
  57. msgid_from = 'rspamd',
  58. enabled = false,
  59. max_entries = 1000,
  60. keys_expire = 172800,
  61. only_domains = nil,
  62. },
  63. actions = {},
  64. }
  65. -- Returns a key used to be inserted into dmarc report sample
  66. exports.dmarc_report = function (task, settings, data)
  67. local rspamd_lua_utils = require "lua_util"
  68. local E = {}
  69. local ip = task:get_from_ip()
  70. if not ip or not ip:is_valid() then
  71. rspamd_logger.infox(task, 'cannot store dmarc report for %s: no valid source IP',
  72. data.domain)
  73. return nil
  74. end
  75. ip = ip:to_string()
  76. if rspamd_lua_utils.is_rspamc_or_controller(task) and not settings.reporting.report_local_controller then
  77. rspamd_logger.infox(task, 'cannot store dmarc report for %s from IP %s: has come from controller/rspamc',
  78. data.domain, ip)
  79. return
  80. end
  81. local dkim_pass = table.concat(data.dkim_results.pass or E, '|')
  82. local dkim_fail = table.concat(data.dkim_results.fail or E, '|')
  83. local dkim_temperror = table.concat(data.dkim_results.temperror or E, '|')
  84. local dkim_permerror = table.concat(data.dkim_results.permerror or E, '|')
  85. local disposition_to_return = data.disposition
  86. local res = table.concat({
  87. ip, data.spf_ok, data.dkim_ok,
  88. disposition_to_return, (data.sampled_out and 'sampled_out' or ''), data.domain,
  89. dkim_pass, dkim_fail, dkim_temperror, dkim_permerror, data.spf_domain, data.spf_result}, ',')
  90. return res
  91. end
  92. exports.gen_munging_callback = function(munging_opts, settings)
  93. local rspamd_util = require "rspamd_util"
  94. local lua_mime = require "lua_mime"
  95. return function (task)
  96. if munging_opts.mitigate_allow_only then
  97. if not task:has_symbol(settings.symbols.allow) then
  98. lua_util.debugm(N, task, 'skip munging, no %s symbol',
  99. settings.symbols.allow)
  100. -- Excepted
  101. return
  102. end
  103. else
  104. local has_dmarc = task:has_symbol(settings.symbols.allow) or
  105. task:has_symbol(settings.symbols.quarantine) or
  106. task:has_symbol(settings.symbols.reject) or
  107. task:has_symbol(settings.symbols.softfail)
  108. if not has_dmarc then
  109. lua_util.debugm(N, task, 'skip munging, no %s symbol',
  110. settings.symbols.allow)
  111. -- Excepted
  112. return
  113. end
  114. end
  115. if munging_opts.mitigate_strict_only then
  116. local s = task:get_symbol(settings.symbols.allow) or {[1] = {}}
  117. local sopts = s[1].options or {}
  118. local seen_strict
  119. for _,o in ipairs(sopts) do
  120. if o == 'reject' or o == 'quarantine' then
  121. seen_strict = true
  122. break
  123. end
  124. end
  125. if not seen_strict then
  126. lua_util.debugm(N, task, 'skip munging, no strict policy found in %s',
  127. settings.symbols.allow)
  128. -- Excepted
  129. return
  130. end
  131. end
  132. if munging_opts.munge_map_condition then
  133. local accepted,trace = munging_opts.munge_map_condition:process(task)
  134. if not accepted then
  135. lua_util.debugm(task, 'skip munging, maps condition not satisfied: (%s)',
  136. trace)
  137. -- Excepted
  138. return
  139. end
  140. end
  141. -- Now, look for domain for munging
  142. local mr = task:get_recipients({ 'mime', 'orig'})
  143. local rcpt_found
  144. if mr then
  145. for _,r in ipairs(mr) do
  146. if r.domain and munging_opts.list_map:get_key(r.addr) then
  147. rcpt_found = r
  148. break
  149. end
  150. end
  151. end
  152. if not rcpt_found then
  153. lua_util.debugm(task, 'skip munging, recipients are not in list_map')
  154. -- Excepted
  155. return
  156. end
  157. local from = task:get_from({ 'mime', 'orig'})
  158. if not from or not from[1] then
  159. lua_util.debugm(task, 'skip munging, from is bad')
  160. -- Excepted
  161. return
  162. end
  163. from = from[1]
  164. local via_user = rcpt_found.user
  165. local via_addr = rcpt_found.addr
  166. local via_name
  167. if from.name then
  168. via_name = string.format('%s via %s', from.name, via_user)
  169. else
  170. via_name = string.format('%s via %s', from.user or 'unknown', via_user)
  171. end
  172. local hdr_encoded = rspamd_util.fold_header('From',
  173. rspamd_util.mime_header_encode(string.format('%s <%s>',
  174. via_name, via_addr)), task:get_newlines_type())
  175. local orig_from_encoded = rspamd_util.fold_header('X-Original-From',
  176. rspamd_util.mime_header_encode(string.format('%s <%s>',
  177. from.name or '', from.addr)), task:get_newlines_type())
  178. local add_hdrs = {
  179. ['From'] = { order = 1, value = hdr_encoded },
  180. }
  181. local remove_hdrs = {['From'] = 0}
  182. local nreply = from.addr
  183. if munging_opts.reply_goes_to_list then
  184. -- Reply-to goes to the list
  185. nreply = via_addr
  186. end
  187. if task:has_header('Reply-To') then
  188. -- If we have reply-to header, then we need to insert an additional
  189. -- address there
  190. local orig_reply = task:get_header_full('Reply-To')[1]
  191. if orig_reply.value then
  192. nreply = string.format('%s, %s', orig_reply.value, nreply)
  193. end
  194. remove_hdrs['Reply-To'] = 1
  195. end
  196. add_hdrs['Reply-To'] = {order = 0, value = nreply}
  197. add_hdrs['X-Original-From'] = { order = 0, value = orig_from_encoded}
  198. lua_mime.modify_headers(task, {
  199. remove = remove_hdrs,
  200. add = add_hdrs
  201. })
  202. lua_util.debugm(N, task, 'munged DMARC header for %s: %s -> %s',
  203. from.domain, hdr_encoded, from.addr)
  204. rspamd_logger.infox(task, 'munged DMARC header for %s', from.addr)
  205. task:insert_result('DMARC_MUNGED', 1.0, from.addr)
  206. end
  207. end
  208. local function gen_dmarc_grammar()
  209. local lpeg = require "lpeg"
  210. lpeg.locale(lpeg)
  211. local space = lpeg.space^0
  212. local name = lpeg.C(lpeg.alpha^1) * space
  213. local sep = (lpeg.S("\\;") * space) + (lpeg.space^1)
  214. local value = lpeg.C(lpeg.P(lpeg.graph - sep)^1)
  215. local pair = lpeg.Cg(name * "=" * space * value) * sep^-1
  216. local list = lpeg.Cf(lpeg.Ct("") * pair^0, rawset)
  217. local version = lpeg.P("v") * space * lpeg.P("=") * space * lpeg.P("DMARC1")
  218. local record = version * sep * list
  219. return record
  220. end
  221. local dmarc_grammar = gen_dmarc_grammar()
  222. local function dmarc_key_value_case(elts)
  223. if type(elts) ~= "table" then
  224. return elts
  225. end
  226. local result = {}
  227. for k, v in pairs(elts) do
  228. k = k:lower()
  229. if k ~= "v" then
  230. v = v:lower()
  231. end
  232. result[k] = v
  233. end
  234. return result
  235. end
  236. --[[
  237. -- Used to check dmarc record, check elements and produce dmarc policy processed
  238. -- result.
  239. -- Returns:
  240. -- false,false - record is garbage
  241. -- false,error_message - record is invalid
  242. -- true,policy_table - record is valid and parsed
  243. ]]
  244. local function dmarc_check_record(log_obj, record, is_tld)
  245. local failed_policy
  246. local result = {
  247. dmarc_policy = 'none'
  248. }
  249. local elts = dmarc_grammar:match(record)
  250. lua_util.debugm(N, log_obj, "got DMARC record: %s, tld_flag=%s, processed=%s",
  251. record, is_tld, elts)
  252. if elts then
  253. elts = dmarc_key_value_case(elts)
  254. local dkim_pol = elts['adkim']
  255. if dkim_pol then
  256. if dkim_pol == 's' then
  257. result.strict_dkim = true
  258. elseif dkim_pol ~= 'r' then
  259. failed_policy = 'adkim tag has invalid value: ' .. dkim_pol
  260. return false,failed_policy
  261. end
  262. end
  263. local spf_pol = elts['aspf']
  264. if spf_pol then
  265. if spf_pol == 's' then
  266. result.strict_spf = true
  267. elseif spf_pol ~= 'r' then
  268. failed_policy = 'aspf tag has invalid value: ' .. spf_pol
  269. return false,failed_policy
  270. end
  271. end
  272. local policy = elts['p']
  273. if policy then
  274. if (policy == 'reject') then
  275. result.dmarc_policy = 'reject'
  276. elseif (policy == 'quarantine') then
  277. result.dmarc_policy = 'quarantine'
  278. elseif (policy ~= 'none') then
  279. failed_policy = 'p tag has invalid value: ' .. policy
  280. return false,failed_policy
  281. end
  282. end
  283. -- Adjust policy if we are in tld mode
  284. local subdomain_policy = elts['sp']
  285. if elts['sp'] and is_tld then
  286. result.subdomain_policy = elts['sp']
  287. if (subdomain_policy == 'reject') then
  288. result.dmarc_policy = 'reject'
  289. elseif (subdomain_policy == 'quarantine') then
  290. result.dmarc_policy = 'quarantine'
  291. elseif (subdomain_policy == 'none') then
  292. result.dmarc_policy = 'none'
  293. elseif (subdomain_policy ~= 'none') then
  294. failed_policy = 'sp tag has invalid value: ' .. subdomain_policy
  295. return false,failed_policy
  296. end
  297. end
  298. result.pct = elts['pct']
  299. if result.pct then
  300. result.pct = tonumber(result.pct)
  301. end
  302. if elts.rua then
  303. result.rua = elts['rua']
  304. end
  305. result.raw_elts = elts
  306. else
  307. return false,false -- Ignore garbage
  308. end
  309. return true, result
  310. end
  311. exports.dmarc_check_record = dmarc_check_record
  312. return exports