UNPKG

haraka-plugin-rspamd

Version:
545 lines (466 loc) 16.2 kB
'use strict' // node built-ins const fs = require('node:fs') const http = require('node:http') const https = require('node:https') // haraka libs const DSN = require('haraka-dsn') exports.register = function () { this.load_rspamd_ini() // explicit hook (not magic hook_data_post) so the plugin can be inherited; // don't rename. guarded so inheritors don't re-register. haraka/Haraka#3604 if (this.name === 'rspamd') this.register_hook('data_post', 'rspamd_data_post') } const INI_BOOLEANS = [ '-check.authenticated', '+dkim.enabled', '-check.private_ip', '-check.local_ip', '-check.relay', '+reject.spam', '-reject.authenticated', '+rewrite_subject.enabled', '+rmilter_headers.enabled', '+soft_reject.enabled', '+smtp_message.enabled', '-defer.error', '-defer.timeout', '-request.pass_all', '-request.body_block', '-request.groups', '-request.milter', '-request.no_log', '-request.profile', '-request.skip', '-request.skip_process', '-request.zstd', '-request.ext_urls', '-request.raw', '+tls.reject_unauthorized', ] exports.load_rspamd_ini = function () { this.cfg = this.config.get('rspamd.ini', { booleans: INI_BOOLEANS }, () => this.load_rspamd_ini(), ) this.cfg.reject.message ??= 'Detected as spam' this.cfg.soft_reject.message ??= 'Deferred by policy' this.cfg.spambar ??= { positive: '+', negative: '-', neutral: '/' } this.cfg.main.host ??= 'localhost' this.cfg.main.port ??= 11333 this.cfg.main.path ??= '/checkv2' this.cfg.main.scheme ??= 'http' this.cfg.subject ??= '[SPAM] %s' this.cfg.main.add_headers ??= this.cfg.main.always_add_headers === true ? 'always' : 'sometimes' this.cfg.tls ??= {} this.cfg.tls.reject_unauthorized ??= true this.cfg.request ??= {} } exports.get_options = function (connection) { // https://docs.rspamd.com/developers/protocol // https://github.com/rspamd/rspamd/blob/master/rules/headers_checks.lua const options = { headers: {}, path: this.cfg.main.path, method: 'POST' } set_transport(options, this.cfg) set_protocol(options, this.cfg) set_auth(options, connection) set_remote(options, connection) set_spf(options, connection) set_envelope(options, connection) set_tls(options, connection) set_upstream_auth(options, this.cfg) set_custom_headers(options, this.cfg) return options } function set_transport(options, cfg) { if (cfg.main.unix_socket) { options.socketPath = cfg.main.unix_socket return } options.host = cfg.main.host options.port = cfg.main.port if (get_scheme(cfg) === 'https') { options.protocol = 'https:' set_https_transport(options, cfg) } else { options.protocol = 'http:' } } function get_scheme(cfg) { const scheme = cfg.main.scheme?.toLowerCase() return scheme === 'https' ? 'https' : 'http' } function set_https_transport(options, cfg) { const tls = cfg.tls ?? {} options.rejectUnauthorized = tls.reject_unauthorized !== false if (tls.servername) options.servername = tls.servername if (tls.ca_file) options.ca = fs.readFileSync(tls.ca_file) if (tls.cert_file) options.cert = fs.readFileSync(tls.cert_file) if (tls.key_file) options.key = fs.readFileSync(tls.key_file) } function set_protocol(options, cfg) { const req = cfg.request ?? {} if (req.settings_id) options.headers['Settings-ID'] = req.settings_id if (req.settings) options.headers.Settings = req.settings if (req.pass_all) options.headers.Pass = 'all' if (req.raw) options.headers.Raw = 'yes' const flags = get_flags(req) if (flags.length) options.headers.Flags = flags.join(',') if (req.url_format) options.headers['URL-Format'] = req.url_format } function get_flags(req) { const flags = new Set(parse_flags(req.flags)) const bool_flags = { body_block: req.body_block, ext_urls: req.ext_urls, groups: req.groups, milter: req.milter, no_log: req.no_log, profile: req.profile, skip: req.skip, skip_process: req.skip_process, zstd: req.zstd, } for (const [flag, enabled] of Object.entries(bool_flags)) { if (enabled) flags.add(flag) } return [...flags] } function parse_flags(raw) { if (Array.isArray(raw)) return raw.map((v) => `${v}`.trim()).filter((v) => v) if (typeof raw !== 'string') return [] return raw .split(',') .map((v) => v.trim()) .filter((v) => v) } function set_upstream_auth(options, cfg) { const auth = cfg.auth ?? {} if (auth.basic_user) { const auth_pass = auth.basic_pass ?? '' const token = Buffer.from(`${auth.basic_user}:${auth_pass}`).toString( 'base64', ) options.headers.Authorization = `Basic ${token}` } if (!auth.header) return const env_value = auth.value_env ? process.env[auth.value_env] : undefined const header_value = env_value ?? auth.value if (header_value) options.headers[auth.header] = header_value } function set_custom_headers(options, cfg) { const headers = cfg.request_headers if (!headers || typeof headers !== 'object') return for (const [key, value] of Object.entries(headers)) { if (!value) continue options.headers[key] = `${value}` } } function set_auth(options, connection) { if (connection.notes.auth_user) options.headers.User = connection.notes.auth_user } function set_remote(options, connection) { if (connection.remote.ip) options.headers.IP = connection.remote.ip const fcrdns = connection.results.get('fcrdns') const host = fcrdns?.fcrdns?.[0] ?? connection.remote.host if (host) options.headers.Hostname = host if (connection.hello.host) options.headers.Helo = connection.hello.host } function set_spf(options, connection) { const spf = connection.transaction.results.get('spf') ?? connection.results.get('spf') if (spf?.result) options.headers.SPF = { result: spf.result.toLowerCase() } } function set_envelope(options, connection) { const txn = connection.transaction const from = txn.mail_from?.address?.toString() if (from) options.headers.From = from const rcpts = txn.rcpt_to if (rcpts?.length) { options.headers.Rcpt = rcpts.map((r) => r.address) // for per-user options if (rcpts.length === 1) options.headers['Deliver-To'] = options.headers.Rcpt[0] } if (txn.uuid) options.headers['Queue-Id'] = txn.uuid } function set_tls(options, connection) { if (!connection.tls.enabled) return options.headers['TLS-Cipher'] = connection.tls.cipher.name options.headers['TLS-Version'] = connection.tls.cipher.version } exports.get_smtp_message = function (r) { if (!this.cfg.smtp_message.enabled) return const messages = r?.data?.messages if (!messages || typeof messages !== 'object') return return messages.smtp_message } exports.do_rewrite = function (connection, data) { if (!this.cfg.rewrite_subject.enabled) return false if (data.action !== 'rewrite subject') return false const rspamd_subject = data.subject || this.cfg.subject const old_subject = connection.transaction.header.get('Subject') || '' const new_subject = rspamd_subject.replace('%s', old_subject) connection.transaction.remove_header('Subject') connection.transaction.add_header('Subject', new_subject) } exports.add_dkim_header = function (connection, data) { if (!this.cfg.dkim.enabled) return if (!data['dkim-signature']) return connection.transaction.add_header('DKIM-Signature', data['dkim-signature']) } exports.do_milter_headers = function (connection, data) { if (!this.cfg.rmilter_headers.enabled) return if (!data?.milter) return const { remove_headers, add_headers } = data.milter const txn = connection.transaction if (remove_headers) { for (const key of Object.keys(remove_headers)) txn.remove_header(key) } if (!add_headers) return try { connection.logdebug( this, `milter.add_headers: ${JSON.stringify(add_headers)}`, ) for (const [key, value] of Object.entries(add_headers)) { if (value == null) continue if (Array.isArray(value)) { for (const v of value) add_milter_value(txn, key, v) } else { add_milter_value(txn, key, value) } } } catch (err) { connection.logerror(this, `milter.add_headers error: ${err}`) } } function add_milter_value(txn, key, value) { if (value && typeof value === 'object') { txn.add_header(key, value.value) } else { txn.add_header(key, value) } } exports.get_request_client = function (options) { if (options.socketPath) return http return options.protocol === 'https:' ? https : http } exports.rspamd_data_post = function (next, connection) { const plugin = this if (!connection.transaction) return next() if (!plugin.should_check(connection)) return next() const start = Date.now() const ctx = make_request_context(plugin, connection, next) ctx.timer = setTimeout( () => on_timeout(plugin, connection, ctx), (plugin.cfg.main.timeout || plugin.timeout - 1) * 1000, ) const options = plugin.get_options(connection) const request_client = plugin.get_request_client(options) ctx.req = request_client.request(options, (res) => { let rawData = '' res.on('data', (chunk) => { rawData += chunk }) res.on('end', () => on_response(plugin, connection, ctx, rawData, start)) }) ctx.req.on('error', (err) => on_request_error(plugin, connection, ctx, err)) connection.transaction.message_stream.pipe(ctx.req) // pipe calls req.end() asynchronously } function make_request_context(plugin, connection, next) { const ctx = { req: null, timer: null, calledNext: false } ctx.nextOnce = (code, msg) => { // unpipe() before destroy() — see haraka/message-stream#22. connection?.transaction?.message_stream?.unpipe() if (ctx.req) ctx.req.destroy() clearTimeout(ctx.timer) if (ctx.calledNext) return ctx.calledNext = true if (!connection?.transaction) return next(code, msg) } return ctx } function on_timeout(plugin, connection, ctx) { if (!connection?.transaction) return connection.transaction.results.add(plugin, { err: 'timeout' }) if (plugin.cfg.defer.timeout) return ctx.nextOnce(DENYSOFT, 'Rspamd scan timeout') ctx.nextOnce() } function on_request_error(plugin, connection, ctx, err) { if (!connection?.transaction) return ctx.nextOnce() // client gone connection.transaction.results.add(plugin, { err: err.message }) if (plugin.cfg.defer.error) return ctx.nextOnce(DENYSOFT, 'Rspamd scan error') ctx.nextOnce() } function on_response(plugin, connection, ctx, rawData, start) { if (!connection.transaction) return ctx.nextOnce() // client gone const r = plugin.parse_response(rawData, connection) if (!r?.data || !r.log) { if (plugin.cfg.defer.error) return ctx.nextOnce(DENYSOFT, 'Rspamd scan error') return ctx.nextOnce() } const action = plugin.handle_rspamd(connection, r, start) ctx.nextOnce(...(action || [])) } // handle a parsed response (annotate + decide action). I/O-free so inheriting // plugins can reuse it; returns next() args ([] = CONT). haraka/Haraka#3604 exports.handle_rspamd = function (connection, r, start) { if (!connection.transaction) return [] r.log.emit = true // spit out a log entry if (start !== undefined) r.log.time = (Date.now() - start) / 1000 connection.transaction.results.add(this, r.log) if (r.data.symbols) connection.transaction.results.add(this, { symbols: r.data.symbols }) this.do_rewrite(connection, r.data) const action = this.decide_action(connection, r) if (action) return action this.add_dkim_header(connection, r.data) this.do_milter_headers(connection, r.data) this.add_headers(connection, r.data) return [] } exports.decide_action = function (connection, r) { const smtp_message = this.get_smtp_message(r) if (this.cfg.soft_reject.enabled && r.data.action === 'soft reject') { return [ DENYSOFT, DSN.sec_unauthorized(smtp_message || this.cfg.soft_reject.message, 451), ] } if (this.wants_reject(connection, r.data)) { return [DENY, smtp_message || this.cfg.reject.message] } return null } exports.should_check = function (connection) { const check = this.cfg.check const remote = connection.remote const skip_rules = [ ['authed', !check.authenticated && connection.notes.auth_user], ['relay', !check.relay && connection.relaying], ['local_ip', !check.local_ip && remote.is_local], // local IPs are a subset of private IPs — don't double-skip [ 'private_ip', !check.private_ip && remote.is_private && !(check.local_ip && remote.is_local), ], ] let result = true for (const [name, should_skip] of skip_rules) { if (!should_skip) continue connection.transaction.results.add(this, { skip: name }) result = false } return result } exports.wants_reject = function (connection, data) { if (data.action !== 'reject') return false const flag = connection.notes.auth_user ? this.cfg.reject.authenticated : this.cfg.reject.spam return flag !== false } exports.wants_headers_added = function (rspamd_data) { if (this.cfg.main.add_headers === 'never') return false if (this.cfg.main.add_headers === 'always') return true // implicit add_headers=sometimes, based on rspamd response if (rspamd_data.action === 'add header') return true return false } const SCALAR_TYPES = new Set(['boolean', 'number', 'string']) const SCALAR_KEYS = ['action', 'is_skipped', 'required_score', 'score'] const COLLECTION_KEYS = ['urls', 'emails', 'messages'] exports.get_clean = function (data, connection) { const clean = { symbols: {} } for (const [key, sym] of Object.entries(data.symbols ?? {})) { // transform { name: KEY, score: VAL } -> { KEY: VAL } if (sym?.name && sym.score !== undefined) { clean.symbols[sym.name] = sym.score } else { connection.logerror(this, sym ?? key) } } for (const key of SCALAR_KEYS) { if (data[key] === undefined) continue if (SCALAR_TYPES.has(typeof data[key])) { clean[key] = data[key] } else { connection.loginfo(this, `skipping unhandled: ${typeof data[key]}`) } } // collapse to comma-separated strings so values get logged for (const key of COLLECTION_KEYS) { const val = data[key] if (!val) continue if (Array.isArray(val)) { clean[key] = val.join(',') } else if (typeof val === 'object') { // dictionary form, e.g. messages: { smtp_message: '…' } clean[key] = Object.entries(val) .map(([k, v]) => `${k} : ${v}`) .join(',') } } return clean } exports.parse_response = function (rawData, connection) { if (!rawData) return let data try { data = JSON.parse(rawData) } catch (err) { connection.transaction.results.add(this, { err: `parse failure: ${err.message}`, }) return } const keys = Object.keys(data) if (keys.length === 0) return if (keys.length === 1 && data.error) { connection.transaction.results.add(this, { err: data.error }) return } return { data, log: this.get_clean(data, connection), } } exports.add_headers = function (connection, data) { if (!this.wants_headers_added(data)) return const { header, spambar } = this.cfg if (!header) return const txn = connection.transaction const values = { bar: make_spam_bar(data.score, spambar), report: format_symbols(data.symbols), score: `${data.score}`, } for (const [key, name] of Object.entries(header)) { if (!name || values[key] === undefined) continue replace_header(txn, name, values[key]) } } function replace_header(txn, name, value) { txn.remove_header(name) txn.add_header(name, value) } function make_spam_bar(score, spambar) { if (score >= 1) return (spambar.positive || '+').repeat(Math.floor(score)) if (score <= -1) return (spambar.negative || '-').repeat(Math.floor(-score)) return spambar.neutral || '/' } function format_symbols(symbols) { const pretty = [] for (const sym of Object.values(symbols ?? {})) { if (sym?.score) pretty.push(`${sym.name}(${sym.score})`) } return pretty.join(' ') }