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.

bimi.lua 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. --[[
  2. Copyright (c) 2021, 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 N = "bimi"
  14. local lua_util = require "lua_util"
  15. local rspamd_logger = require "rspamd_logger"
  16. local ts = (require "tableshape").types
  17. local lua_redis = require "lua_redis"
  18. local ucl = require "ucl"
  19. local lua_mime = require "lua_mime"
  20. local rspamd_http = require "rspamd_http"
  21. local rspamd_util = require "rspamd_util"
  22. local settings = {
  23. helper_url = "http://127.0.0.1:3030",
  24. helper_timeout = 5,
  25. helper_sync = true,
  26. vmc_only = true,
  27. redis_prefix = 'rs_bimi',
  28. redis_min_expiry = 24 * 3600,
  29. }
  30. local redis_params
  31. local settings_schema = ts.shape({
  32. helper_url = ts.string,
  33. helper_timeout = ts.number + ts.string / lua_util.parse_time_interval,
  34. helper_sync = ts.boolean,
  35. vmc_only = ts.boolean,
  36. redis_min_expiry = ts.number + ts.string / lua_util.parse_time_interval,
  37. redis_prefix = ts.string,
  38. enabled = ts.boolean:is_optional(),
  39. }, {extra_fields = lua_redis.config_schema})
  40. local function check_dmarc_policy(task)
  41. local dmarc_sym = task:get_symbol('DMARC_POLICY_ALLOW')
  42. if not dmarc_sym then
  43. lua_util.debugm(N, task, "no DMARC allow symbol")
  44. return nil
  45. end
  46. local opts = dmarc_sym[1].options or {}
  47. if not opts[1] or #opts ~= 2 then
  48. lua_util.debugm(N, task, "DMARC options are bogus: %s", opts)
  49. return nil
  50. end
  51. -- opts[1] - domain; opts[2] - policy
  52. local dom, policy = opts[1], opts[2]
  53. if policy ~= 'reject' and policy ~= 'quarantine' then
  54. lua_util.debugm(N, task, "DMARC policy for domain %s is not strict: %s",
  55. dom, policy)
  56. return nil
  57. end
  58. return dom
  59. end
  60. local function gen_bimi_grammar()
  61. local lpeg = require "lpeg"
  62. lpeg.locale(lpeg)
  63. local space = lpeg.space^0
  64. local name = lpeg.C(lpeg.alpha^1) * space
  65. local sep = (lpeg.S("\\;") * space) + (lpeg.space^1)
  66. local value = lpeg.C(lpeg.P(lpeg.graph - sep)^1)
  67. local pair = lpeg.Cg(name * "=" * space * value) * sep^-1
  68. local list = lpeg.Cf(lpeg.Ct("") * pair^0, rawset)
  69. local version = lpeg.P("v") * space * lpeg.P("=") * space * lpeg.P("BIMI1")
  70. local record = version * sep * list
  71. return record
  72. end
  73. local bimi_grammar = gen_bimi_grammar()
  74. local function check_bimi_record(task, rec)
  75. local elts = bimi_grammar:match(rec)
  76. if elts then
  77. lua_util.debugm(N, task, "got BIMI record: %s, processed=%s",
  78. rec, elts)
  79. local res = {}
  80. if type(elts.l) == 'string' then
  81. res.l = elts.l
  82. end
  83. if type(elts.a) == 'string' then
  84. res.a = elts.a
  85. end
  86. if res.l or res.a then
  87. return res
  88. end
  89. end
  90. end
  91. local function insert_bimi_headers(task, domain, bimi_content)
  92. local hdr_name = 'BIMI-Indicator'
  93. -- Re-encode base64...
  94. local content = rspamd_util.encode_base64(rspamd_util.decode_base64(bimi_content),
  95. 73, task:get_newlines_type())
  96. lua_mime.modify_headers(task, {
  97. remove = {[hdr_name] = 0},
  98. add = {
  99. [hdr_name] = {
  100. order = 0,
  101. value = rspamd_util.fold_header(hdr_name, content,
  102. task:get_newlines_type())
  103. }
  104. }
  105. })
  106. task:insert_result('BIMI_VALID', 1.0, {domain})
  107. end
  108. local function process_bimi_json(task, domain, redis_data)
  109. local parser = ucl.parser()
  110. local _,err = parser:parse_string(redis_data)
  111. if err then
  112. rspamd_logger.errx(task, "cannot parse BIMI result from Redis for %s: %s",
  113. domain, err)
  114. else
  115. local d = parser:get_object()
  116. if d.content then
  117. insert_bimi_headers(task, domain, d.content)
  118. elseif d.error then
  119. lua_util.debugm(N, task, "invalid BIMI for %s: %s",
  120. domain, d.error)
  121. end
  122. end
  123. end
  124. local function make_helper_request(task, domain, record, redis_server)
  125. local is_sync = settings.helper_sync
  126. local helper_url = string.format('%s/v1/check', settings.helper_url)
  127. local redis_key = string.format('%s%s', settings.redis_prefix,
  128. domain)
  129. local function http_helper_callback(http_err, code, body, _)
  130. if http_err then
  131. rspamd_logger.warnx(task, 'got error reply from helper %s: code=%s; reply=%s',
  132. helper_url, code, http_err)
  133. return
  134. end
  135. if code ~= 200 then
  136. rspamd_logger.warnx(task, 'got non 200 reply from helper %s: code=%s; reply=%s',
  137. helper_url, code, http_err)
  138. return
  139. end
  140. if is_sync then
  141. local parser = ucl.parser()
  142. local _,err = parser:parse_string(body)
  143. if err then
  144. rspamd_logger.errx(task, "cannot parse BIMI result from helper for %s: %s",
  145. domain, err)
  146. else
  147. local d = parser:get_object()
  148. if d.content then
  149. insert_bimi_headers(task, domain, d.content)
  150. elseif d.error then
  151. lua_util.debugm(N, task, "invalid BIMI for %s: %s",
  152. domain, d.error)
  153. end
  154. local ret, upstream
  155. local function redis_set_cb(redis_err, _)
  156. if redis_err then
  157. rspamd_logger.warnx(task, 'cannot get reply from Redis when storing image %s: %s',
  158. upstream:get_addr():to_string(), redis_err)
  159. upstream:fail()
  160. else
  161. lua_util.debugm(N, task, 'stored bimi image in Redis for domain %s; key=%s',
  162. domain, redis_key)
  163. end
  164. end
  165. ret,_,upstream = lua_redis.redis_make_request(task,
  166. redis_params, -- connect params
  167. redis_key, -- hash key
  168. true, -- is write
  169. redis_set_cb, --callback
  170. 'PSETEX', -- command
  171. {redis_key, tostring(settings.redis_min_expiry * 1000.0),
  172. ucl.to_format(d, "json-compact")})
  173. if not ret then
  174. rspamd_logger.warnx(task, 'cannot make request to Redis when storing image; domain %s',
  175. domain)
  176. end
  177. end
  178. else
  179. -- In async mode we skip request and use merely Redis to insert indicators
  180. lua_util.debugm(N, task, "sent request to resolve %s to %s",
  181. domain, helper_url)
  182. end
  183. end
  184. local request_data = {
  185. url = record.a,
  186. sync = is_sync,
  187. domain = domain
  188. }
  189. if not is_sync then
  190. -- Allow bimi helper to save data in Redis
  191. request_data.redis_server = redis_server
  192. request_data.redis_prefix = settings.redis_prefix
  193. request_data.redis_expiry = settings.redis_min_expiry * 1000.0
  194. else
  195. request_data.skip_redis = true
  196. end
  197. local serialised = ucl.to_format(request_data, 'json-compact')
  198. lua_util.debugm(N, task, "send request to BIMI helper: %s",
  199. serialised)
  200. rspamd_http.request({
  201. task = task,
  202. mime_type = 'application/json',
  203. timeout = settings.helper_timeout,
  204. body = serialised,
  205. url = helper_url,
  206. callback = http_helper_callback,
  207. keepalive = true,
  208. })
  209. end
  210. local function check_bimi_vmc(task, domain, record)
  211. local redis_key = string.format('%s%s', settings.redis_prefix,
  212. domain)
  213. local ret, _, upstream
  214. local function redis_cached_cb(err, data)
  215. if err then
  216. rspamd_logger.warnx(task, 'cannot get reply from Redis %s: %s',
  217. upstream:get_addr():to_string(), err)
  218. upstream:fail()
  219. else
  220. if type(data) == 'string' then
  221. -- We got a cached record, good stuff
  222. lua_util.debugm(N, task, "got valid cached BIMI result for domain: %s",
  223. domain)
  224. process_bimi_json(task, domain, data)
  225. else
  226. -- Get server addr + port
  227. -- We need to fix IPv6 address as redis-rs has no support of
  228. -- the braced IPv6 addresses
  229. local db, password = '', ''
  230. if redis_params.db then
  231. db = string.format('/%s', redis_params.db)
  232. end
  233. if redis_params.password then
  234. password = string.format(':%s@', redis_params.password)
  235. end
  236. local redis_server = string.format('redis://%s%s:%s%s',
  237. password,
  238. upstream:get_name(), upstream:get_port(),
  239. db)
  240. make_helper_request(task, domain, record, redis_server)
  241. end
  242. end
  243. end
  244. -- We first check Redis and then try to use helper
  245. ret,_,upstream = lua_redis.redis_make_request(task,
  246. redis_params, -- connect params
  247. redis_key, -- hash key
  248. false, -- is write
  249. redis_cached_cb, --callback
  250. 'GET', -- command
  251. {redis_key})
  252. if not ret then
  253. rspamd_logger.warnx(task, 'cannot make request to Redis; domain %s', domain)
  254. end
  255. end
  256. local function check_bimi_dns(task, domain)
  257. local resolve_name = string.format('default._bimi.%s', domain)
  258. local dns_cb = function (_, _, results, err)
  259. if err then
  260. lua_util.debugm(N, task, "cannot resolve bimi for %s: %s",
  261. domain, err)
  262. else
  263. for _,rec in ipairs(results) do
  264. local res = check_bimi_record(task, rec)
  265. if res then
  266. if settings.vmc_only and not res.a then
  267. lua_util.debugm(N, task, "BIMI for domain %s has no VMC, skip it",
  268. domain)
  269. return
  270. end
  271. if res.a then
  272. check_bimi_vmc(task, domain, res)
  273. elseif res.l then
  274. -- TODO: add l check
  275. lua_util.debugm(N, task, "l only BIMI for domain %s is not implemented yet",
  276. domain)
  277. end
  278. end
  279. end
  280. end
  281. end
  282. task:get_resolver():resolve_txt({
  283. task=task,
  284. name = resolve_name,
  285. callback = dns_cb,
  286. forced = true
  287. })
  288. end
  289. local function bimi_callback(task)
  290. local dmarc_domain_maybe = check_dmarc_policy(task)
  291. if not dmarc_domain_maybe then return end
  292. -- We can either check BIMI via DNS or check Redis cache
  293. -- BIMI check is an external check, so we might prefer Redis to be checked
  294. -- first. On the other hand, DNS request is cheaper and counting low BIMI
  295. -- adaptation we would need to have both Redis and DNS request to hit no
  296. -- result. So, it might be better to check DNS first at this stage...
  297. check_bimi_dns(task, dmarc_domain_maybe)
  298. end
  299. local opts = rspamd_config:get_all_opt('bimi')
  300. if not opts then
  301. lua_util.disable_module(N, "config")
  302. return
  303. end
  304. settings = lua_util.override_defaults(settings, opts)
  305. local res,err = settings_schema:transform(settings)
  306. if not res then
  307. rspamd_logger.warnx(rspamd_config, 'plugin is misconfigured: %s', err)
  308. lua_util.disable_module(N, "config")
  309. return
  310. end
  311. rspamd_logger.infox(rspamd_config, 'enabled BIMI plugin')
  312. settings = res
  313. redis_params = lua_redis.parse_redis_server(N, opts)
  314. if redis_params then
  315. local id = rspamd_config:register_symbol({
  316. name = 'BIMI_CHECK',
  317. type = 'normal',
  318. callback = bimi_callback,
  319. })
  320. rspamd_config:register_symbol{
  321. name = 'BIMI_VALID',
  322. type = 'virtual',
  323. parent = id,
  324. score = 0.0
  325. }
  326. rspamd_config:register_dependency('BIMI_CHECK', 'DMARC_CHECK')
  327. else
  328. lua_util.disable_module(N, "redis")
  329. end