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.

misc.lua 17KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  1. --[[
  2. Copyright (c) 2011-2017, Vsevolod Stakhov <vsevolod@highsecure.ru>
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. ]]--
  13. -- This is main lua config file for rspamd
  14. local E = {}
  15. local fun = require "fun"
  16. local util = require "rspamd_util"
  17. local rspamd_regexp = require "rspamd_regexp"
  18. -- Different text parts
  19. rspamd_config.R_PARTS_DIFFER = {
  20. callback = function(task)
  21. local distance = task:get_mempool():get_variable('parts_distance', 'double')
  22. if distance then
  23. local nd = tonumber(distance)
  24. -- ND is relation of different words to total words
  25. if nd >= 0.5 then
  26. local tw = task:get_mempool():get_variable('total_words', 'int')
  27. if tw then
  28. local score
  29. if tw > 30 then
  30. -- We are confident about difference
  31. score = (nd - 0.5) * 2.0
  32. else
  33. -- We are not so confident about difference
  34. score = (nd - 0.5)
  35. end
  36. task:insert_result('R_PARTS_DIFFER', score,
  37. string.format('%.1f%%', tostring(100.0 * nd)))
  38. end
  39. end
  40. end
  41. return false
  42. end,
  43. score = 1.0,
  44. description = 'Text and HTML parts differ',
  45. group = 'body'
  46. }
  47. -- Date issues
  48. rspamd_config.MISSING_DATE = {
  49. callback = function(task)
  50. local date = task:get_header_raw('Date')
  51. if date == nil or date == '' then
  52. return true
  53. end
  54. return false
  55. end,
  56. score = 1.0,
  57. description = 'Message date is missing',
  58. group = 'date'
  59. }
  60. rspamd_config.DATE_IN_FUTURE = {
  61. callback = function(task)
  62. local dm = task:get_date{format = 'message', gmt = true}
  63. local dt = task:get_date{format = 'connect', gmt = true}
  64. -- 2 hours
  65. if dm > 0 and dm - dt > 7200 then
  66. return true
  67. end
  68. return false
  69. end,
  70. score = 4.0,
  71. description = 'Message date is in future',
  72. group = 'date'
  73. }
  74. rspamd_config.DATE_IN_PAST = {
  75. callback = function(task)
  76. local dm = task:get_date{format = 'message', gmt = true}
  77. local dt = task:get_date{format = 'connect', gmt = true}
  78. -- A day
  79. if dm > 0 and dt - dm > 86400 then
  80. return true
  81. end
  82. return false
  83. end,
  84. score = 1.0,
  85. description = 'Message date is in past',
  86. group = 'date'
  87. }
  88. rspamd_config.R_SUSPICIOUS_URL = {
  89. callback = function(task)
  90. local urls = task:get_urls()
  91. if urls then
  92. for _,u in ipairs(urls) do
  93. if u:is_obscured() then
  94. task:insert_result('R_SUSPICIOUS_URL', 1.0, u:get_host())
  95. end
  96. end
  97. end
  98. return false
  99. end,
  100. score = 5.0,
  101. one_shot = true,
  102. description = 'Obfusicated or suspicious URL has been found in a message',
  103. group = 'url'
  104. }
  105. rspamd_config.ENVFROM_PRVS = {
  106. callback = function (task)
  107. --[[
  108. Detect PRVS/BATV addresses to avoid FORGED_SENDER
  109. https://en.wikipedia.org/wiki/Bounce_Address_Tag_Validation
  110. Signature syntax:
  111. prvs=TAG=USER@example.com BATV draft (https://tools.ietf.org/html/draft-levine-smtp-batv-01)
  112. prvs=USER=TAG@example.com
  113. btv1==TAG==USER@example.com Barracuda appliance
  114. msprvs1=TAG=USER@example.com Sparkpost email delivery service
  115. ]]--
  116. if not (task:has_from(1) and task:has_from(2)) then
  117. return false
  118. end
  119. local envfrom = task:get_from(1)
  120. local re_text = '^(?:(prvs|msprvs1)=([^=]+)=|btv1==[^=]+==)(.+@(.+))$'
  121. local re = rspamd_regexp.create_cached(re_text)
  122. local c = re:search(envfrom[1].addr:lower(), false, true)
  123. if not c then return false end
  124. local ef = c[1][4]
  125. -- See if it matches the From header
  126. local from = task:get_from(2)
  127. if ef == from[1].addr:lower() then
  128. return true
  129. end
  130. -- Check for prvs=USER=TAG@example.com
  131. local t = c[1][2]
  132. if t == 'prvs' then
  133. local efr = c[1][3] .. '@' .. c[1][5]
  134. if efr == from[1].addr:lower() then
  135. return true
  136. end
  137. end
  138. return false
  139. end,
  140. score = 0.0,
  141. description = "Envelope From is a PRVS address that matches the From address",
  142. group = 'prvs'
  143. }
  144. rspamd_config.ENVFROM_VERP = {
  145. callback = function (task)
  146. if not (task:has_from(1) and task:has_recipients(1)) then
  147. return false
  148. end
  149. local envfrom = task:get_from(1)
  150. local envrcpts = task:get_recipients(1)
  151. -- VERP only works for single recipient messages
  152. if #envrcpts > 1 then return false end
  153. -- Get recipient and compute VERP address
  154. local rcpt = envrcpts[1].addr:lower()
  155. local verp = rcpt:gsub('@','=')
  156. -- Get the user portion of the envfrom
  157. local ef_user = envfrom[1].user:lower()
  158. -- See if the VERP representation of the recipient appears in it
  159. if ef_user:find(verp, 1, true)
  160. and not ef_user:find('+caf_=' .. verp, 1, true) -- Google Forwarding
  161. and not ef_user:find('^srs[01]=') -- SRS
  162. then
  163. return true
  164. end
  165. return false
  166. end,
  167. score = 0.0,
  168. description = "Envelope From is a VERP address",
  169. group = "mailing_list"
  170. }
  171. local check_rcvd = rspamd_config:register_symbol{
  172. name = 'CHECK_RCVD',
  173. callback = function (task)
  174. local rcvds = task:get_received_headers()
  175. if not rcvds then return false end
  176. local all_tls = fun.all(function(rc)
  177. return rc.flags and rc.flags['ssl']
  178. end, fun.filter(function(rc)
  179. return rc.by and rc.by ~= 'localhost'
  180. end, rcvds))
  181. -- See if only the last hop was encrypted
  182. if all_tls then
  183. task:insert_result('RCVD_TLS_ALL', 1.0)
  184. else
  185. local rcvd = rcvds[1]
  186. if rcvd.by and rcvd.by == 'localhost' then
  187. -- Ignore artificial header from Rmilter
  188. rcvd = rcvds[2]
  189. end
  190. if rcvd.flags and rcvd.flags['ssl'] then
  191. task:insert_result('RCVD_TLS_LAST', 1.0)
  192. else
  193. task:insert_result('RCVD_NO_TLS_LAST', 1.0)
  194. end
  195. end
  196. local auth = fun.any(function(rc)
  197. return rc.flags and rc.flags['authenticated']
  198. end, rcvds)
  199. if auth then
  200. task:insert_result('RCVD_VIA_SMTP_AUTH', 1.0)
  201. end
  202. end
  203. }
  204. rspamd_config:register_symbol{
  205. type = 'virtual',
  206. parent = check_rcvd,
  207. name = 'RCVD_TLS_ALL',
  208. description = 'All hops used encrypted transports',
  209. score = 0.0,
  210. group = 'encryption'
  211. }
  212. rspamd_config:register_symbol{
  213. type = 'virtual',
  214. parent = check_rcvd,
  215. name = 'RCVD_TLS_LAST',
  216. description = 'Last hop used encrypted transports',
  217. score = 0.0,
  218. group = 'encryption'
  219. }
  220. rspamd_config:register_symbol{
  221. type = 'virtual',
  222. parent = check_rcvd,
  223. name = 'RCVD_NO_TLS_LAST',
  224. description = 'Last hop did not use encrypted transports',
  225. score = 0.0,
  226. group = 'encryption'
  227. }
  228. rspamd_config:register_symbol{
  229. type = 'virtual',
  230. parent = check_rcvd,
  231. name = 'RCVD_VIA_SMTP_AUTH',
  232. description = 'Message injected via SMTP AUTH',
  233. score = 0.0,
  234. group = 'authentication'
  235. }
  236. rspamd_config.RCVD_HELO_USER = {
  237. callback = function (task)
  238. -- Check HELO argument from MTA
  239. local helo = task:get_helo()
  240. if (helo and helo:lower():find('^user$')) then
  241. return true
  242. end
  243. -- Check Received headers
  244. local rcvds = task:get_header_full('Received')
  245. if not rcvds then return false end
  246. for _, rcvd in ipairs(rcvds) do
  247. local r = rcvd['decoded']:lower()
  248. if (r:find("^%s*from%suser%s")) then return true end
  249. if (r:find("helo[%s=]user[%s%)]")) then return true end
  250. end
  251. end,
  252. description = 'HELO User spam pattern',
  253. score = 3.0
  254. }
  255. rspamd_config.URI_COUNT_ODD = {
  256. callback = function (task)
  257. local ct = task:get_header('Content-Type')
  258. if (ct and ct:lower():find('^multipart/alternative')) then
  259. local urls = task:get_urls() or {}
  260. local nurls = fun.filter(function(url)
  261. return not url:is_html_displayed()
  262. end, urls):foldl(function(acc, val) return acc + 1 end, 0)
  263. if nurls % 2 == 1 then
  264. return true, 1.0, tostring(nurls)
  265. end
  266. end
  267. end,
  268. description = 'Odd number of URIs in multipart/alternative message',
  269. score = 1.0
  270. }
  271. rspamd_config.HAS_ATTACHMENT = {
  272. callback = function (task)
  273. local parts = task:get_parts()
  274. if parts and #parts > 1 then
  275. for _, p in ipairs(parts) do
  276. local cd = p:get_header('Content-Disposition')
  277. if (cd and cd:lower():match('^attachment')) then
  278. return true
  279. end
  280. end
  281. end
  282. end,
  283. description = 'Message contains attachments'
  284. }
  285. -- Requires freemail maps loaded in multimap
  286. local function freemail_reply_neq_from(task)
  287. local frt = task:get_symbol('FREEMAIL_REPLYTO')
  288. local ff = task:get_symbol('FREEMAIL_FROM')
  289. if (frt and ff and frt['options'] and ff['options'] and
  290. frt['options'][1] ~= ff['options'][1])
  291. then
  292. return true
  293. end
  294. return false
  295. end
  296. local freemail_reply_neq_from_id = rspamd_config:register_symbol({
  297. name = 'FREEMAIL_REPLYTO_NEQ_FROM_DOM',
  298. callback = freemail_reply_neq_from,
  299. description = 'Freemail From and Reply-To, but to different Freemail services',
  300. score = 3.0
  301. })
  302. rspamd_config:register_dependency(freemail_reply_neq_from_id, 'FREEMAIL_REPLYTO')
  303. rspamd_config:register_dependency(freemail_reply_neq_from_id, 'FREEMAIL_FROM')
  304. rspamd_config.OMOGRAPH_URL = {
  305. callback = function(task)
  306. local urls = task:get_urls()
  307. if urls then
  308. local bad_omographs = 0
  309. local bad_urls = {}
  310. fun.each(function(u)
  311. local h1 = u:get_host()
  312. local h2 = u:get_phished():get_host()
  313. if h1 and h2 then
  314. if util.is_utf_spoofed(h1, h2) then
  315. table.insert(bad_urls, string.format('%s->%s', h1, h2))
  316. bad_omographs = bad_omographs + 1
  317. end
  318. end
  319. end, fun.filter(function(u) return u:is_phished() end, urls))
  320. if bad_omographs > 0 then
  321. if bad_omographs > 1 then bad_omographs = 1.0 end
  322. return true, bad_omographs, bad_urls
  323. end
  324. end
  325. return false
  326. end,
  327. score = 5.0,
  328. description = 'Url contains both latin and non-latin characters'
  329. }
  330. rspamd_config.URL_IN_SUBJECT = {
  331. callback = function(task)
  332. local urls = task:get_urls()
  333. if urls then
  334. for _,u in ipairs(urls) do
  335. if u:is_subject() then
  336. local subject = task:get_subject()
  337. if subject then
  338. if tostring(u) == subject then
  339. return true,1.0,u:get_host()
  340. end
  341. end
  342. return true,0.25,u:get_host()
  343. end
  344. end
  345. end
  346. return false
  347. end,
  348. score = 4.0,
  349. description = 'Url found in Subject'
  350. }
  351. local aliases_id = rspamd_config:register_symbol{
  352. type = 'prefilter',
  353. name = 'EMAIL_PLUS_ALIASES',
  354. callback = function(task)
  355. local function check_address(addr)
  356. if addr.user then
  357. local cap, pluses = string.match(addr.user, '^([^%+][^%+]*)(%+.*)$')
  358. if cap then
  359. return cap, rspamd_str_split(pluses, '+')
  360. end
  361. end
  362. return nil
  363. end
  364. local function set_addr(addr, new_user)
  365. addr.user = new_user
  366. if addr.domain then
  367. addr.addr = string.format('%s@%s', addr.user, addr.domain)
  368. else
  369. addr.addr = string.format('%s@', addr.user)
  370. end
  371. if addr.name and #addr.name > 0 then
  372. addr.raw = string.format('"%s" <%s>', addr.name, addr.addr)
  373. else
  374. addr.raw = string.format('<%s>', addr.addr)
  375. end
  376. end
  377. local function check_from(type)
  378. if task:has_from(type) then
  379. local addr = task:get_from(type)[1]
  380. local na,tags = check_address(addr)
  381. if na then
  382. set_addr(addr, na)
  383. task:set_from(type, addr)
  384. task:insert_result('TAGGED_FROM', 1.0, fun.totable(
  385. fun.filter(function(t) return t and #t > 0 end, tags)))
  386. end
  387. end
  388. end
  389. check_from('smtp')
  390. check_from('mime')
  391. local function check_rcpt(type)
  392. if task:has_recipients(type) then
  393. local modified = false
  394. local all_tags = {}
  395. local addrs = task:get_recipients(type)
  396. for _, addr in ipairs(addrs) do
  397. local na,tags = check_address(addr)
  398. if na then
  399. set_addr(addr, na)
  400. modified = true
  401. fun.each(function(t) table.insert(all_tags, t) end,
  402. fun.filter(function(t) return t and #t > 0 end, tags))
  403. end
  404. end
  405. if modified then
  406. task:set_recipients(type, addrs)
  407. task:insert_result('TAGGED_RCPT', 1.0, all_tags)
  408. end
  409. end
  410. end
  411. check_rcpt('smtp')
  412. check_rcpt('mime')
  413. end,
  414. priority = 150,
  415. description = 'Removes plus aliases from the email',
  416. }
  417. rspamd_config:register_symbol{
  418. type = 'virtual',
  419. parent = aliases_id,
  420. name = 'TAGGED_RCPT',
  421. description = 'SMTP recipients have plus tags',
  422. score = 0,
  423. }
  424. rspamd_config:register_symbol{
  425. type = 'virtual',
  426. parent = aliases_id,
  427. name = 'TAGGED_FROM',
  428. description = 'SMTP from has plus tags',
  429. score = 0,
  430. }
  431. local check_from_display_name = rspamd_config:register_symbol{
  432. type = 'callback',
  433. callback = function (task)
  434. local from = task:get_from(2)
  435. if not (from and from[1] and from[1].name) then return false end
  436. -- See if we can parse an email address from the name
  437. local parsed = util.parse_mail_address(from[1].name)
  438. if not parsed then return false end
  439. if not (parsed[1] and parsed[1]['addr']) then return false end
  440. -- Make sure we did not mistake e.g. <something>@<name> for an email address
  441. if not parsed[1]['domain'] or not parsed[1]['domain']:find('%.') then return false end
  442. -- See if the parsed domains differ
  443. if not util.strequal_caseless(from[1]['domain'], parsed[1]['domain']) then
  444. -- See if the destination domain is the same as the spoof
  445. local mto = task:get_recipients(2)
  446. local sto = task:get_recipients(1)
  447. if mto then
  448. for _, to in ipairs(mto) do
  449. if to['domain'] ~= '' and util.strequal_caseless(to['domain'], parsed[1]['domain']) then
  450. task:insert_result('SPOOF_DISPLAY_NAME', 1.0, from[1]['domain'], parsed[1]['domain'])
  451. return false
  452. end
  453. end
  454. end
  455. if sto then
  456. for _, to in ipairs(sto) do
  457. if to['domain'] ~= '' and util.strequal_caseless(to['domain'], parsed[1]['domain']) then
  458. task:insert_result('SPOOF_DISPLAY_NAME', 1.0, from[1]['domain'], parsed[1]['domain'])
  459. return false
  460. end
  461. end
  462. end
  463. task:insert_result('FROM_NEQ_DISPLAY_NAME', 1.0, from[1]['domain'], parsed[1]['domain'])
  464. end
  465. return false
  466. end,
  467. }
  468. rspamd_config:register_symbol{
  469. type = 'virtual',
  470. parent = check_from_display_name,
  471. name = 'SPOOF_DISPLAY_NAME',
  472. description = 'Display name is being used to spoof and trick the recipient',
  473. score = 8,
  474. }
  475. rspamd_config:register_symbol{
  476. type = 'virtual',
  477. parent = check_from_display_name,
  478. name = 'FROM_NEQ_DISPLAY_NAME',
  479. description = 'Display name contains an email address different to the From address',
  480. score = 4,
  481. }
  482. rspamd_config.SPOOF_REPLYTO = {
  483. callback = function (task)
  484. -- First check for a Reply-To header
  485. local rt = task:get_header_full('Reply-To')
  486. if not rt or not rt[1] then return false end
  487. -- Get From and To headers
  488. rt = rt[1]['value']
  489. local from = task:get_from(2)
  490. local to = task:get_recipients(2)
  491. if not (from and from[1] and from[1].addr) then return false end
  492. if (to and to[1] and to[1].addr) then
  493. -- Handle common case for Web Contact forms of From = To
  494. if util.strequal_caseless(from[1].addr, to[1].addr) then
  495. return false
  496. end
  497. end
  498. -- SMTP recipients must contain From domain
  499. to = task:get_recipients(1)
  500. if not to then return false end
  501. local found_fromdom = false
  502. for _, t in ipairs(to) do
  503. if util.strequal_caseless(t.domain, from[1].domain) then
  504. found_fromdom = true
  505. break
  506. end
  507. end
  508. if not found_fromdom then return false end
  509. -- Parse Reply-To header
  510. local parsed = ((util.parse_mail_address(rt) or E)[1] or E).domain
  511. if not parsed then return false end
  512. -- Reply-To domain must be different to From domain
  513. if not util.strequal_caseless(parsed, from[1].domain) then
  514. return true, from[1].domain, parsed
  515. end
  516. return false
  517. end,
  518. description = 'Reply-To is being used to spoof and trick the recipient to send an off-domain reply',
  519. score = 6.0
  520. }
  521. rspamd_config.INFO_TO_INFO_LU = {
  522. callback = function(task)
  523. local lu = task:get_header('List-Unsubscribe')
  524. if not lu then return false end
  525. local from = task:get_from('mime')
  526. if not (from and from[1] and util.strequal_caseless(from[1].user, 'info')) then
  527. return false
  528. end
  529. local to = task:get_recipients('smtp')
  530. if not to then return false end
  531. local found = false
  532. for _,r in ipairs(to) do
  533. if util.strequal_caseless(r['user'], 'info') then
  534. found = true
  535. end
  536. end
  537. if found then return true end
  538. return false
  539. end,
  540. description = 'info@ From/To address with List-Unsubscribe headers',
  541. score = 2.0
  542. }
  543. -- Detects bad content-transfer-encoding for text parts
  544. rspamd_config.R_BAD_CTE_7BIT = {
  545. callback = function(task)
  546. local tp = task:get_text_parts() or {}
  547. for _,p in ipairs(tp) do
  548. local cte = p:get_mimepart():get_cte() or ''
  549. if cte ~= '8bit' and p:has_8bit_raw() then
  550. return true,1.0,cte
  551. end
  552. end
  553. return false
  554. end,
  555. score = 4.0,
  556. description = 'Detects bad content-transfer-encoding for text parts',
  557. group = 'header'
  558. }