summaryrefslogtreecommitdiffstats
path: root/rules/headers_checks.lua
diff options
context:
space:
mode:
Diffstat (limited to 'rules/headers_checks.lua')
-rw-r--r--rules/headers_checks.lua1174
1 files changed, 1174 insertions, 0 deletions
diff --git a/rules/headers_checks.lua b/rules/headers_checks.lua
new file mode 100644
index 0000000..92ebb0c
--- /dev/null
+++ b/rules/headers_checks.lua
@@ -0,0 +1,1174 @@
+--[[
+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 util = require "rspamd_util"
+local ipairs = ipairs
+local pairs = pairs
+local table = table
+local tostring = tostring
+local tonumber = tonumber
+local fun = require "fun"
+local E = {}
+
+local rcvd_cb_id = rspamd_config:register_symbol {
+ name = 'CHECK_RECEIVED',
+ type = 'callback',
+ score = 0.0,
+ group = 'headers',
+ callback = function(task)
+ local cnts = {
+ [1] = 'ONE',
+ [2] = 'TWO',
+ [3] = 'THREE',
+ [5] = 'FIVE',
+ [7] = 'SEVEN',
+ [12] = 'TWELVE'
+ }
+ local def = 'ZERO'
+ local received = task:get_received_headers()
+ local nreceived = fun.reduce(function(acc, rcvd)
+ return acc + 1
+ end, 0, fun.filter(function(h)
+ return not h['flags']['artificial']
+ end, received))
+
+ for k, v in pairs(cnts) do
+ if nreceived >= tonumber(k) then
+ def = v
+ end
+ end
+
+ task:insert_result('RCVD_COUNT_' .. def, 1.0, tostring(nreceived))
+ end
+}
+
+rspamd_config:register_symbol {
+ name = 'RCVD_COUNT_ZERO',
+ score = 0.0,
+ parent = rcvd_cb_id,
+ type = 'virtual',
+ description = 'Message has no Received headers',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'RCVD_COUNT_ONE',
+ score = 0.0,
+ parent = rcvd_cb_id,
+ type = 'virtual',
+ description = 'Message has one Received header',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'RCVD_COUNT_TWO',
+ score = 0.0,
+ parent = rcvd_cb_id,
+ type = 'virtual',
+ description = 'Message has two Received headers',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'RCVD_COUNT_THREE',
+ score = 0.0,
+ parent = rcvd_cb_id,
+ type = 'virtual',
+ description = 'Message has 3-5 Received headers',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'RCVD_COUNT_FIVE',
+ score = 0.0,
+ parent = rcvd_cb_id,
+ type = 'virtual',
+ description = 'Message has 5-7 Received headers',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'RCVD_COUNT_SEVEN',
+ score = 0.0,
+ parent = rcvd_cb_id,
+ type = 'virtual',
+ description = 'Message has 7-11 Received headers',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'RCVD_COUNT_TWELVE',
+ score = 0.0,
+ parent = rcvd_cb_id,
+ type = 'virtual',
+ description = 'Message has 12 or more Received headers',
+ group = 'headers',
+}
+
+local prio_cb_id = rspamd_config:register_symbol {
+ name = 'HAS_X_PRIO',
+ type = 'callback',
+ description = 'X-Priority check callback rule',
+ score = 0.0,
+ group = 'headers',
+ callback = function(task)
+ local cnts = {
+ [1] = 'ONE',
+ [2] = 'TWO',
+ [3] = 'THREE',
+ [5] = 'FIVE',
+ }
+ local def = 'ZERO'
+ local xprio = task:get_header('X-Priority');
+ if not xprio then
+ return false
+ end
+ local _, _, x = xprio:find('^%s?(%d+)');
+ if (x) then
+ x = tonumber(x)
+ for k, v in pairs(cnts) do
+ if x >= tonumber(k) then
+ def = v
+ end
+ end
+ task:insert_result('HAS_X_PRIO_' .. def, 1.0, tostring(x))
+ end
+ end
+}
+rspamd_config:register_symbol {
+ name = 'HAS_X_PRIO_ZERO',
+ score = 0.0,
+ parent = prio_cb_id,
+ type = 'virtual',
+ description = 'Message has X-Priority header set to 0',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'HAS_X_PRIO_ONE',
+ score = 0.0,
+ parent = prio_cb_id,
+ type = 'virtual',
+ description = 'Message has X-Priority header set to 1',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'HAS_X_PRIO_TWO',
+ score = 0.0,
+ parent = prio_cb_id,
+ type = 'virtual',
+ description = 'Message has X-Priority header set to 2',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'HAS_X_PRIO_THREE',
+ score = 0.0,
+ parent = prio_cb_id,
+ type = 'virtual',
+ description = 'Message has X-Priority header set to 3 or 4',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'HAS_X_PRIO_FIVE',
+ score = 0.0,
+ parent = prio_cb_id,
+ type = 'virtual',
+ description = 'Message has X-Priority header set to 5 or higher',
+ group = 'headers',
+}
+
+local function get_raw_header(task, name)
+ return ((task:get_header_full(name) or {})[1] or {})['value']
+end
+
+local check_replyto_id = rspamd_config:register_symbol({
+ type = 'callback',
+ name = 'CHECK_REPLYTO',
+ score = 0.0,
+ group = 'headers',
+ callback = function(task)
+ local replyto = get_raw_header(task, 'Reply-To')
+ if not replyto then
+ return false
+ end
+ local rt = util.parse_mail_address(replyto, task:get_mempool())
+ if not (rt and rt[1] and (string.len(rt[1].addr) > 0)) then
+ task:insert_result('REPLYTO_UNPARSEABLE', 1.0)
+ return false
+ else
+ local rta = rt[1].addr
+ task:insert_result('HAS_REPLYTO', 1.0, rta)
+ -- Check if Reply-To address starts with title seen in display name
+ local sym = task:get_symbol('FROM_NAME_HAS_TITLE')
+ local title = (((sym or E)[1] or E).options or E)[1]
+ if title then
+ rta = rta:lower()
+ if rta:find('^' .. title) then
+ task:insert_result('REPLYTO_EMAIL_HAS_TITLE', 1.0)
+ end
+ end
+ end
+
+ -- See if Reply-To matches From in some way
+ local from = task:get_from { 'mime', 'orig' }
+ local from_h = get_raw_header(task, 'From')
+ if not (from and from[1]) then
+ return false
+ end
+ if (from_h and from_h == replyto) then
+ -- From and Reply-To are identical
+ task:insert_result('REPLYTO_EQ_FROM', 1.0)
+ else
+ if (from and from[1]) then
+ -- See if From and Reply-To addresses match
+ if (util.strequal_caseless(from[1].addr, rt[1].addr)) then
+ task:insert_result('REPLYTO_ADDR_EQ_FROM', 1.0)
+ elseif from[1].domain and rt[1].domain then
+ if (util.strequal_caseless(from[1].domain, rt[1].domain)) then
+ task:insert_result('REPLYTO_DOM_EQ_FROM_DOM', 1.0)
+ else
+ -- See if Reply-To matches the To address
+ local to = task:get_recipients(2)
+ if (to and to[1] and to[1].addr:lower() == rt[1].addr:lower()) then
+ -- Ignore this for mailing-lists and automatic submissions
+ if (not (task:get_header('List-Unsubscribe') or
+ task:get_header('X-To-Get-Off-This-List') or
+ task:get_header('X-List') or
+ task:get_header('Auto-Submitted')))
+ then
+ task:insert_result('REPLYTO_EQ_TO_ADDR', 1.0)
+ end
+ else
+ task:insert_result('REPLYTO_DOM_NEQ_FROM_DOM', 1.0)
+ end
+ end
+ end
+ -- See if the Display Names match
+ if (from[1].name and rt[1].name and
+ util.strequal_caseless(from[1].name, rt[1].name)) then
+ task:insert_result('REPLYTO_DN_EQ_FROM_DN', 1.0)
+ end
+ end
+ end
+ end
+})
+
+rspamd_config:register_symbol {
+ name = 'REPLYTO_UNPARSEABLE',
+ score = 1.0,
+ parent = check_replyto_id,
+ type = 'virtual',
+ description = 'Reply-To header could not be parsed',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'HAS_REPLYTO',
+ score = 0.0,
+ parent = check_replyto_id,
+ type = 'virtual',
+ description = 'Has Reply-To header',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'REPLYTO_EQ_FROM',
+ score = 0.0,
+ parent = check_replyto_id,
+ type = 'virtual',
+ description = 'Reply-To header is identical to From header',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'REPLYTO_ADDR_EQ_FROM',
+ score = 0.0,
+ parent = check_replyto_id,
+ type = 'virtual',
+ description = 'Reply-To header is identical to SMTP From',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'REPLYTO_DOM_EQ_FROM_DOM',
+ score = 0.0,
+ parent = check_replyto_id,
+ type = 'virtual',
+ description = 'Reply-To domain matches the From domain',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'REPLYTO_DOM_NEQ_FROM_DOM',
+ score = 0.0,
+ parent = check_replyto_id,
+ type = 'virtual',
+ description = 'Reply-To domain does not match the From domain',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'REPLYTO_DN_EQ_FROM_DN',
+ score = 0.0,
+ parent = check_replyto_id,
+ type = 'virtual',
+ description = 'Reply-To display name matches From',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'REPLYTO_EMAIL_HAS_TITLE',
+ score = 2.0,
+ parent = check_replyto_id,
+ type = 'virtual',
+ description = 'Reply-To header has title',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'REPLYTO_EQ_TO_ADDR',
+ score = 5.0,
+ parent = check_replyto_id,
+ type = 'virtual',
+ description = 'Reply-To is the same as the To address',
+ group = 'headers',
+}
+
+rspamd_config:register_dependency('CHECK_REPLYTO', 'CHECK_FROM')
+
+local check_mime_id = rspamd_config:register_symbol {
+ name = 'CHECK_MIME',
+ type = 'callback',
+ group = 'headers',
+ score = 0.0,
+ callback = function(task)
+ -- Check if there is a MIME-Version header
+ local missing_mime = false
+ if not task:has_header('MIME-Version') then
+ missing_mime = true
+ end
+
+ -- Check presence of MIME specific headers
+ local has_ct_header = task:has_header('Content-Type')
+ local has_cte_header = task:has_header('Content-Transfer-Encoding')
+
+ -- Add the symbol if we have MIME headers, but no MIME-Version
+ -- (do not add the symbol for RFC822 messages)
+ if (has_ct_header or has_cte_header) and missing_mime then
+ task:insert_result('MISSING_MIME_VERSION', 1.0)
+ end
+
+ local found_ma = false
+ local found_plain = false
+ local found_html = false
+
+ for _, p in ipairs(task:get_parts()) do
+ local mtype, subtype = p:get_type()
+ local ctype = mtype:lower() .. '/' .. subtype:lower()
+ if (ctype == 'multipart/alternative') then
+ found_ma = true
+ end
+ if (ctype == 'text/plain') then
+ found_plain = true
+ end
+ if (ctype == 'text/html') then
+ found_html = true
+ end
+ end
+
+ if (found_ma) then
+ if (not found_plain) then
+ task:insert_result('MIME_MA_MISSING_TEXT', 1.0)
+ end
+ if (not found_html) then
+ task:insert_result('MIME_MA_MISSING_HTML', 1.0)
+ end
+ end
+ end
+}
+
+rspamd_config:register_symbol {
+ name = 'MISSING_MIME_VERSION',
+ score = 2.0,
+ parent = check_mime_id,
+ type = 'virtual',
+ description = 'MIME-Version header is missing in MIME message',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'MIME_MA_MISSING_TEXT',
+ score = 2.0,
+ parent = check_mime_id,
+ type = 'virtual',
+ description = 'MIME multipart/alternative missing text/plain part',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'MIME_MA_MISSING_HTML',
+ score = 1.0,
+ parent = check_mime_id,
+ type = 'virtual',
+ description = 'MIME multipart/alternative missing text/html part',
+ group = 'headers',
+}
+
+-- Used to be called IS_LIST
+rspamd_config.PREVIOUSLY_DELIVERED = {
+ callback = function(task)
+ if not task:has_recipients(2) then
+ return false
+ end
+ local to = task:get_recipients(2)
+ local rcvds = task:get_header_full('Received')
+ if not rcvds then
+ return false
+ end
+ for _, rcvd in ipairs(rcvds) do
+ local _, _, addr = rcvd['decoded']:lower():find("%sfor%s<(.-)>")
+ if addr then
+ for _, toa in ipairs(to) do
+ if toa and toa.addr:lower() == addr then
+ return true, addr
+ end
+ end
+ return false
+ end
+ end
+ end,
+ description = 'Message either to a list or was forwarded',
+ group = 'headers',
+ score = 0.0
+}
+rspamd_config.BROKEN_HEADERS = {
+ callback = function(task)
+ return task:has_flag('broken_headers')
+ end,
+ score = 10.0,
+ group = 'headers',
+ description = 'Headers structure is likely broken'
+}
+
+rspamd_config.BROKEN_CONTENT_TYPE = {
+ callback = function(task)
+ return fun.any(function(p)
+ return p:is_broken()
+ end,
+ task:get_parts())
+ end,
+ score = 1.5,
+ group = 'headers',
+ description = 'Message has part with broken content type'
+}
+
+rspamd_config.HEADER_RCONFIRM_MISMATCH = {
+ callback = function(task)
+ local header_from = nil
+ local cread = task:get_header('X-Confirm-Reading-To')
+
+ if task:has_from('mime') then
+ header_from = task:get_from('mime')[1]
+ end
+
+ local header_cread = nil
+ if cread then
+ local headers_cread = util.parse_mail_address(cread, task:get_mempool())
+ if headers_cread then
+ header_cread = headers_cread[1]
+ end
+ end
+
+ if header_from and header_cread then
+ if not string.find(header_from['addr'], header_cread['addr']) then
+ return true
+ end
+ end
+
+ return false
+ end,
+
+ score = 2.0,
+ group = 'headers',
+ description = 'Read confirmation address is different to from address'
+}
+
+rspamd_config.HEADER_FORGED_MDN = {
+ callback = function(task)
+ local mdn = task:get_header('Disposition-Notification-To')
+ if not mdn then
+ return false
+ end
+ local header_rp = nil
+
+ if task:has_from('smtp') then
+ header_rp = task:get_from('smtp')[1]
+ end
+
+ -- Parse mail addr
+ local headers_mdn = util.parse_mail_address(mdn, task:get_mempool())
+
+ if headers_mdn and not header_rp then
+ return true
+ end
+ if header_rp and not headers_mdn then
+ return false
+ end
+ if not headers_mdn and not header_rp then
+ return false
+ end
+
+ local found_match = false
+ for _, h in ipairs(headers_mdn) do
+ if util.strequal_caseless(h['addr'], header_rp['addr']) then
+ found_match = true
+ break
+ end
+ end
+
+ return (not found_match)
+ end,
+
+ score = 2.0,
+ group = 'headers',
+ description = 'Read confirmation address is different to return path'
+}
+
+local headers_unique = {
+ ['Content-Type'] = 1.0,
+ ['Content-Transfer-Encoding'] = 1.0,
+ -- https://tools.ietf.org/html/rfc5322#section-3.6
+ ['Date'] = 0.1,
+ ['From'] = 1.0,
+ ['Sender'] = 1.0,
+ ['Reply-To'] = 1.0,
+ ['To'] = 0.2,
+ ['Cc'] = 0.1,
+ ['Bcc'] = 0.1,
+ ['Message-ID'] = 0.7,
+ ['In-Reply-To'] = 0.7,
+ ['References'] = 0.3,
+ ['Subject'] = 0.7
+}
+
+local multiple_unique_headers_id = rspamd_config:register_symbol {
+ name = 'MULTIPLE_UNIQUE_HEADERS',
+ callback = function(task)
+ local res = 0
+ local max_mult = 0.0
+ local res_tbl = {}
+ local found = 0
+
+ for hdr, mult in pairs(headers_unique) do
+ local hc = task:get_header_count(hdr)
+ found = found + hc
+
+ if hc > 1 then
+ res = res + 1
+ table.insert(res_tbl, hdr)
+ if max_mult < mult then
+ max_mult = mult
+ end
+ end
+ end
+
+ if res > 0 then
+ task:insert_result('MULTIPLE_UNIQUE_HEADERS', max_mult, table.concat(res_tbl, ','))
+ elseif found == 0 then
+ task:insert_result('MISSING_ESSENTIAL_HEADERS', 1.0)
+ end
+ end,
+
+ score = 7.0,
+ group = 'headers',
+ one_shot = true,
+ description = 'Repeated unique headers'
+}
+
+rspamd_config:register_symbol {
+ name = 'MISSING_ESSENTIAL_HEADERS',
+ score = 7.0,
+ group = 'blankspam',
+ parent = multiple_unique_headers_id,
+ type = 'virtual',
+ description = 'Common headers were entirely absent',
+}
+
+rspamd_config.MISSING_FROM = {
+ callback = function(task)
+ local from = task:get_header('From')
+ if from == nil or from == '' then
+ return true
+ end
+ return false
+ end,
+ score = 2.0,
+ group = 'headers',
+ description = 'Missing From header'
+}
+
+rspamd_config.MULTIPLE_FROM = {
+ callback = function(task)
+ local from = task:get_from('mime')
+ if from and from[2] then
+ return true, 1.0, fun.totable(fun.map(function(a)
+ return a.raw
+ end, from))
+ end
+ return false
+ end,
+ score = 8.0,
+ group = 'headers',
+ description = 'Multiple addresses in From header'
+}
+
+rspamd_config.MV_CASE = {
+ callback = function(task)
+ return task:has_header('Mime-Version', true)
+ end,
+ description = 'Mime-Version .vs. MIME-Version',
+ score = 0.5,
+ group = 'headers'
+}
+
+local check_from_id = rspamd_config:register_symbol {
+ name = 'CHECK_FROM',
+ type = 'callback',
+ score = 0.0,
+ group = 'headers',
+ callback = function(task)
+ local envfrom = task:get_from(1)
+ local from = task:get_from(2)
+ if (envfrom and envfrom[1] and not envfrom[1]["flags"]["valid"]) then
+ task:insert_result('ENVFROM_INVALID', 1.0)
+ end
+ if (from and from[1]) then
+ if not (from[1]["flags"]["valid"]) then
+ task:insert_result('FROM_INVALID', 1.0)
+ end
+ if (from[1].name == nil or from[1].name == '') then
+ task:insert_result('FROM_NO_DN', 1.0)
+ elseif (from[1].name and
+ util.strequal_caseless(from[1].name, from[1].addr)) then
+ task:insert_result('FROM_DN_EQ_ADDR', 1.0)
+ elseif (from[1].name and from[1].name ~= '') then
+ task:insert_result('FROM_HAS_DN', 1.0)
+ -- Look for Mr/Mrs/Dr titles
+ local n = from[1].name:lower()
+ local match, match_end
+ match, match_end = n:find('^mrs?[%.%s]')
+ if match then
+ task:insert_result('FROM_NAME_HAS_TITLE', 1.0, n:sub(match, match_end - 1))
+ end
+ match, match_end = n:find('^dr[%.%s]')
+ if match then
+ task:insert_result('FROM_NAME_HAS_TITLE', 1.0, n:sub(match, match_end - 1))
+ end
+ -- Check for excess spaces
+ if n:find('%s%s') then
+ task:insert_result('FROM_NAME_EXCESS_SPACE', 1.0)
+ end
+ end
+
+ if envfrom then
+ if util.strequal_caseless(envfrom[1].addr, from[1].addr) then
+ task:insert_result('FROM_EQ_ENVFROM', 1.0)
+ elseif envfrom[1].addr ~= '' then
+ task:insert_result('FROM_NEQ_ENVFROM', 1.0, from[1].addr, envfrom[1].addr)
+ end
+ end
+ end
+
+ local to = task:get_recipients(2)
+ if not (to and to[1] and #to == 1 and from and from[1]) then
+ return false
+ end
+ -- Check if FROM == TO
+ if (util.strequal_caseless(to[1].addr, from[1].addr)) then
+ task:insert_result('TO_EQ_FROM', 1.0)
+ elseif (to[1].domain and from[1].domain and
+ util.strequal_caseless(to[1].domain, from[1].domain))
+ then
+ task:insert_result('TO_DOM_EQ_FROM_DOM', 1.0)
+ end
+ end
+}
+
+rspamd_config:register_symbol {
+ name = 'ENVFROM_INVALID',
+ score = 2.0,
+ group = 'headers',
+ parent = check_from_id,
+ type = 'virtual',
+ description = 'Envelope from does not have a valid format',
+}
+rspamd_config:register_symbol {
+ name = 'FROM_INVALID',
+ score = 2.0,
+ group = 'headers',
+ parent = check_from_id,
+ type = 'virtual',
+ description = 'From header does not have a valid format',
+}
+rspamd_config:register_symbol {
+ name = 'FROM_NO_DN',
+ score = 0.0,
+ group = 'headers',
+ parent = check_from_id,
+ type = 'virtual',
+ description = 'From header does not have a display name',
+}
+rspamd_config:register_symbol {
+ name = 'FROM_DN_EQ_ADDR',
+ score = 1.0,
+ group = 'headers',
+ parent = check_from_id,
+ type = 'virtual',
+ description = 'From header display name is the same as the address',
+}
+rspamd_config:register_symbol {
+ name = 'FROM_HAS_DN',
+ score = 0.0,
+ group = 'headers',
+ parent = check_from_id,
+ type = 'virtual',
+ description = 'From header has a display name',
+}
+rspamd_config:register_symbol {
+ name = 'FROM_NAME_EXCESS_SPACE',
+ score = 1.0,
+ group = 'headers',
+ parent = check_from_id,
+ type = 'virtual',
+ description = 'From header display name contains excess whitespace',
+}
+rspamd_config:register_symbol {
+ name = 'FROM_NAME_HAS_TITLE',
+ score = 1.0,
+ group = 'headers',
+ parent = check_from_id,
+ type = 'virtual',
+ description = 'From header display name has a title (Mr/Mrs/Dr)',
+}
+rspamd_config:register_symbol {
+ name = 'FROM_EQ_ENVFROM',
+ score = 0.0,
+ group = 'headers',
+ parent = check_from_id,
+ type = 'virtual',
+ description = 'From address is the same as the envelope',
+}
+rspamd_config:register_symbol {
+ name = 'FROM_NEQ_ENVFROM',
+ score = 0.0,
+ group = 'headers',
+ parent = check_from_id,
+ type = 'virtual',
+ description = 'From address is different to the envelope',
+}
+rspamd_config:register_symbol {
+ name = 'TO_EQ_FROM',
+ score = 0.0,
+ group = 'headers',
+ parent = check_from_id,
+ type = 'virtual',
+ description = 'To address matches the From address',
+}
+rspamd_config:register_symbol {
+ name = 'TO_DOM_EQ_FROM_DOM',
+ score = 0.0,
+ group = 'headers',
+ parent = check_from_id,
+ type = 'virtual',
+ description = 'To domain is the same as the From domain',
+}
+
+local check_to_cc_id = rspamd_config:register_symbol {
+ name = 'CHECK_TO_CC',
+ type = 'callback',
+ score = 0.0,
+ group = 'headers,mime',
+ callback = function(task)
+ local rcpts = task:get_recipients(1)
+ local to = task:get_recipients(2)
+ local to_match_envrcpt = 0
+ local cnts = {
+ [1] = 'ONE',
+ [2] = 'TWO',
+ [3] = 'THREE',
+ [5] = 'FIVE',
+ [7] = 'SEVEN',
+ [12] = 'TWELVE',
+ [50] = 'GT_50'
+ }
+ local def = 'ZERO'
+ if (not to) then
+ return false
+ end
+ -- Add symbol for recipient count
+ local nrcpt = #to
+ for k, v in pairs(cnts) do
+ if nrcpt >= tonumber(k) then
+ def = v
+ end
+ end
+ task:insert_result('RCPT_COUNT_' .. def, 1.0, tostring(nrcpt))
+ -- Check for display names
+ local to_dn_count = 0
+ local to_dn_eq_addr_count = 0
+ for _, toa in ipairs(to) do
+ -- To: Recipients <noreply@dropbox.com>
+ if (toa['name'] and (toa['name']:lower() == 'recipient'
+ or toa['name']:lower() == 'recipients')) then
+ task:insert_result('TO_DN_RECIPIENTS', 1.0)
+ end
+ if (toa['name'] and util.strequal_caseless(toa['name'], toa['addr'])) then
+ to_dn_eq_addr_count = to_dn_eq_addr_count + 1
+ elseif (toa['name'] and toa['name'] ~= '') then
+ to_dn_count = to_dn_count + 1
+ end
+ -- See if header recipients match envrcpts
+ if (rcpts) then
+ for _, rcpt in ipairs(rcpts) do
+ if (toa and toa['addr'] and rcpt and rcpt['addr'] and
+ util.strequal_caseless(rcpt['addr'], toa['addr']))
+ then
+ to_match_envrcpt = to_match_envrcpt + 1
+ end
+ end
+ end
+ end
+ if (to_dn_count == 0 and to_dn_eq_addr_count == 0) then
+ task:insert_result('TO_DN_NONE', 1.0)
+ elseif (to_dn_count == #to) then
+ task:insert_result('TO_DN_ALL', 1.0)
+ elseif (to_dn_count > 0) then
+ task:insert_result('TO_DN_SOME', 1.0)
+ end
+ if (to_dn_eq_addr_count == #to) then
+ task:insert_result('TO_DN_EQ_ADDR_ALL', 1.0)
+ elseif (to_dn_eq_addr_count > 0) then
+ task:insert_result('TO_DN_EQ_ADDR_SOME', 1.0)
+ end
+
+ -- See if header recipients match envelope recipients
+ if (to_match_envrcpt == #to) then
+ task:insert_result('TO_MATCH_ENVRCPT_ALL', 1.0)
+ elseif (to_match_envrcpt > 0) then
+ task:insert_result('TO_MATCH_ENVRCPT_SOME', 1.0)
+ end
+ end
+}
+
+rspamd_config:register_symbol {
+ name = 'RCPT_COUNT_ZERO',
+ score = 0.0,
+ parent = check_to_cc_id,
+ type = 'virtual',
+ description = 'No recipients',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'RCPT_COUNT_ONE',
+ score = 0.0,
+ parent = check_to_cc_id,
+ type = 'virtual',
+ description = 'One recipient',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'RCPT_COUNT_TWO',
+ score = 0.0,
+ parent = check_to_cc_id,
+ type = 'virtual',
+ description = 'Two recipients',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'RCPT_COUNT_THREE',
+ score = 0.0,
+ parent = check_to_cc_id,
+ type = 'virtual',
+ description = '3-5 recipients',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'RCPT_COUNT_FIVE',
+ score = 0.0,
+ parent = check_to_cc_id,
+ type = 'virtual',
+ description = '5-7 recipients',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'RCPT_COUNT_SEVEN',
+ score = 0.0,
+ parent = check_to_cc_id,
+ type = 'virtual',
+ description = '7-11 recipients',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'RCPT_COUNT_TWELVE',
+ score = 0.0,
+ parent = check_to_cc_id,
+ type = 'virtual',
+ description = '12-50 recipients',
+ group = 'headers',
+}
+rspamd_config:register_symbol {
+ name = 'RCPT_COUNT_GT_50',
+ score = 0.0,
+ parent = check_to_cc_id,
+ type = 'virtual',
+ description = '50+ recipients',
+ group = 'headers',
+}
+
+rspamd_config:register_symbol {
+ name = 'TO_DN_RECIPIENTS',
+ score = 2.0,
+ group = 'headers',
+ parent = check_to_cc_id,
+ type = 'virtual',
+ description = 'To header display name is "Recipients"',
+}
+rspamd_config:register_symbol {
+ name = 'TO_DN_NONE',
+ score = 0.0,
+ group = 'headers',
+ parent = check_to_cc_id,
+ type = 'virtual',
+ description = 'None of the recipients have display names',
+}
+rspamd_config:register_symbol {
+ name = 'TO_DN_ALL',
+ score = 0.0,
+ group = 'headers',
+ parent = check_to_cc_id,
+ type = 'virtual',
+ description = 'All the recipients have display names',
+}
+rspamd_config:register_symbol {
+ name = 'TO_DN_SOME',
+ score = 0.0,
+ group = 'headers',
+ parent = check_to_cc_id,
+ type = 'virtual',
+ description = 'Some of the recipients have display names',
+}
+rspamd_config:register_symbol {
+ name = 'TO_DN_EQ_ADDR_ALL',
+ score = 0.0,
+ group = 'headers',
+ parent = check_to_cc_id,
+ type = 'virtual',
+ description = 'All of the recipients have display names that are the same as their address',
+}
+rspamd_config:register_symbol {
+ name = 'TO_DN_EQ_ADDR_SOME',
+ score = 0.0,
+ group = 'headers',
+ parent = check_to_cc_id,
+ type = 'virtual',
+ description = 'Some of the recipients have display names that are the same as their address',
+}
+rspamd_config:register_symbol {
+ name = 'TO_MATCH_ENVRCPT_ALL',
+ score = 0.0,
+ group = 'headers',
+ parent = check_to_cc_id,
+ type = 'virtual',
+ description = 'All of the recipients match the envelope',
+}
+rspamd_config:register_symbol {
+ name = 'TO_MATCH_ENVRCPT_SOME',
+ score = 0.0,
+ group = 'headers',
+ parent = check_to_cc_id,
+ type = 'virtual',
+ description = 'Some of the recipients match the envelope',
+}
+
+-- TODO: rewrite this rule, it should not touch headers directly
+rspamd_config.CTYPE_MISSING_DISPOSITION = {
+ callback = function(task)
+ local parts = task:get_parts()
+ if (not parts) or (parts and #parts < 1) then
+ return false
+ end
+ for _, p in ipairs(parts) do
+ local ct = p:get_header('Content-Type')
+ if (ct and ct:lower():match('^application/octet%-stream') ~= nil) then
+ local cd = p:get_header('Content-Disposition')
+ if (not cd) or (cd and cd:lower():find('^attachment') == nil) then
+ local ci = p:get_header('Content-ID')
+ if ci or (#parts > 1 and (cd and cd:find('filename=.+%.asc') ~= nil))
+ then
+ return false
+ end
+
+ local parent = p:get_parent()
+
+ if parent then
+ local t, st = parent:get_type()
+
+ if t == 'multipart' and st == 'encrypted' then
+ -- Special case
+ return false
+ end
+ end
+
+ return true
+ end
+ end
+ end
+ return false
+ end,
+ description = 'Binary content-type not specified as an attachment',
+ score = 4.0,
+ group = 'mime'
+}
+
+rspamd_config.CTYPE_MIXED_BOGUS = {
+ callback = function(task)
+ local ct = task:get_header('Content-Type')
+ if (not ct) then
+ return false
+ end
+ local parts = task:get_parts()
+ if (not parts) then
+ return false
+ end
+ if (not ct:lower():match('^multipart/mixed')) then
+ return false
+ end
+ local found = false
+ -- Check each part and look for a part that isn't multipart/* or text/plain or text/html
+ local ntext_parts = 0
+ for _, p in ipairs(parts) do
+ local mtype, _ = p:get_type()
+ if mtype then
+ if mtype == 'text' and not p:is_attachment() then
+ ntext_parts = ntext_parts + 1
+ if ntext_parts > 2 then
+ found = true
+ break
+ end
+ elseif mtype ~= 'multipart' then
+ found = true
+ break
+ end
+ end
+ end
+ if (not found) then
+ return true
+ end
+ return false
+ end,
+ description = 'multipart/mixed without non-textual part',
+ score = 1.0,
+ group = 'mime'
+}
+
+local function check_for_base64_text(part)
+ local ct = part:get_header('Content-Type')
+ if (not ct) then
+ return false
+ end
+ ct = ct:lower()
+ if (ct:match('^text')) then
+ -- Check encoding
+ local cte = part:get_header('Content-Transfer-Encoding')
+ if (cte and cte:lower():match('^base64')) then
+ return true
+ end
+ end
+ return false
+end
+
+rspamd_config.MIME_BASE64_TEXT = {
+ callback = function(task)
+ -- Check outer part
+ if (check_for_base64_text(task)) then
+ return true
+ else
+ local parts = task:get_parts()
+ if (not parts) then
+ return false
+ end
+ -- Check each part and look for base64 encoded text parts
+ for _, part in ipairs(parts) do
+ if (check_for_base64_text(part)) then
+ return true
+ end
+ end
+ end
+ return false
+ end,
+ description = 'Has text part encoded in base64',
+ score = 0.1,
+ group = 'mime'
+}
+
+rspamd_config.MIME_BASE64_TEXT_BOGUS = {
+ callback = function(task)
+ local parts = task:get_text_parts()
+ if (not parts) then
+ return false
+ end
+ -- Check each part and look for base64 encoded text parts
+ -- where the part does not have any 8bit characters within it
+ for _, part in ipairs(parts) do
+ local mimepart = part:get_mimepart();
+ if (check_for_base64_text(mimepart) and not part:has_8bit()) then
+ return true
+ end
+ end
+ return false
+ end,
+ description = 'Has text part encoded in base64 that does not contain any 8bit characters',
+ score = 1.0,
+ group = 'mime'
+}
+
+local function is_8bit_addr(addr)
+ if addr.flags and addr.flags['8bit'] then
+ return true
+ end
+
+ return false;
+end
+
+rspamd_config.INVALID_FROM_8BIT = {
+ callback = function(task)
+ local from = (task:get_from('mime') or {})[1] or {}
+ if is_8bit_addr(from) then
+ return true
+ end
+ return false
+ end,
+ description = 'Invalid 8bit character in From header',
+ score = 6.0,
+ group = 'headers'
+}
+
+rspamd_config.INVALID_RCPT_8BIT = {
+ callback = function(task)
+ local rcpts = task:get_recipients('mime') or {}
+ return fun.any(function(rcpt)
+ if is_8bit_addr(rcpt) then
+ return true
+ end
+ return false
+ end, rcpts)
+ end,
+ description = 'Invalid 8bit character in recipients headers',
+ score = 6.0,
+ group = 'headers'
+}
+
+rspamd_config.XM_CASE = {
+ callback = function(task)
+ return task:has_header('X-mailer', true)
+ end,
+ description = 'X-mailer .vs. X-Mailer',
+ score = 0.5,
+ group = 'headers'
+}