summaryrefslogtreecommitdiffstats
path: root/src/plugins/lua/bimi.lua
diff options
context:
space:
mode:
Diffstat (limited to 'src/plugins/lua/bimi.lua')
-rw-r--r--src/plugins/lua/bimi.lua391
1 files changed, 391 insertions, 0 deletions
diff --git a/src/plugins/lua/bimi.lua b/src/plugins/lua/bimi.lua
new file mode 100644
index 0000000..2783590
--- /dev/null
+++ b/src/plugins/lua/bimi.lua
@@ -0,0 +1,391 @@
+--[[
+Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com>
+
+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.
+]]--
+
+local N = "bimi"
+local lua_util = require "lua_util"
+local rspamd_logger = require "rspamd_logger"
+local ts = (require "tableshape").types
+local lua_redis = require "lua_redis"
+local ucl = require "ucl"
+local lua_mime = require "lua_mime"
+local rspamd_http = require "rspamd_http"
+local rspamd_util = require "rspamd_util"
+
+local settings = {
+ helper_url = "http://127.0.0.1:3030",
+ helper_timeout = 5,
+ helper_sync = true,
+ vmc_only = true,
+ redis_prefix = 'rs_bimi',
+ redis_min_expiry = 24 * 3600,
+}
+local redis_params
+
+local settings_schema = lua_redis.enrich_schema({
+ helper_url = ts.string,
+ helper_timeout = ts.number + ts.string / lua_util.parse_time_interval,
+ helper_sync = ts.boolean,
+ vmc_only = ts.boolean,
+ redis_min_expiry = ts.number + ts.string / lua_util.parse_time_interval,
+ redis_prefix = ts.string,
+ enabled = ts.boolean:is_optional(),
+})
+
+local function check_dmarc_policy(task)
+ local dmarc_sym = task:get_symbol('DMARC_POLICY_ALLOW')
+
+ if not dmarc_sym then
+ lua_util.debugm(N, task, "no DMARC allow symbol")
+ return nil
+ end
+
+ local opts = dmarc_sym[1].options or {}
+ if not opts[1] or #opts ~= 2 then
+ lua_util.debugm(N, task, "DMARC options are bogus: %s", opts)
+ return nil
+ end
+
+ -- opts[1] - domain; opts[2] - policy
+ local dom, policy = opts[1], opts[2]
+
+ if policy ~= 'reject' and policy ~= 'quarantine' then
+ lua_util.debugm(N, task, "DMARC policy for domain %s is not strict: %s",
+ dom, policy)
+ return nil
+ end
+
+ return dom
+end
+
+local function gen_bimi_grammar()
+ local lpeg = require "lpeg"
+ lpeg.locale(lpeg)
+ local space = lpeg.space ^ 0
+ local name = lpeg.C(lpeg.alpha ^ 1) * space
+ local sep = (lpeg.S("\\;") * space) + (lpeg.space ^ 1)
+ local value = lpeg.C(lpeg.P(lpeg.graph - sep) ^ 1)
+ local pair = lpeg.Cg(name * "=" * space * value) * sep ^ -1
+ local list = lpeg.Cf(lpeg.Ct("") * pair ^ 0, rawset)
+ local version = lpeg.P("v") * space * lpeg.P("=") * space * lpeg.P("BIMI1")
+ local record = version * sep * list
+
+ return record
+end
+
+local bimi_grammar = gen_bimi_grammar()
+
+local function check_bimi_record(task, rec)
+ local elts = bimi_grammar:match(rec)
+
+ if elts then
+ lua_util.debugm(N, task, "got BIMI record: %s, processed=%s",
+ rec, elts)
+ local res = {}
+
+ if type(elts.l) == 'string' then
+ res.l = elts.l
+ end
+ if type(elts.a) == 'string' then
+ res.a = elts.a
+ end
+
+ if res.l or res.a then
+ return res
+ end
+ end
+end
+
+local function insert_bimi_headers(task, domain, bimi_content)
+ local hdr_name = 'BIMI-Indicator'
+ -- Re-encode base64...
+ local content = rspamd_util.encode_base64(rspamd_util.decode_base64(bimi_content),
+ 73, task:get_newlines_type())
+ lua_mime.modify_headers(task, {
+ remove = { [hdr_name] = 0 },
+ add = {
+ [hdr_name] = {
+ order = 0,
+ value = rspamd_util.fold_header(hdr_name, content,
+ task:get_newlines_type())
+ }
+ }
+ })
+ task:insert_result('BIMI_VALID', 1.0, { domain })
+end
+
+local function process_bimi_json(task, domain, redis_data)
+ local parser = ucl.parser()
+ local _, err = parser:parse_string(redis_data)
+
+ if err then
+ rspamd_logger.errx(task, "cannot parse BIMI result from Redis for %s: %s",
+ domain, err)
+ else
+ local d = parser:get_object()
+ if d.content then
+ insert_bimi_headers(task, domain, d.content)
+ elseif d.error then
+ lua_util.debugm(N, task, "invalid BIMI for %s: %s",
+ domain, d.error)
+ end
+ end
+end
+
+local function make_helper_request(task, domain, record, redis_server)
+ local is_sync = settings.helper_sync
+ local helper_url = string.format('%s/v1/check', settings.helper_url)
+ local redis_key = string.format('%s%s', settings.redis_prefix,
+ domain)
+
+ local function http_helper_callback(http_err, code, body, _)
+ if http_err then
+ rspamd_logger.warnx(task, 'got error reply from helper %s: code=%s; reply=%s',
+ helper_url, code, http_err)
+ return
+ end
+ if code ~= 200 then
+ rspamd_logger.warnx(task, 'got non 200 reply from helper %s: code=%s; reply=%s',
+ helper_url, code, http_err)
+ return
+ end
+ if is_sync then
+ local parser = ucl.parser()
+ local _, err = parser:parse_string(body)
+
+ if err then
+ rspamd_logger.errx(task, "cannot parse BIMI result from helper for %s: %s",
+ domain, err)
+ else
+ local d = parser:get_object()
+ if d.content then
+ insert_bimi_headers(task, domain, d.content)
+ elseif d.error then
+ lua_util.debugm(N, task, "invalid BIMI for %s: %s",
+ domain, d.error)
+ end
+
+ local ret, upstream
+ local function redis_set_cb(redis_err, _)
+ if redis_err then
+ rspamd_logger.warnx(task, 'cannot get reply from Redis when storing image %s: %s',
+ upstream:get_addr():to_string(), redis_err)
+ upstream:fail()
+ else
+ lua_util.debugm(N, task, 'stored bimi image in Redis for domain %s; key=%s',
+ domain, redis_key)
+ end
+ end
+
+ ret, _, upstream = lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ redis_key, -- hash key
+ true, -- is write
+ redis_set_cb, --callback
+ 'PSETEX', -- command
+ { redis_key, tostring(settings.redis_min_expiry * 1000.0),
+ ucl.to_format(d, "json-compact") })
+
+ if not ret then
+ rspamd_logger.warnx(task, 'cannot make request to Redis when storing image; domain %s',
+ domain)
+ end
+ end
+ else
+ -- In async mode we skip request and use merely Redis to insert indicators
+ lua_util.debugm(N, task, "sent request to resolve %s to %s",
+ domain, helper_url)
+ end
+ end
+
+ local request_data = {
+ url = record.a,
+ sync = is_sync,
+ domain = domain
+ }
+
+ if not is_sync then
+ -- Allow bimi helper to save data in Redis
+ request_data.redis_server = redis_server
+ request_data.redis_prefix = settings.redis_prefix
+ request_data.redis_expiry = settings.redis_min_expiry * 1000.0
+ else
+ request_data.skip_redis = true
+ end
+
+ local serialised = ucl.to_format(request_data, 'json-compact')
+ lua_util.debugm(N, task, "send request to BIMI helper: %s",
+ serialised)
+ rspamd_http.request({
+ task = task,
+ mime_type = 'application/json',
+ timeout = settings.helper_timeout,
+ body = serialised,
+ url = helper_url,
+ callback = http_helper_callback,
+ keepalive = true,
+ })
+end
+
+local function check_bimi_vmc(task, domain, record)
+ local redis_key = string.format('%s%s', settings.redis_prefix,
+ domain)
+ local ret, _, upstream
+
+ local function redis_cached_cb(err, data)
+ if err then
+ rspamd_logger.warnx(task, 'cannot get reply from Redis %s: %s',
+ upstream:get_addr():to_string(), err)
+ upstream:fail()
+ else
+ if type(data) == 'string' then
+ -- We got a cached record, good stuff
+ lua_util.debugm(N, task, "got valid cached BIMI result for domain: %s",
+ domain)
+ process_bimi_json(task, domain, data)
+ else
+ -- Get server addr + port
+ -- We need to fix IPv6 address as redis-rs has no support of
+ -- the braced IPv6 addresses
+ local db, password = '', ''
+ if redis_params.db then
+ db = string.format('/%s', redis_params.db)
+ end
+ if redis_params.username then
+ if redis_params.password then
+ password = string.format( '%s:%s@', redis_params.username, redis_params.password)
+ else
+ rspamd_logger.warnx(task, "Redis requires a password when username is supplied")
+ end
+ elseif redis_params.password then
+ password = string.format(':%s@', redis_params.password)
+ end
+ local redis_server = string.format('redis://%s%s:%s%s',
+ password,
+ upstream:get_name(), upstream:get_port(),
+ db)
+ make_helper_request(task, domain, record, redis_server)
+ end
+ end
+ end
+
+ -- We first check Redis and then try to use helper
+ ret, _, upstream = lua_redis.redis_make_request(task,
+ redis_params, -- connect params
+ redis_key, -- hash key
+ false, -- is write
+ redis_cached_cb, --callback
+ 'GET', -- command
+ { redis_key })
+
+ if not ret then
+ rspamd_logger.warnx(task, 'cannot make request to Redis; domain %s', domain)
+ end
+end
+
+local function check_bimi_dns(task, domain)
+ local resolve_name = string.format('default._bimi.%s', domain)
+ local dns_cb = function(_, _, results, err)
+ if err then
+ lua_util.debugm(N, task, "cannot resolve bimi for %s: %s",
+ domain, err)
+ else
+ for _, rec in ipairs(results) do
+ local res = check_bimi_record(task, rec)
+
+ if res then
+ if settings.vmc_only and not res.a then
+ lua_util.debugm(N, task, "BIMI for domain %s has no VMC, skip it",
+ domain)
+
+ return
+ end
+
+ if res.a then
+ check_bimi_vmc(task, domain, res)
+ elseif res.l then
+ -- TODO: add l check
+ lua_util.debugm(N, task, "l only BIMI for domain %s is not implemented yet",
+ domain)
+ end
+ end
+ end
+ end
+ end
+ task:get_resolver():resolve_txt({
+ task = task,
+ name = resolve_name,
+ callback = dns_cb,
+ forced = true
+ })
+end
+
+local function bimi_callback(task)
+ local dmarc_domain_maybe = check_dmarc_policy(task)
+
+ if not dmarc_domain_maybe then
+ return
+ end
+
+
+ -- We can either check BIMI via DNS or check Redis cache
+ -- BIMI check is an external check, so we might prefer Redis to be checked
+ -- first. On the other hand, DNS request is cheaper and counting low BIMI
+ -- adaptation we would need to have both Redis and DNS request to hit no
+ -- result. So, it might be better to check DNS first at this stage...
+ check_bimi_dns(task, dmarc_domain_maybe)
+end
+
+local opts = rspamd_config:get_all_opt('bimi')
+if not opts then
+ lua_util.disable_module(N, "config")
+ return
+end
+
+settings = lua_util.override_defaults(settings, opts)
+local res, err = settings_schema:transform(settings)
+
+if not res then
+ rspamd_logger.warnx(rspamd_config, 'plugin is misconfigured: %s', err)
+ local err_msg = string.format("schema error: %s", res)
+ lua_util.config_utils.push_config_error(N, err_msg)
+ lua_util.disable_module(N, "failed", err_msg)
+ return
+end
+
+rspamd_logger.infox(rspamd_config, 'enabled BIMI plugin')
+
+settings = res
+redis_params = lua_redis.parse_redis_server(N, opts)
+
+if redis_params then
+ local id = rspamd_config:register_symbol({
+ name = 'BIMI_CHECK',
+ type = 'normal',
+ callback = bimi_callback,
+ augmentations = { string.format("timeout=%f", settings.helper_timeout or
+ redis_params.timeout or 0.0) }
+ })
+ rspamd_config:register_symbol {
+ name = 'BIMI_VALID',
+ type = 'virtual',
+ parent = id,
+ score = 0.0
+ }
+
+ rspamd_config:register_dependency('BIMI_CHECK', 'DMARC_CHECK')
+else
+ lua_util.disable_module(N, "redis")
+end