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.

replies.lua 9.4KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. --[[
  2. Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
  3. Copyright (c) 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. if confighelp then
  15. return
  16. end
  17. local rspamd_logger = require 'rspamd_logger'
  18. local hash = require 'rspamd_cryptobox_hash'
  19. local lua_util = require 'lua_util'
  20. local lua_redis = require 'lua_redis'
  21. local fun = require "fun"
  22. -- A plugin that implements replies check using redis
  23. -- Default port for redis upstreams
  24. local redis_params
  25. local settings = {
  26. action = nil,
  27. expire = 86400, -- 1 day by default
  28. key_prefix = 'rr',
  29. key_size = 20,
  30. message = 'Message is reply to one we originated',
  31. symbol = 'REPLY',
  32. score = -4, -- Default score
  33. use_auth = true,
  34. use_local = true,
  35. cookie = nil,
  36. cookie_key = nil,
  37. cookie_is_pattern = false,
  38. cookie_valid_time = '2w', -- 2 weeks by default
  39. min_message_id = 2, -- minimum length of the message-id header
  40. }
  41. local N = "replies"
  42. local function make_key(goop, sz, prefix)
  43. local h = hash.create()
  44. h:update(goop)
  45. local key
  46. if sz then
  47. key = h:base32():sub(1, sz)
  48. else
  49. key = h:base32()
  50. end
  51. if prefix then
  52. key = prefix .. key
  53. end
  54. return key
  55. end
  56. local function replies_check(task)
  57. local in_reply_to
  58. local function check_recipient(stored_rcpt)
  59. local rcpts = task:get_recipients('mime')
  60. if rcpts then
  61. local filter_predicate = function(input_rcpt)
  62. local real_rcpt_h = make_key(input_rcpt:lower(), 8)
  63. return real_rcpt_h == stored_rcpt
  64. end
  65. if fun.any(filter_predicate, fun.map(function(rcpt) return rcpt.addr or '' end, rcpts)) then
  66. lua_util.debugm(N, task, 'reply to %s validated', in_reply_to)
  67. return true
  68. end
  69. rspamd_logger.infox(task, 'ignoring reply to %s as no recipients are matching hash %s',
  70. in_reply_to, stored_rcpt)
  71. else
  72. rspamd_logger.infox(task, 'ignoring reply to %s as recipient cannot be detected for hash %s',
  73. in_reply_to, stored_rcpt)
  74. end
  75. return false
  76. end
  77. local function redis_get_cb(err, data, addr)
  78. if err ~= nil then
  79. rspamd_logger.errx(task, 'redis_get_cb error when reading data from %s: %s', addr:get_addr(), err)
  80. return
  81. end
  82. if data and type(data) == 'string' and check_recipient(data) then
  83. -- Hash was found
  84. task:insert_result(settings['symbol'], 1.0)
  85. if settings['action'] ~= nil then
  86. local ip_addr = task:get_ip()
  87. if (settings.use_auth and
  88. task:get_user()) or
  89. (settings.use_local and ip_addr and ip_addr:is_local()) then
  90. rspamd_logger.infox(task, "not forcing action for local network or authorized user");
  91. else
  92. task:set_pre_result(settings['action'], settings['message'], N)
  93. end
  94. end
  95. end
  96. end
  97. -- If in-reply-to header not present return
  98. in_reply_to = task:get_header_raw('in-reply-to')
  99. if not in_reply_to then
  100. return
  101. end
  102. -- Create hash of in-reply-to and query redis
  103. local key = make_key(in_reply_to, settings.key_size, settings.key_prefix)
  104. local ret = lua_redis.redis_make_request(task,
  105. redis_params, -- connect params
  106. key, -- hash key
  107. false, -- is write
  108. redis_get_cb, --callback
  109. 'GET', -- command
  110. {key} -- arguments
  111. )
  112. if not ret then
  113. rspamd_logger.errx(task, "redis request wasn't scheduled")
  114. end
  115. end
  116. local function replies_set(task)
  117. local function redis_set_cb(err, _, addr)
  118. if err ~=nil then
  119. rspamd_logger.errx(task, 'redis_set_cb error when writing data to %s: %s', addr:get_addr(), err)
  120. end
  121. end
  122. -- If sender is unauthenticated return
  123. local ip = task:get_ip()
  124. if settings.use_auth and task:get_user() then
  125. lua_util.debugm(N, task, 'sender is authenticated')
  126. elseif settings.use_local and (ip and ip:is_local()) then
  127. lua_util.debugm(N, task, 'sender is from local network')
  128. else
  129. return
  130. end
  131. -- If no message-id present return
  132. local msg_id = task:get_header_raw('message-id')
  133. if msg_id == nil or msg_id:len() <= (settings.min_message_id or 2) then
  134. return
  135. end
  136. -- Create hash of message-id and store to redis
  137. local key = make_key(msg_id, settings.key_size, settings.key_prefix)
  138. local sender = task:get_reply_sender()
  139. if sender then
  140. local sender_hash = make_key(sender:lower(), 8)
  141. lua_util.debugm(N, task, 'storing id: %s (%s), reply-to: %s (%s) for replies check',
  142. msg_id, key, sender, sender_hash)
  143. local ret = lua_redis.redis_make_request(task,
  144. redis_params, -- connect params
  145. key, -- hash key
  146. true, -- is write
  147. redis_set_cb, --callback
  148. 'PSETEX', -- command
  149. {key, tostring(math.floor(settings['expire'] * 1000)), sender_hash} -- arguments
  150. )
  151. if not ret then
  152. rspamd_logger.errx(task, "redis request wasn't scheduled")
  153. end
  154. else
  155. rspamd_logger.infox(task, "cannot find reply sender address")
  156. end
  157. end
  158. local function replies_check_cookie(task)
  159. local function cookie_matched(extra, ts)
  160. local dt = task:get_date{format = 'connect', gmt = true}
  161. if dt < ts then
  162. rspamd_logger.infox(task, 'ignore cookie as its date is in future')
  163. return
  164. end
  165. if settings.cookie_valid_time then
  166. if dt - ts > settings.cookie_valid_time then
  167. rspamd_logger.infox(task,
  168. 'ignore cookie as its timestamp is too old: %s (%s current time)',
  169. ts, dt)
  170. return
  171. end
  172. end
  173. if extra then
  174. task:insert_result(settings['symbol'], 1.0,
  175. string.format('cookie:%s:%s', extra, ts))
  176. else
  177. task:insert_result(settings['symbol'], 1.0,
  178. string.format('cookie:%s', ts))
  179. end
  180. if settings['action'] ~= nil then
  181. local ip_addr = task:get_ip()
  182. if (settings.use_auth and
  183. task:get_user()) or
  184. (settings.use_local and ip_addr and ip_addr:is_local()) then
  185. rspamd_logger.infox(task, "not forcing action for local network or authorized user");
  186. else
  187. task:set_pre_result(settings['action'], settings['message'], N)
  188. end
  189. end
  190. end
  191. -- If in-reply-to header not present return
  192. local irt = task:get_header('in-reply-to')
  193. if irt == nil then
  194. return
  195. end
  196. local cr = require "rspamd_cryptobox"
  197. -- Extract user part if needed
  198. local extracted_cookie = irt:match('^%<?([^@]+)@.*$')
  199. if not extracted_cookie then
  200. -- Assume full message id as a cookie
  201. extracted_cookie = irt
  202. end
  203. local dec_cookie,ts = cr.decrypt_cookie(settings.cookie_key, extracted_cookie)
  204. if dec_cookie then
  205. -- We have something that looks like a cookie
  206. if settings.cookie_is_pattern then
  207. local m = dec_cookie:match(settings.cookie)
  208. if m then
  209. cookie_matched(m, ts)
  210. end
  211. else
  212. -- Direct match
  213. if dec_cookie == settings.cookie then
  214. cookie_matched(nil, ts)
  215. end
  216. end
  217. end
  218. end
  219. local opts = rspamd_config:get_all_opt('replies')
  220. if not (opts and type(opts) == 'table') then
  221. rspamd_logger.infox(rspamd_config, 'module is unconfigured')
  222. return
  223. end
  224. if opts then
  225. settings = lua_util.override_defaults(settings, opts)
  226. redis_params = lua_redis.parse_redis_server('replies')
  227. if not redis_params then
  228. if not (settings.cookie and settings.cookie_key) then
  229. rspamd_logger.infox(rspamd_config, 'no servers are specified, disabling module')
  230. lua_util.disable_module(N, "redis")
  231. else
  232. -- Cookies mode
  233. -- Check key sanity:
  234. local pattern = {'^'}
  235. for i=1,32 do pattern[i + 1] = '[a-zA-Z0-9]' end
  236. pattern[34] = '$'
  237. if not settings.cookie_key:match(table.concat(pattern, '')) then
  238. rspamd_logger.errx(rspamd_config,
  239. 'invalid cookies key: %s, must be 32 hex digits', settings.cookie_key)
  240. lua_util.disable_module(N, "config")
  241. return
  242. end
  243. if settings.cookie_valid_time then
  244. settings.cookie_valid_time = lua_util.parse_time_interval(settings.cookie_valid_time)
  245. end
  246. local id = rspamd_config:register_symbol({
  247. name = 'REPLIES_CHECK',
  248. type = 'prefilter',
  249. callback = replies_check_cookie,
  250. flags = 'nostat',
  251. priority = lua_util.symbols_priorities.medium,
  252. group = "replies"
  253. })
  254. rspamd_config:register_symbol({
  255. name = settings['symbol'],
  256. parent = id,
  257. type = 'virtual',
  258. score = settings.score,
  259. group = "replies",
  260. })
  261. end
  262. else
  263. rspamd_config:register_symbol({
  264. name = 'REPLIES_SET',
  265. type = 'idempotent',
  266. callback = replies_set,
  267. group = 'replies',
  268. flags = 'explicit_disable,ignore_passthrough',
  269. })
  270. local id = rspamd_config:register_symbol({
  271. name = 'REPLIES_CHECK',
  272. type = 'prefilter',
  273. flags = 'nostat',
  274. callback = replies_check,
  275. priority = lua_util.symbols_priorities.medium,
  276. group = "replies"
  277. })
  278. rspamd_config:register_symbol({
  279. name = settings['symbol'],
  280. parent = id,
  281. type = 'virtual',
  282. score = settings.score,
  283. group = "replies",
  284. })
  285. end
  286. end