diff options
Diffstat (limited to 'src/plugins/lua/bimi.lua')
-rw-r--r-- | src/plugins/lua/bimi.lua | 391 |
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 |