summaryrefslogtreecommitdiffstats
path: root/src/plugins/lua/replies.lua
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/plugins/lua/replies.lua328
1 files changed, 328 insertions, 0 deletions
diff --git a/src/plugins/lua/replies.lua b/src/plugins/lua/replies.lua
new file mode 100644
index 0000000..c4df9c9
--- /dev/null
+++ b/src/plugins/lua/replies.lua
@@ -0,0 +1,328 @@
+--[[
+Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
+Copyright (c) 2016, Andrew Lewis <nerf@judo.za.org>
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+]]--
+
+if confighelp then
+ return
+end
+
+local rspamd_logger = require 'rspamd_logger'
+local hash = require 'rspamd_cryptobox_hash'
+local lua_util = require 'lua_util'
+local lua_redis = require 'lua_redis'
+local fun = require "fun"
+
+-- A plugin that implements replies check using redis
+
+-- Default port for redis upstreams
+local redis_params
+local settings = {
+ action = nil,
+ expire = 86400, -- 1 day by default
+ key_prefix = 'rr',
+ key_size = 20,
+ message = 'Message is reply to one we originated',
+ symbol = 'REPLY',
+ score = -4, -- Default score
+ use_auth = true,
+ use_local = true,
+ cookie = nil,
+ cookie_key = nil,
+ cookie_is_pattern = false,
+ cookie_valid_time = '2w', -- 2 weeks by default
+ min_message_id = 2, -- minimum length of the message-id header
+}
+
+local N = "replies"
+
+local function make_key(goop, sz, prefix)
+ local h = hash.create()
+ h:update(goop)
+ local key
+ if sz then
+ key = h:base32():sub(1, sz)
+ else
+ key = h:base32()
+ end
+
+ if prefix then
+ key = prefix .. key
+ end
+
+ return key
+end
+
+local function replies_check(task)
+ local in_reply_to
+ local function check_recipient(stored_rcpt)
+ local rcpts = task:get_recipients('mime')
+
+ if rcpts then
+ local filter_predicate = function(input_rcpt)
+ local real_rcpt_h = make_key(input_rcpt:lower(), 8)
+
+ return real_rcpt_h == stored_rcpt
+ end
+
+ if fun.any(filter_predicate, fun.map(function(rcpt)
+ return rcpt.addr or ''
+ end, rcpts)) then
+ lua_util.debugm(N, task, 'reply to %s validated', in_reply_to)
+ return true
+ end
+
+ rspamd_logger.infox(task, 'ignoring reply to %s as no recipients are matching hash %s',
+ in_reply_to, stored_rcpt)
+ else
+ rspamd_logger.infox(task, 'ignoring reply to %s as recipient cannot be detected for hash %s',
+ in_reply_to, stored_rcpt)
+ end
+
+ return false
+ end
+
+ local function redis_get_cb(err, data, addr)
+ if err ~= nil then
+ rspamd_logger.errx(task, 'redis_get_cb error when reading data from %s: %s', addr:get_addr(), err)
+ return
+ end
+ if data and type(data) == 'string' and check_recipient(data) then
+ -- Hash was found
+ task:insert_result(settings['symbol'], 1.0)
+ if settings['action'] ~= nil then
+ local ip_addr = task:get_ip()
+ if (settings.use_auth and
+ task:get_user()) or
+ (settings.use_local and ip_addr and ip_addr:is_local()) then
+ rspamd_logger.infox(task, "not forcing action for local network or authorized user");
+ else
+ task:set_pre_result(settings['action'], settings['message'], N)
+ end
+ end
+ end
+ end
+ -- If in-reply-to header not present return
+ in_reply_to = task:get_header_raw('in-reply-to')
+ if not in_reply_to then
+ return
+ end
+ -- Create hash of in-reply-to and query redis
+ local key = make_key(in_reply_to, settings.key_size, settings.key_prefix)
+
+ local ret = lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ false, -- is write
+ redis_get_cb, --callback
+ 'GET', -- command
+ { key } -- arguments
+ )
+
+ if not ret then
+ rspamd_logger.errx(task, "redis request wasn't scheduled")
+ end
+end
+
+local function replies_set(task)
+ local function redis_set_cb(err, _, addr)
+ if err ~= nil then
+ rspamd_logger.errx(task, 'redis_set_cb error when writing data to %s: %s', addr:get_addr(), err)
+ end
+ end
+ -- If sender is unauthenticated return
+ local ip = task:get_ip()
+ if settings.use_auth and task:get_user() then
+ lua_util.debugm(N, task, 'sender is authenticated')
+ elseif settings.use_local and (ip and ip:is_local()) then
+ lua_util.debugm(N, task, 'sender is from local network')
+ else
+ return
+ end
+ -- If no message-id present return
+ local msg_id = task:get_header_raw('message-id')
+ if msg_id == nil or msg_id:len() <= (settings.min_message_id or 2) then
+ return
+ end
+ -- Create hash of message-id and store to redis
+ local key = make_key(msg_id, settings.key_size, settings.key_prefix)
+
+ local sender = task:get_reply_sender()
+
+ if sender then
+ local sender_hash = make_key(sender:lower(), 8)
+ lua_util.debugm(N, task, 'storing id: %s (%s), reply-to: %s (%s) for replies check',
+ msg_id, key, sender, sender_hash)
+ local ret = lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ true, -- is write
+ redis_set_cb, --callback
+ 'PSETEX', -- command
+ { key, tostring(math.floor(settings['expire'] * 1000)), sender_hash } -- arguments
+ )
+ if not ret then
+ rspamd_logger.errx(task, "redis request wasn't scheduled")
+ end
+ else
+ rspamd_logger.infox(task, "cannot find reply sender address")
+ end
+end
+
+local function replies_check_cookie(task)
+ local function cookie_matched(extra, ts)
+ local dt = task:get_date { format = 'connect', gmt = true }
+
+ if dt < ts then
+ rspamd_logger.infox(task, 'ignore cookie as its date is in future')
+
+ return
+ end
+
+ if settings.cookie_valid_time then
+ if dt - ts > settings.cookie_valid_time then
+ rspamd_logger.infox(task,
+ 'ignore cookie as its timestamp is too old: %s (%s current time)',
+ ts, dt)
+
+ return
+ end
+ end
+
+ if extra then
+ task:insert_result(settings['symbol'], 1.0,
+ string.format('cookie:%s:%s', extra, ts))
+ else
+ task:insert_result(settings['symbol'], 1.0,
+ string.format('cookie:%s', ts))
+ end
+ if settings['action'] ~= nil then
+ local ip_addr = task:get_ip()
+ if (settings.use_auth and
+ task:get_user()) or
+ (settings.use_local and ip_addr and ip_addr:is_local()) then
+ rspamd_logger.infox(task, "not forcing action for local network or authorized user");
+ else
+ task:set_pre_result(settings['action'], settings['message'], N)
+ end
+ end
+ end
+
+ -- If in-reply-to header not present return
+ local irt = task:get_header('in-reply-to')
+ if irt == nil then
+ return
+ end
+
+ local cr = require "rspamd_cryptobox"
+ -- Extract user part if needed
+ local extracted_cookie = irt:match('^%<?([^@]+)@.*$')
+ if not extracted_cookie then
+ -- Assume full message id as a cookie
+ extracted_cookie = irt
+ end
+
+ local dec_cookie, ts = cr.decrypt_cookie(settings.cookie_key, extracted_cookie)
+
+ if dec_cookie then
+ -- We have something that looks like a cookie
+ if settings.cookie_is_pattern then
+ local m = dec_cookie:match(settings.cookie)
+
+ if m then
+ cookie_matched(m, ts)
+ end
+ else
+ -- Direct match
+ if dec_cookie == settings.cookie then
+ cookie_matched(nil, ts)
+ end
+ end
+ end
+end
+
+local opts = rspamd_config:get_all_opt('replies')
+if not (opts and type(opts) == 'table') then
+ rspamd_logger.infox(rspamd_config, 'module is unconfigured')
+ return
+end
+if opts then
+ settings = lua_util.override_defaults(settings, opts)
+ redis_params = lua_redis.parse_redis_server('replies')
+ if not redis_params then
+ if not (settings.cookie and settings.cookie_key) then
+ rspamd_logger.infox(rspamd_config, 'no servers are specified, disabling module')
+ lua_util.disable_module(N, "redis")
+ else
+ -- Cookies mode
+ -- Check key sanity:
+ local pattern = { '^' }
+ for i = 1, 32 do
+ pattern[i + 1] = '[a-zA-Z0-9]'
+ end
+ pattern[34] = '$'
+ if not settings.cookie_key:match(table.concat(pattern, '')) then
+ rspamd_logger.errx(rspamd_config,
+ 'invalid cookies key: %s, must be 32 hex digits', settings.cookie_key)
+ lua_util.disable_module(N, "config")
+
+ return
+ end
+
+ if settings.cookie_valid_time then
+ settings.cookie_valid_time = lua_util.parse_time_interval(settings.cookie_valid_time)
+ end
+
+ local id = rspamd_config:register_symbol({
+ name = 'REPLIES_CHECK',
+ type = 'prefilter',
+ callback = replies_check_cookie,
+ flags = 'nostat',
+ priority = lua_util.symbols_priorities.medium,
+ group = "replies"
+ })
+ rspamd_config:register_symbol({
+ name = settings['symbol'],
+ parent = id,
+ type = 'virtual',
+ score = settings.score,
+ group = "replies",
+ })
+ end
+ else
+ rspamd_config:register_symbol({
+ name = 'REPLIES_SET',
+ type = 'idempotent',
+ callback = replies_set,
+ group = 'replies',
+ flags = 'explicit_disable,ignore_passthrough',
+ })
+ local id = rspamd_config:register_symbol({
+ name = 'REPLIES_CHECK',
+ type = 'prefilter',
+ flags = 'nostat',
+ callback = replies_check,
+ priority = lua_util.symbols_priorities.medium,
+ group = "replies"
+ })
+ rspamd_config:register_symbol({
+ name = settings['symbol'],
+ parent = id,
+ type = 'virtual',
+ score = settings.score,
+ group = "replies",
+ })
+ end
+end