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.

headers_checks.lua 28KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071
  1. --[[
  2. Copyright (c) 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. local util = require "rspamd_util"
  14. local ipairs = ipairs
  15. local pairs = pairs
  16. local table = table
  17. local tostring = tostring
  18. local tonumber = tonumber
  19. local fun = require "fun"
  20. local E = {}
  21. local rcvd_cb_id = rspamd_config:register_symbol{
  22. name = 'CHECK_RECEIVED',
  23. type = 'callback',
  24. callback = function(task)
  25. local cnts = {
  26. [1] = 'ONE',
  27. [2] = 'TWO',
  28. [3] = 'THREE',
  29. [5] = 'FIVE',
  30. [7] = 'SEVEN',
  31. [12] = 'TWELVE'
  32. }
  33. local def = 'ZERO'
  34. local received = task:get_received_headers()
  35. local nreceived = fun.reduce(function(acc, rcvd)
  36. return acc + 1
  37. end, 0, fun.filter(function(h)
  38. return not h['artificial']
  39. end, received))
  40. for k,v in pairs(cnts) do
  41. if nreceived >= tonumber(k) then
  42. def = v
  43. end
  44. end
  45. task:insert_result('RCVD_COUNT_' .. def, 1.0, tostring(nreceived))
  46. end
  47. }
  48. rspamd_config:register_symbol{
  49. name = 'RCVD_COUNT_ZERO',
  50. score = 0.0,
  51. parent = rcvd_cb_id,
  52. type = 'virtual',
  53. description = 'No received',
  54. group = 'headers',
  55. }
  56. rspamd_config:register_symbol{
  57. name = 'RCVD_COUNT_ONE',
  58. score = 0.0,
  59. parent = rcvd_cb_id,
  60. type = 'virtual',
  61. description = 'One received',
  62. group = 'headers',
  63. }
  64. rspamd_config:register_symbol{
  65. name = 'RCVD_COUNT_TWO',
  66. score = 0.0,
  67. parent = rcvd_cb_id,
  68. type = 'virtual',
  69. description = 'Two received',
  70. group = 'headers',
  71. }
  72. rspamd_config:register_symbol{
  73. name = 'RCVD_COUNT_THREE',
  74. score = 0.0,
  75. parent = rcvd_cb_id,
  76. type = 'virtual',
  77. description = '3-5 received',
  78. group = 'headers',
  79. }
  80. rspamd_config:register_symbol{
  81. name = 'RCVD_COUNT_FIVE',
  82. score = 0.0,
  83. parent = rcvd_cb_id,
  84. type = 'virtual',
  85. description = '5-7 received',
  86. group = 'headers',
  87. }
  88. rspamd_config:register_symbol{
  89. name = 'RCVD_COUNT_SEVEN',
  90. score = 0.0,
  91. parent = rcvd_cb_id,
  92. type = 'virtual',
  93. description = '7-11 received',
  94. group = 'headers',
  95. }
  96. rspamd_config:register_symbol{
  97. name = 'RCVD_COUNT_TWELVE',
  98. score = 0.0,
  99. parent = rcvd_cb_id,
  100. type = 'virtual',
  101. description = '12+ received',
  102. group = 'headers',
  103. }
  104. local prio_cb_id = rspamd_config:register_symbol {
  105. name = 'HAS_X_PRIO',
  106. type = 'callback',
  107. callback = function (task)
  108. local cnts = {
  109. [1] = 'ONE',
  110. [2] = 'TWO',
  111. [3] = 'THREE',
  112. [5] = 'FIVE',
  113. }
  114. local def = 'ZERO'
  115. local xprio = task:get_header('X-Priority');
  116. if not xprio then return false end
  117. local _,_,x = xprio:find('^%s?(%d+)');
  118. if (x) then
  119. x = tonumber(x)
  120. for k,v in pairs(cnts) do
  121. if x >= tonumber(k) then
  122. def = v
  123. end
  124. end
  125. task:insert_result('HAS_X_PRIO_' .. def, 1.0, tostring(x))
  126. end
  127. end
  128. }
  129. rspamd_config:register_symbol{
  130. name = 'HAS_X_PRIO_ZERO',
  131. score = 0.0,
  132. parent = prio_cb_id,
  133. type = 'virtual',
  134. description = 'Priority 0',
  135. group = 'headers',
  136. }
  137. rspamd_config:register_symbol{
  138. name = 'HAS_X_PRIO_ONE',
  139. score = 0.0,
  140. parent = prio_cb_id,
  141. type = 'virtual',
  142. description = 'Priority 1',
  143. group = 'headers',
  144. }
  145. rspamd_config:register_symbol{
  146. name = 'HAS_X_PRIO_TWO',
  147. score = 0.0,
  148. parent = prio_cb_id,
  149. type = 'virtual',
  150. description = 'Priority 2',
  151. group = 'headers',
  152. }
  153. rspamd_config:register_symbol{
  154. name = 'HAS_X_PRIO_THREE',
  155. score = 0.0,
  156. parent = prio_cb_id,
  157. type = 'virtual',
  158. description = 'Priority 3-4',
  159. group = 'headers',
  160. }
  161. rspamd_config:register_symbol{
  162. name = 'HAS_X_PRIO_FIVE',
  163. score = 0.0,
  164. parent = prio_cb_id,
  165. type = 'virtual',
  166. description = 'Priority 5+',
  167. group = 'headers',
  168. }
  169. local function get_raw_header(task, name)
  170. return ((task:get_header_full(name) or {})[1] or {})['value']
  171. end
  172. local check_replyto_id = rspamd_config:register_callback_symbol('CHECK_REPLYTO', 1.0,
  173. function (task)
  174. local replyto = get_raw_header(task, 'Reply-To')
  175. if not replyto then return false end
  176. local rt = util.parse_mail_address(replyto, task:get_mempool())
  177. if not (rt and rt[1] and (string.len(rt[1].addr) > 0)) then
  178. task:insert_result('REPLYTO_UNPARSEABLE', 1.0)
  179. return false
  180. else
  181. local rta = rt[1].addr
  182. task:insert_result('HAS_REPLYTO', 1.0, rta)
  183. -- Check if Reply-To address starts with title seen in display name
  184. local sym = task:get_symbol('FROM_NAME_HAS_TITLE')
  185. local title = (((sym or E)[1] or E).options or E)[1]
  186. if title then
  187. rta = rta:lower()
  188. if rta:find('^' .. title) then
  189. task:insert_result('REPLYTO_EMAIL_HAS_TITLE', 1.0)
  190. end
  191. end
  192. end
  193. -- See if Reply-To matches From in some way
  194. local from = task:get_from(2)
  195. local from_h = get_raw_header(task, 'From')
  196. if not (from and from[1]) then return false end
  197. if (from_h and from_h == replyto) then
  198. -- From and Reply-To are identical
  199. task:insert_result('REPLYTO_EQ_FROM', 1.0)
  200. else
  201. if (from and from[1]) then
  202. -- See if From and Reply-To addresses match
  203. if (util.strequal_caseless(from[1].addr, rt[1].addr)) then
  204. task:insert_result('REPLYTO_ADDR_EQ_FROM', 1.0)
  205. elseif from[1].domain and rt[1].domain then
  206. if (util.strequal_caseless(from[1].domain, rt[1].domain)) then
  207. task:insert_result('REPLYTO_DOM_EQ_FROM_DOM', 1.0)
  208. else
  209. -- See if Reply-To matches the To address
  210. local to = task:get_recipients(2)
  211. if (to and to[1] and to[1].addr:lower() == rt[1].addr:lower()) then
  212. -- Ignore this for mailing-lists and automatic submissions
  213. if (not (task:get_header('List-Unsubscribe') or
  214. task:get_header('X-To-Get-Off-This-List') or
  215. task:get_header('X-List') or
  216. task:get_header('Auto-Submitted')))
  217. then
  218. task:insert_result('REPLYTO_EQ_TO_ADDR', 1.0)
  219. end
  220. else
  221. task:insert_result('REPLYTO_DOM_NEQ_FROM_DOM', 1.0)
  222. end
  223. end
  224. end
  225. -- See if the Display Names match
  226. if (from[1].name and rt[1].name and
  227. util.strequal_caseless(from[1].name, rt[1].name)) then
  228. task:insert_result('REPLYTO_DN_EQ_FROM_DN', 1.0)
  229. end
  230. end
  231. end
  232. end
  233. )
  234. rspamd_config:register_symbol{
  235. name = 'REPLYTO_UNPARSEABLE',
  236. score = 1.0,
  237. parent = check_replyto_id,
  238. type = 'virtual',
  239. description = 'Reply-To header could not be parsed',
  240. group = 'headers',
  241. }
  242. rspamd_config:register_symbol{
  243. name = 'HAS_REPLYTO',
  244. score = 0.0,
  245. parent = check_replyto_id,
  246. type = 'virtual',
  247. description = 'Has Reply-To header',
  248. group = 'headers',
  249. }
  250. rspamd_config:register_symbol{
  251. name = 'REPLYTO_EQ_FROM',
  252. score = 0.0,
  253. parent = check_replyto_id,
  254. type = 'virtual',
  255. description = 'Reply-To header is identical to From header',
  256. group = 'headers',
  257. }
  258. rspamd_config:register_symbol{
  259. name = 'REPLYTO_ADDR_EQ_FROM',
  260. score = 0.0,
  261. parent = check_replyto_id,
  262. type = 'virtual',
  263. description = 'Reply-To header is identical to SMTP From',
  264. group = 'headers',
  265. }
  266. rspamd_config:register_symbol{
  267. name = 'REPLYTO_DOM_EQ_FROM_DOM',
  268. score = 0.0,
  269. parent = check_replyto_id,
  270. type = 'virtual',
  271. description = 'Reply-To domain matches the From domain',
  272. group = 'headers',
  273. }
  274. rspamd_config:register_symbol{
  275. name = 'REPLYTO_DOM_NEQ_FROM_DOM',
  276. score = 0.0,
  277. parent = check_replyto_id,
  278. type = 'virtual',
  279. description = 'Reply-To domain does not match the From domain',
  280. group = 'headers',
  281. }
  282. rspamd_config:register_symbol{
  283. name = 'REPLYTO_DN_EQ_FROM_DN',
  284. score = 0.0,
  285. parent = check_replyto_id,
  286. type = 'virtual',
  287. description = 'Reply-To display name matches From',
  288. group = 'headers',
  289. }
  290. rspamd_config:register_symbol{
  291. name = 'REPLYTO_EMAIL_HAS_TITLE',
  292. score = 2.0,
  293. parent = check_replyto_id,
  294. type = 'virtual',
  295. description = 'Reply-To header has title',
  296. group = 'headers',
  297. }
  298. rspamd_config:register_symbol{
  299. name = 'REPLYTO_EQ_TO_ADDR',
  300. score = 5.0,
  301. parent = check_replyto_id,
  302. type = 'virtual',
  303. description = 'Reply-To is the same as the To address',
  304. group = 'headers',
  305. }
  306. rspamd_config:register_dependency('CHECK_REPLYTO', 'CHECK_FROM')
  307. local check_mime_id = rspamd_config:register_symbol{
  308. name = 'CHECK_MIME',
  309. type = 'callback',
  310. callback = function(task)
  311. local parts = task:get_parts()
  312. if not parts then return false end
  313. -- Make sure there is a MIME-Version header
  314. local mv = task:get_header('MIME-Version')
  315. local missing_mime = false
  316. if (not mv) then
  317. missing_mime = true
  318. end
  319. local found_ma = false
  320. local found_plain = false
  321. local found_html = false
  322. local cte_7bit = false
  323. for _,p in ipairs(parts) do
  324. local mtype,subtype = p:get_type()
  325. local ctype = mtype:lower() .. '/' .. subtype:lower()
  326. if (ctype == 'multipart/alternative') then
  327. found_ma = true
  328. end
  329. if (ctype == 'text/plain') then
  330. if p:get_cte() == '7bit' then
  331. cte_7bit = true
  332. end
  333. found_plain = true
  334. end
  335. if (ctype == 'text/html') then
  336. if p:get_cte() == '7bit' then
  337. cte_7bit = true
  338. end
  339. found_html = true
  340. end
  341. end
  342. if missing_mime then
  343. if not (not found_ma and ((found_plain or found_html) and cte_7bit)) then
  344. task:insert_result('MISSING_MIME_VERSION', 1.0)
  345. end
  346. end
  347. if (found_ma) then
  348. if (not found_plain) then
  349. task:insert_result('MIME_MA_MISSING_TEXT', 1.0)
  350. end
  351. if (not found_html) then
  352. task:insert_result('MIME_MA_MISSING_HTML', 1.0)
  353. end
  354. end
  355. end
  356. }
  357. rspamd_config:register_symbol{
  358. name = 'MISSING_MIME_VERSION',
  359. score = 2.0,
  360. parent = check_mime_id,
  361. type = 'virtual',
  362. description = 'MIME-Version header is missing',
  363. group = 'headers',
  364. }
  365. rspamd_config:register_symbol{
  366. name = 'MIME_MA_MISSING_TEXT',
  367. score = 2.0,
  368. parent = check_mime_id,
  369. type = 'virtual',
  370. description = 'MIME multipart/alternative missing text/plain part',
  371. group = 'headers',
  372. }
  373. rspamd_config:register_symbol{
  374. name = 'MIME_MA_MISSING_HTML',
  375. score = 1.0,
  376. parent = check_mime_id,
  377. type = 'virtual',
  378. description = 'MIME multipart/alternative missing text/html part',
  379. group = 'headers',
  380. }
  381. -- Used to be called IS_LIST
  382. rspamd_config.PREVIOUSLY_DELIVERED = {
  383. callback = function(task)
  384. if not task:has_recipients(2) then return false end
  385. local to = task:get_recipients(2)
  386. local rcvds = task:get_header_full('Received')
  387. if not rcvds then return false end
  388. for _, rcvd in ipairs(rcvds) do
  389. local _,_,addr = rcvd['decoded']:lower():find("%sfor%s<(.-)>")
  390. if addr then
  391. for _, toa in ipairs(to) do
  392. if toa and toa.addr:lower() == addr then
  393. return true, addr
  394. end
  395. end
  396. return false
  397. end
  398. end
  399. end,
  400. description = 'Message either to a list or was forwarded',
  401. score = 0.0
  402. }
  403. rspamd_config.BROKEN_HEADERS = {
  404. callback = function(task)
  405. return task:has_flag('broken_headers')
  406. end,
  407. score = 10.0,
  408. group = 'headers',
  409. description = 'Headers structure is likely broken'
  410. }
  411. rspamd_config.BROKEN_CONTENT_TYPE = {
  412. callback = function(task)
  413. return fun.any(function(p) return p:is_broken() end,
  414. task:get_parts())
  415. end,
  416. score = 1.5,
  417. group = 'headers',
  418. description = 'Message has part with broken content type'
  419. }
  420. rspamd_config.HEADER_RCONFIRM_MISMATCH = {
  421. callback = function (task)
  422. local header_from = nil
  423. local cread = task:get_header('X-Confirm-Reading-To')
  424. if task:has_from('mime') then
  425. header_from = task:get_from('mime')[1]
  426. end
  427. local header_cread = nil
  428. if cread then
  429. local headers_cread = util.parse_mail_address(cread, task:get_mempool())
  430. if headers_cread then header_cread = headers_cread[1] end
  431. end
  432. if header_from and header_cread then
  433. if not string.find(header_from['addr'], header_cread['addr']) then
  434. return true
  435. end
  436. end
  437. return false
  438. end,
  439. score = 2.0,
  440. group = 'headers',
  441. description = 'Read confirmation address is different to from address'
  442. }
  443. rspamd_config.HEADER_FORGED_MDN = {
  444. callback = function (task)
  445. local mdn = task:get_header('Disposition-Notification-To')
  446. if not mdn then return false end
  447. local header_rp = nil
  448. if task:has_from('smtp') then
  449. header_rp = task:get_from('smtp')[1]
  450. end
  451. -- Parse mail addr
  452. local headers_mdn = util.parse_mail_address(mdn, task:get_mempool())
  453. if headers_mdn and not header_rp then return true end
  454. if header_rp and not headers_mdn then return false end
  455. if not headers_mdn and not header_rp then return false end
  456. local found_match = false
  457. for _, h in ipairs(headers_mdn) do
  458. if util.strequal_caseless(h['addr'], header_rp['addr']) then
  459. found_match = true
  460. break
  461. end
  462. end
  463. return (not found_match)
  464. end,
  465. score = 2.0,
  466. group = 'headers',
  467. description = 'Read confirmation address is different to return path'
  468. }
  469. local headers_unique = {
  470. ['Content-Type'] = 1.0,
  471. ['Content-Transfer-Encoding'] = 1.0,
  472. -- https://tools.ietf.org/html/rfc5322#section-3.6
  473. ['Date'] = 0.1,
  474. ['From'] = 1.0,
  475. ['Sender'] = 1.0,
  476. ['Reply-To'] = 1.0,
  477. ['To'] = 0.2,
  478. ['Cc'] = 0.1,
  479. ['Bcc'] = 0.1,
  480. ['Message-ID'] = 0.7,
  481. ['In-Reply-To'] = 0.7,
  482. ['References'] = 0.3,
  483. ['Subject'] = 0.7
  484. }
  485. rspamd_config.MULTIPLE_UNIQUE_HEADERS = {
  486. callback = function(task)
  487. local res = 0
  488. local max_mult = 0.0
  489. local res_tbl = {}
  490. for hdr,mult in pairs(headers_unique) do
  491. local hc = task:get_header_count(hdr)
  492. if hc > 1 then
  493. res = res + 1
  494. table.insert(res_tbl, hdr)
  495. if max_mult < mult then
  496. max_mult = mult
  497. end
  498. end
  499. end
  500. if res > 0 then
  501. return true,max_mult,table.concat(res_tbl, ',')
  502. end
  503. return false
  504. end,
  505. score = 7.0,
  506. group = 'headers',
  507. one_shot = true,
  508. description = 'Repeated unique headers'
  509. }
  510. rspamd_config.MISSING_FROM = {
  511. callback = function(task)
  512. local from = task:get_header('From')
  513. if from == nil or from == '' then
  514. return true
  515. end
  516. return false
  517. end,
  518. score = 2.0,
  519. group = 'headers',
  520. description = 'Missing From: header'
  521. }
  522. rspamd_config.MULTIPLE_FROM = {
  523. callback = function(task)
  524. local from = task:get_from('mime')
  525. if from and from[1] then
  526. if #from > 1 then
  527. return true,1.0,table.concat(
  528. fun.totable(
  529. fun.map(function(a) return a.addr end,
  530. fun.filter(function(a) return a.addr and a.addr ~= '' end,
  531. from))),
  532. ',')
  533. end
  534. end
  535. return false
  536. end,
  537. score = 9.0,
  538. group = 'headers',
  539. description = 'Multiple addresses in From'
  540. }
  541. rspamd_config.MV_CASE = {
  542. callback = function (task)
  543. local mv = task:get_header('Mime-Version', true)
  544. if (mv) then return true end
  545. end,
  546. description = 'Mime-Version .vs. MIME-Version',
  547. score = 0.5,
  548. group = 'headers',
  549. }
  550. rspamd_config.FAKE_REPLY = {
  551. callback = function (task)
  552. local subject = task:get_header('Subject')
  553. if (subject and subject:lower():find('^re:')) then
  554. local ref = task:get_header('References')
  555. local rt = task:get_header('In-Reply-To')
  556. if (not (ref or rt)) then return true end
  557. end
  558. return false
  559. end,
  560. description = 'Fake reply',
  561. score = 1.0,
  562. group = 'headers'
  563. }
  564. local check_from_id = rspamd_config:register_symbol{
  565. name = 'CHECK_FROM',
  566. type = 'callback',
  567. callback = function(task)
  568. local envfrom = task:get_from(1)
  569. local from = task:get_from(2)
  570. if (from and from[1] and (from[1].name == nil or from[1].name == '' )) then
  571. task:insert_result('FROM_NO_DN', 1.0)
  572. elseif (from and from[1] and from[1].name and
  573. util.strequal_caseless(from[1].name, from[1].addr)) then
  574. task:insert_result('FROM_DN_EQ_ADDR', 1.0)
  575. elseif (from and from[1] and from[1].name and from[1].name ~= '') then
  576. task:insert_result('FROM_HAS_DN', 1.0)
  577. -- Look for Mr/Mrs/Dr titles
  578. local n = from[1].name:lower()
  579. local match, match_end
  580. match, match_end = n:find('^mrs?[%.%s]')
  581. if match then
  582. task:insert_result('FROM_NAME_HAS_TITLE', 1.0, n:sub(match, match_end-1))
  583. end
  584. match, match_end = n:find('^dr[%.%s]')
  585. if match then
  586. task:insert_result('FROM_NAME_HAS_TITLE', 1.0, n:sub(match, match_end-1))
  587. end
  588. -- Check for excess spaces
  589. if n:find('%s%s') then
  590. task:insert_result('FROM_NAME_EXCESS_SPACE', 1.0)
  591. end
  592. end
  593. if (envfrom and from and envfrom[1] and from[1] and
  594. util.strequal_caseless(envfrom[1].addr, from[1].addr))
  595. then
  596. task:insert_result('FROM_EQ_ENVFROM', 1.0)
  597. elseif (envfrom and envfrom[1] and envfrom[1].addr) then
  598. task:insert_result('FROM_NEQ_ENVFROM', 1.0, ((from or E)[1] or E).addr or '', envfrom[1].addr)
  599. end
  600. local to = task:get_recipients(2)
  601. if not (to and to[1] and #to == 1 and from and from[1]) then return false end
  602. -- Check if FROM == TO
  603. if (util.strequal_caseless(to[1].addr, from[1].addr)) then
  604. task:insert_result('TO_EQ_FROM', 1.0)
  605. elseif (to[1].domain and from[1].domain and
  606. util.strequal_caseless(to[1].domain, from[1].domain))
  607. then
  608. task:insert_result('TO_DOM_EQ_FROM_DOM', 1.0)
  609. end
  610. end
  611. }
  612. rspamd_config:register_symbol{
  613. name = 'FROM_NO_DN',
  614. score = 0,
  615. group = 'headers',
  616. parent = check_from_id,
  617. type = 'virtual',
  618. description = 'From header does not have a display name',
  619. }
  620. rspamd_config:register_symbol{
  621. name = 'FROM_DN_EQ_ADDR',
  622. score = 1.0,
  623. group = 'headers',
  624. parent = check_from_id,
  625. type = 'virtual',
  626. description = 'From header display name is the same as the address',
  627. }
  628. rspamd_config:register_symbol{
  629. name = 'FROM_HAS_DN',
  630. score = 0.0,
  631. group = 'headers',
  632. parent = check_from_id,
  633. type = 'virtual',
  634. description = 'From header has a display name',
  635. }
  636. rspamd_config:register_symbol{
  637. name = 'FROM_NAME_EXCESS_SPACE',
  638. score = 1.0,
  639. group = 'headers',
  640. parent = check_from_id,
  641. type = 'virtual',
  642. description = 'From header display name contains excess whitespace',
  643. }
  644. rspamd_config:register_symbol{
  645. name = 'FROM_NAME_HAS_TITLE',
  646. score = 1.0,
  647. group = 'headers',
  648. parent = check_from_id,
  649. type = 'virtual',
  650. description = 'From header display name has a title (Mr/Mrs/Dr)',
  651. }
  652. rspamd_config:register_symbol{
  653. name = 'FROM_EQ_ENVFROM',
  654. score = 0.0,
  655. group = 'headers',
  656. parent = check_from_id,
  657. type = 'virtual',
  658. description = 'From address is the same as the envelope',
  659. }
  660. rspamd_config:register_symbol{
  661. name = 'FROM_NEQ_ENVFROM',
  662. score = 0.0,
  663. group = 'headers',
  664. parent = check_from_id,
  665. type = 'virtual',
  666. description = 'From address is different to the envelope',
  667. }
  668. rspamd_config:register_symbol{
  669. name = 'TO_EQ_FROM',
  670. score = 0.0,
  671. group = 'headers',
  672. parent = check_from_id,
  673. type = 'virtual',
  674. description = 'To address matches the From address',
  675. }
  676. rspamd_config:register_symbol{
  677. name = 'TO_DOM_EQ_FROM_DOM',
  678. score = 0.0,
  679. group = 'headers',
  680. parent = check_from_id,
  681. type = 'virtual',
  682. description = 'To domain is the same as the From domain',
  683. }
  684. local check_to_cc_id = rspamd_config:register_symbol{
  685. name = 'CHECK_TO_CC',
  686. type = 'callback',
  687. callback = function(task)
  688. local rcpts = task:get_recipients(1)
  689. local to = task:get_recipients(2)
  690. local to_match_envrcpt = 0
  691. local cnts = {
  692. [1] = 'ONE',
  693. [2] = 'TWO',
  694. [3] = 'THREE',
  695. [5] = 'FIVE',
  696. [7] = 'SEVEN',
  697. [12] = 'TWELVE',
  698. [50] = 'GT_50'
  699. }
  700. local def = 'ZERO'
  701. if (not to) then return false end
  702. -- Add symbol for recipient count
  703. local nrcpt = #to
  704. for k,v in pairs(cnts) do
  705. if nrcpt >= tonumber(k) then
  706. def = v
  707. end
  708. end
  709. task:insert_result('RCPT_COUNT_' .. def, 1.0, tostring(nrcpt))
  710. -- Check for display names
  711. local to_dn_count = 0
  712. local to_dn_eq_addr_count = 0
  713. for _, toa in ipairs(to) do
  714. -- To: Recipients <noreply@dropbox.com>
  715. if (toa['name'] and (toa['name']:lower() == 'recipient'
  716. or toa['name']:lower() == 'recipients')) then
  717. task:insert_result('TO_DN_RECIPIENTS', 1.0)
  718. end
  719. if (toa['name'] and util.strequal_caseless(toa['name'], toa['addr'])) then
  720. to_dn_eq_addr_count = to_dn_eq_addr_count + 1
  721. elseif (toa['name'] and toa['name'] ~= '') then
  722. to_dn_count = to_dn_count + 1
  723. end
  724. -- See if header recipients match envrcpts
  725. if (rcpts) then
  726. for _, rcpt in ipairs(rcpts) do
  727. if (toa and toa['addr'] and rcpt and rcpt['addr'] and
  728. util.strequal_caseless(rcpt['addr'], toa['addr']))
  729. then
  730. to_match_envrcpt = to_match_envrcpt + 1
  731. end
  732. end
  733. end
  734. end
  735. if (to_dn_count == 0 and to_dn_eq_addr_count == 0) then
  736. task:insert_result('TO_DN_NONE', 1.0)
  737. elseif (to_dn_count == #to) then
  738. task:insert_result('TO_DN_ALL', 1.0)
  739. elseif (to_dn_count > 0) then
  740. task:insert_result('TO_DN_SOME', 1.0)
  741. end
  742. if (to_dn_eq_addr_count == #to) then
  743. task:insert_result('TO_DN_EQ_ADDR_ALL', 1.0)
  744. elseif (to_dn_eq_addr_count > 0) then
  745. task:insert_result('TO_DN_EQ_ADDR_SOME', 1.0)
  746. end
  747. -- See if header recipients match envelope recipients
  748. if (to_match_envrcpt == #to) then
  749. task:insert_result('TO_MATCH_ENVRCPT_ALL', 1.0)
  750. elseif (to_match_envrcpt > 0) then
  751. task:insert_result('TO_MATCH_ENVRCPT_SOME', 1.0)
  752. end
  753. end
  754. }
  755. rspamd_config:register_symbol{
  756. name = 'RCPT_COUNT_ZERO',
  757. score = 0.0,
  758. parent = check_to_cc_id,
  759. type = 'virtual',
  760. description = 'No recipients',
  761. group = 'headers',
  762. }
  763. rspamd_config:register_symbol{
  764. name = 'RCPT_COUNT_ONE',
  765. score = 0.0,
  766. parent = check_to_cc_id,
  767. type = 'virtual',
  768. description = 'One recipient',
  769. group = 'headers',
  770. }
  771. rspamd_config:register_symbol{
  772. name = 'RCPT_COUNT_TWO',
  773. score = 0.0,
  774. parent = check_to_cc_id,
  775. type = 'virtual',
  776. description = 'Two recipients',
  777. group = 'headers',
  778. }
  779. rspamd_config:register_symbol{
  780. name = 'RCPT_COUNT_THREE',
  781. score = 0.0,
  782. parent = check_to_cc_id,
  783. type = 'virtual',
  784. description = '3-5 recipients',
  785. group = 'headers',
  786. }
  787. rspamd_config:register_symbol{
  788. name = 'RCPT_COUNT_FIVE',
  789. score = 0.0,
  790. parent = check_to_cc_id,
  791. type = 'virtual',
  792. description = '5-7 recipients',
  793. group = 'headers',
  794. }
  795. rspamd_config:register_symbol{
  796. name = 'RCPT_COUNT_SEVEN',
  797. score = 0.0,
  798. parent = check_to_cc_id,
  799. type = 'virtual',
  800. description = '7-11 recipients',
  801. group = 'headers',
  802. }
  803. rspamd_config:register_symbol{
  804. name = 'RCPT_COUNT_TWELVE',
  805. score = 0.0,
  806. parent = check_to_cc_id,
  807. type = 'virtual',
  808. description = '12-50 recipients',
  809. group = 'headers',
  810. }
  811. rspamd_config:register_symbol{
  812. name = 'RCPT_COUNT_GT_50',
  813. score = 0.0,
  814. parent = check_to_cc_id,
  815. type = 'virtual',
  816. description = '50+ recipients',
  817. group = 'headers',
  818. }
  819. rspamd_config:register_symbol{
  820. name = 'TO_DN_RECIPIENTS',
  821. score = 2.0,
  822. group = 'headers',
  823. parent = check_to_cc_id,
  824. type = 'virtual',
  825. description = 'To header display name is "Recipients"',
  826. }
  827. rspamd_config:register_symbol{
  828. name = 'TO_DN_NONE',
  829. score = 0.0,
  830. group = 'headers',
  831. parent = check_to_cc_id,
  832. type = 'virtual',
  833. description = 'None of the recipients have display names',
  834. }
  835. rspamd_config:register_symbol{
  836. name = 'TO_DN_ALL',
  837. score = 0.0,
  838. group = 'headers',
  839. parent = check_to_cc_id,
  840. type = 'virtual',
  841. description = 'All the recipients have display names',
  842. }
  843. rspamd_config:register_symbol{
  844. name = 'TO_DN_SOME',
  845. score = 0.0,
  846. group = 'headers',
  847. parent = check_to_cc_id,
  848. type = 'virtual',
  849. description = 'Some of the recipients have display names',
  850. }
  851. rspamd_config:register_symbol{
  852. name = 'TO_DN_EQ_ADDR_ALL',
  853. score = 0.0,
  854. group = 'headers',
  855. parent = check_to_cc_id,
  856. type = 'virtual',
  857. description = 'All of the recipients have display names that are the same as their address',
  858. }
  859. rspamd_config:register_symbol{
  860. name = 'TO_DN_EQ_ADDR_SOME',
  861. score = 0.0,
  862. group = 'headers',
  863. parent = check_to_cc_id,
  864. type = 'virtual',
  865. description = 'Some of the recipients have display names that are the same as their address',
  866. }
  867. rspamd_config:register_symbol{
  868. name = 'TO_MATCH_ENVRCPT_ALL',
  869. score = 0.0,
  870. group = 'headers',
  871. parent = check_to_cc_id,
  872. type = 'virtual',
  873. description = 'All of the recipients match the envelope',
  874. }
  875. rspamd_config:register_symbol{
  876. name = 'TO_MATCH_ENVRCPT_SOME',
  877. score = 0.0,
  878. group = 'headers',
  879. parent = check_to_cc_id,
  880. type = 'virtual',
  881. description = 'Some of the recipients match the envelope',
  882. }
  883. rspamd_config.CTYPE_MISSING_DISPOSITION = {
  884. callback = function(task)
  885. local parts = task:get_parts()
  886. if (not parts) or (parts and #parts < 1) then return false end
  887. for _,p in ipairs(parts) do
  888. local ct = p:get_header('Content-Type')
  889. if (ct and ct:lower():match('^application/octet%-stream') ~= nil) then
  890. local cd = p:get_header('Content-Disposition')
  891. if (not cd) or (cd and cd:lower():find('^attachment') == nil) then
  892. local ci = p:get_header('Content-ID')
  893. if ci or (#parts > 1 and (cd and cd:find('filename=.+%.asc') ~= nil))
  894. then
  895. return false
  896. end
  897. return true
  898. end
  899. end
  900. end
  901. return false
  902. end,
  903. description = 'Binary content-type not specified as an attachment',
  904. score = 4.0,
  905. group = 'headers'
  906. }
  907. rspamd_config.CTYPE_MIXED_BOGUS = {
  908. callback = function(task)
  909. local ct = task:get_header('Content-Type')
  910. if (not ct) then return false end
  911. local parts = task:get_parts()
  912. if (not parts) then return false end
  913. if (not ct:lower():match('^multipart/mixed')) then return false end
  914. local found = false
  915. -- Check each part and look for a part that isn't multipart/* or text/plain or text/html
  916. for _,p in ipairs(parts) do
  917. local pct = p:get_header('Content-Type')
  918. if (pct) then
  919. pct = pct:lower()
  920. if not ((pct:match('^multipart/') or
  921. pct:match('^text/plain') or
  922. pct:match('^text/html'))) then
  923. found = true
  924. end
  925. end
  926. end
  927. if (not found) then return true end
  928. return false
  929. end,
  930. description = 'multipart/mixed without non-textual part',
  931. score = 1.0,
  932. group = 'headers'
  933. }
  934. local function check_for_base64_text(part)
  935. local ct = part:get_header('Content-Type')
  936. if (not ct) then return false end
  937. ct = ct:lower()
  938. if (ct:match('^text')) then
  939. -- Check encoding
  940. local cte = part:get_header('Content-Transfer-Encoding')
  941. if (cte and cte:lower():match('^base64')) then
  942. return true
  943. end
  944. end
  945. return false
  946. end
  947. rspamd_config.MIME_BASE64_TEXT = {
  948. callback = function(task)
  949. -- Check outer part
  950. if (check_for_base64_text(task)) then
  951. return true
  952. else
  953. local parts = task:get_parts()
  954. if (not parts) then return false end
  955. -- Check each part and look for base64 encoded text parts
  956. for _, part in ipairs(parts) do
  957. if (check_for_base64_text(part)) then
  958. return true
  959. end
  960. end
  961. end
  962. return false
  963. end,
  964. description = 'Has text part encoded in base64',
  965. score = 0.1,
  966. group = 'headers'
  967. }
  968. local function is_8bit_addr(addr)
  969. if addr.flags and addr.flags['8bit'] then
  970. return true
  971. end
  972. return false;
  973. end
  974. rspamd_config.INVALID_FROM_8BIT = {
  975. callback = function(task)
  976. local from = (task:get_from('mime') or {})[1] or {}
  977. if is_8bit_addr(from) then
  978. return true
  979. end
  980. return false
  981. end,
  982. description = 'Invalid 8bit character in From header',
  983. score = 6.0,
  984. group = 'headers'
  985. }
  986. rspamd_config.INVALID_RCPT_8BIT = {
  987. callback = function(task)
  988. local rcpts = task:get_recipients('mime') or {}
  989. return fun.any(function(rcpt)
  990. if is_8bit_addr(rcpt) then
  991. return true
  992. end
  993. return false
  994. end, rcpts)
  995. end,
  996. description = 'Invalid 8bit character in recipients headers',
  997. score = 6.0,
  998. group = 'headers'
  999. }
  1000. rspamd_config.XM_CASE = {
  1001. callback = function (task)
  1002. local xm = task:get_header('X-mailer', true)
  1003. if (xm) then return true end
  1004. end,
  1005. description = 'X-mailer .vs. X-Mailer',
  1006. score = 0.5,
  1007. group = 'headers',
  1008. }