diff --git a/README.rst b/README.rst index b3ce79d..9e8b273 100644 --- a/README.rst +++ b/README.rst @@ -58,6 +58,7 @@ Key features and capabilities include: * Sieve filtering scripts and ManageSieve service * `MailScript filtering engine `_ for flexible, custom, dynamic mail filtering rules (Sieve alternative) + * Intelligent sender/recipient analysis - prevent yourself from ever sending an email to the wrong people by mistake! * Webmail client backend diff --git a/bbs/stringlist.c b/bbs/stringlist.c index d9f9552..0171a84 100644 --- a/bbs/stringlist.c +++ b/bbs/stringlist.c @@ -107,7 +107,7 @@ void stringlist_empty(struct stringlist *list) RWLIST_UNLOCK(list); } -const char *stringlist_next(struct stringlist *list, struct stringitem **i) +const char *stringlist_next(const struct stringlist *list, struct stringitem **i) { struct stringitem *inext; if (!*i) { @@ -155,6 +155,25 @@ int stringlist_push(struct stringlist *list, const char *s) return 0; } +int stringlist_push_sorted(struct stringlist *list, const char *s) +{ + struct stringitem *i; + char *sdup = strdup(s); + + if (ALLOC_FAILURE(sdup)) { + return -1; + } + + i = calloc(1, sizeof(*i)); + if (ALLOC_FAILURE(i)) { + free(sdup); + return -1; + } + i->s = sdup; + RWLIST_INSERT_SORTALPHA(list, i, entry, s); + return 0; +} + int stringlist_push_tail(struct stringlist *list, const char *s) { struct stringitem *i; diff --git a/configs/modules.conf b/configs/modules.conf index 166c059..af37d7a 100644 --- a/configs/modules.conf +++ b/configs/modules.conf @@ -77,8 +77,9 @@ load = mod_smtp_filter.so load = mod_smtp_filter_arc.so load = mod_smtp_filter_dkim.so load = mod_smtp_filter_dmarc.so -load = mod_smtp_mailing_lists.so load = mod_spamassassin.so +load = mod_smtp_mailing_lists.so +load = mod_smtp_recipient_monitor.so load = mod_webmail.so load = net_imap.so load = net_nntp.so diff --git a/include/net_smtp.h b/include/net_smtp.h index 709ed96..98934f3 100644 --- a/include/net_smtp.h +++ b/include/net_smtp.h @@ -22,6 +22,18 @@ /* Mainly for message submission agents, not encrypted by default, but may use STARTTLS */ #define DEFAULT_SMTP_MSA_PORT 587 +#define _smtp_reply(smtp, fmt, ...) \ + bbs_debug(6, "%p <= " fmt, smtp, ## __VA_ARGS__); \ + bbs_auto_fd_writef(smtp_node(smtp), smtp_node(smtp) ? smtp_node(smtp)->wfd : -1, fmt, ## __VA_ARGS__); \ + +/*! \brief Final SMTP response with this code */ +#define smtp_resp_reply(smtp, code, subcode, reply) _smtp_reply(smtp, "%d %s %s\r\n", code, subcode, reply) +#define smtp_reply(smtp, code, status, fmt, ...) _smtp_reply(smtp, "%d %s " fmt "\r\n", code, #status, ## __VA_ARGS__) +#define smtp_reply_nostatus(smtp, code, fmt, ...) _smtp_reply(smtp, "%d " fmt "\r\n", code, ## __VA_ARGS__) + +/*! \brief Non-final SMTP response (subsequent responses with the same code follow) */ +#define smtp_reply0_nostatus(smtp, code, fmt, ...) _smtp_reply(smtp, "%d-" fmt "\r\n", code, ## __VA_ARGS__) + struct smtp_session; void __attribute__ ((format (gnu_printf, 3, 4))) bbs_smtp_log(int level, struct smtp_session *smtp, const char *fmt, ...); @@ -72,13 +84,33 @@ enum smtp_direction { SMTP_DIRECTION_OUT = (1 << 2), /*!< Outgoing mail to another MTA */ }; +/*! + * \note There are two different "filtering" APIs available, + * based on the smtp_filter_data (filters) and smtp_msg_process (message processors) structures. + * They are very similar and they probably could theoretically be combined. + * That said, there are a few major differences between them as they exist now, + * that should be considered when deciding which one to use. + * + * - The filtering API can modify/rewrite messages. The message processors do not. + * Thus, filtering here is probably also more akin to "milters" in standard POSIX MTAs. + * This is probably the most important difference. Message processors don't (and can't) + * rewrite messages, so they are probably more efficient to run if you don't need to modify the input. + * - Both can reject messages; message processors have more control over how they are rejected, though filters can quarantine the message. + * - Both can operation on incoming and outgoing messages to varying degrees. + * - In practice, message processors are used for "filtering engines" (ironically) - Sieve and MailScript. + * "Filters" are used for more traditional milter applications, such as SPF, DKIM, etc. - things that prepend headers to the message. + * - Other differences exist. Compare the structs and APIs to see what information is available. + */ + +/* == SMTP filter callbacks - these receive a message and potentially modify it == */ + struct smtp_filter_data { struct smtp_session *smtp; /*!< SMTP session */ int inputfd; /*!< File descriptor from which message can be read */ const char *recipient; /*!< Recipient (RCPT TO). Only available for SMTP_DIRECTION_IN and SMTP_DIRECTION_SUBMIT, and if the scope is SMTP_SCOPE_INDIVIDUAL */ size_t size; /*!< Message length */ enum smtp_direction dir; /*!< Direction */ - int received; /*!< Time that message was received */ + time_t received; /*!< Time that message was received */ /* Duplicated fields: these are simply duplicated from smtp: */ struct bbs_node *node; /*!< Node */ const char *from; /*!< Envelope from */ @@ -200,6 +232,7 @@ int smtp_message_quarantinable(struct smtp_session *smtp); struct smtp_msg_process { /* Inputs */ + struct smtp_session *smtp; /*!< SMTP session. Not originally included, so try to avoid using this! */ int fd; /*!< File descriptor of SMTP session */ struct mailbox *mbox; /*!< Mailbox (incoming only) */ struct bbs_user *user; /*!< BBS user (outgoing only) */ @@ -207,9 +240,11 @@ struct smtp_msg_process { const char *datafile; /*!< Name of email data file */ FILE *fp; /*!< Email data file (used internally only) */ const char *from; /*!< Envelope from */ + const struct stringlist *recipients; /*!< All envelope recipients (RCPT TO). Only available for SMTP_SCOPE_COMBINED/SMTP_DIRECTION_SUBMIT, not SMTP_DIRECTION_IN or SMTP_DIRECTION_OUT */ const char *recipient; /*!< Envelope to - only for INCOMING messages */ int size; /*!< Size of email */ - int userid; /*!< User ID (outgoing only) */ + unsigned int userid; /*!< User ID (outgoing only) */ + enum smtp_direction dir; /*!< Full direction (IN, OUT, or SUBMIT) */ unsigned int direction:1; /*!< 0 = incoming, 1 = outgoing */ /* Outputs */ unsigned int bounce:1; /*!< Whether to send a bounce */ diff --git a/include/stringlist.h b/include/stringlist.h index 5af2faf..dbb38bf 100644 --- a/include/stringlist.h +++ b/include/stringlist.h @@ -82,7 +82,7 @@ void stringlist_empty(struct stringlist *list); * \returns Next string, or NULL if end of list reached. * \note The returned string must not be modified, since it remains in the list. */ -const char *stringlist_next(struct stringlist *list, struct stringitem **i); +const char *stringlist_next(const struct stringlist *list, struct stringitem **i); /*! * \brief Pop the most recently added item to a string list @@ -101,6 +101,15 @@ char *stringlist_pop(struct stringlist *list); */ int stringlist_push(struct stringlist *list, const char *s); +/*! + * \brief Add an item to a stringlist, such that the stringlist remains sorted alphabetically + * \param list + * \param s String to add + * \note Assumes list is WRLOCKed + * \retval 0 on success, -1 on failure + */ +int stringlist_push_sorted(struct stringlist *list, const char *s); + /*! * \brief Add an item to the end of a stringlist * \param list diff --git a/modules/mod_smtp_delivery_local.c b/modules/mod_smtp_delivery_local.c index 600cbd7..0a5ccd3 100644 --- a/modules/mod_smtp_delivery_local.c +++ b/modules/mod_smtp_delivery_local.c @@ -252,6 +252,7 @@ static int do_local_delivery(struct smtp_session *smtp, struct smtp_response *re smtp_mproc_init(smtp, &mproc); mproc.size = (int) datalen; mproc.recipient = recip_buf + 1; /* Without <> */ + mproc.dir = SMTP_DIRECTION_IN; mproc.direction = SMTP_MSG_DIRECTION_IN; mproc.mbox = mbox; mproc.userid = 0; diff --git a/modules/mod_smtp_recipient_monitor.c b/modules/mod_smtp_recipient_monitor.c new file mode 100755 index 0000000..3e07c48 --- /dev/null +++ b/modules/mod_smtp_recipient_monitor.c @@ -0,0 +1,238 @@ +/* + * LBBS -- The Lightweight Bulletin Board System + * + * Copyright (C) 2024, Naveen Albert + * + * Naveen Albert + * + * This program is free software, distributed under the terms of + * the GNU General Public License Version 2. See the LICENSE file + * at the top of the source tree. + */ + +/*! \file + * + * \brief Recipient Greylist-Like Monitoring + * + * \description This module is a helpful tool for local email users. + * It automatically analyzes their outgoing mail, specifically analyzing + * the recipients for each message they send. If the set of recipients + * exactly matches a set of recipients to whom they have previously sent + * messages, the message is accepted. If not, the message is bounced + * with a temporary failure code, to indicate to users they are attempting + * to send a message to a new set of recipients they have never sent email + * to before. This can help prevent sending messages to the wrong recipients, + * or from the wrong email address. + * + * TL;DR It's basically greylisting for new sets of recipients to catch potential mistakes + * with the From identity or set of recipients. + * + * This functionality is enabled on a per-user basis. To do so, + * the user simply creates the file .recipientmap in his or her home directory's + * .config subdirectory. + * + * \author Naveen Albert + */ + +#include "include/bbs.h" + +#include +#include + +#include "include/module.h" +#include "include/stringlist.h" +#include "include/transfer.h" +#include "include/node.h" + +#include "include/net_smtp.h" + +struct deferred_attempt { + unsigned int userid; + time_t time; + RWLIST_ENTRY(deferred_attempt) entry; + char line[]; +}; + +/*! + * \note We only WRLOCK this list, never RDLOCK it, + * so in theory the list could just use a mutex. */ +static RWLIST_HEAD_STATIC(deferred_attempts, deferred_attempt); + +static int processor(struct smtp_msg_process *mproc) +{ + char fullfile[265]; + char line[1024] = ""; + char buf[1024]; + char *linebuf = line; + const char *s; + char *s2; + int found_match = 0; + int was_deferred = 0; + struct stringlist sorted_recipients; + struct stringitem *i = NULL; + size_t lineleft = sizeof(line); + FILE *fp; + time_t now; + struct deferred_attempt *attempt; + + if (mproc->dir != SMTP_DIRECTION_SUBMIT) { + return 0; /* Only applies to user submissions */ + } + if (!mproc->userid) { + return 0; /* Only for user-level filters, not global */ + } + if (!mproc->recipients) { + bbs_warning("Recipient list not available?\n"); + return 0; + } + if (!mproc->smtp) { + bbs_warning("Not an interactive session?\n"); + return 0; + } + + /* Users need to manually create the file to enable its functionality. + * Otherwise, its lack of existence is treated as "feature disabled". */ + if (bbs_transfer_home_config_file(mproc->userid, ".recipientmap", fullfile, sizeof(fullfile))) { + /* File doesn't exist, so we don't need to do anything. Not an error. */ + return 0; + } + fp = fopen(fullfile, "r+"); + if (!fp) { + /* Since bbs_transfer_home_config_file returned 0, this should've succeeded... */ + bbs_error("Failed to open file %s: %s\n", fullfile, strerror(errno)); + return 0; + } + + /* This file is assumed to have a format as follows: + * , LF + * , LF + * ... + * , LF + * + * Each line contains one list. Each list is the From address followed by a comma-separated list of email addresses sorted alphabetically. + * (Email addresses only, no names, so just what you would get from RCPT TO) + * Because of this invariant, we can sort the email addresses for this message the same way, + * and then just do string comparisons of each line in 1 pass over the file. + * Even though we have to sort the list of recipients for THIS message, it will probably be small in most cases, + * and though the file could be very large, it's not being sorted, we are just making a linear pass over it, + * so this should be a fairly efficient operation. + * + * Note that each set of recipients is sorted, but the From addresses at the beginning of each line are not sorted with respect to each other, + * since we simply append to the end of the file when adding a new entry. This doesn't really affect the performance in any way either. */ + + /* First, go ahead and construct the "equivalent line" for this message, which we must find an exact match for. */ + SAFE_FAST_APPEND_NOSPACE(line, sizeof(line), linebuf, lineleft, "%s", mproc->from); + /* We can't just append directly to the string, first we need to sort the recipients. + * Since we have to construct a new list anyways, sort the list as we add to it, + * rather than doing it afterwards. */ + stringlist_init(&sorted_recipients); + while ((s = stringlist_next(mproc->recipients, &i))) { + stringlist_push_sorted(&sorted_recipients, s); + } + /* Now, iterate through the new list and construct the recipients part of the line */ + while ((s2 = stringlist_pop(&sorted_recipients))) { + SAFE_FAST_APPEND_NOSPACE(line, sizeof(line), linebuf, lineleft, ",%s", s2); + free(s2); + } + stringlist_empty_destroy(&sorted_recipients); + /* Since the line we read using fgets ends with LF, add one here, so we can do an exact comparison. */ + SAFE_FAST_APPEND_NOSPACE(line, sizeof(line), linebuf, lineleft, "\n"); + + /* First, see if the set was recently attempted. + * If so, this is the retry and it should be approved. */ + now = time(NULL) - 60; + + /* This is the main bottleneck since only one transaction can be using the list at a time here, + * but message submissions are infrequent, and this list will usually be empty or near-empty, + * so this is unlikely to be an issue. */ + RWLIST_WRLOCK(&deferred_attempts); + RWLIST_TRAVERSE_SAFE_BEGIN(&deferred_attempts, attempt, entry) { + if (attempt->time < now) { + int sec_ago = (int) (now + 60 - attempt->time); /* Add 60 to get actual "now", since we previously subtracted it from now */ + /* It's been more than a minute, purge it */ + bbs_debug(3, "From/Recipients set combo was previously deferred, but that was %d seconds ago (expired)\n", sec_ago); + RWLIST_REMOVE_CURRENT(entry); + free(attempt); + continue; + } + if (mproc->userid != attempt->userid) { + continue; + } + if (!strcasecmp(attempt->line, line)) { + was_deferred = 1; + RWLIST_REMOVE_CURRENT(entry); + free(attempt); + /* Don't break yet. + * Continue iterating to clear out any entries that might be stale. */ + } + } + RWLIST_TRAVERSE_SAFE_END; + RWLIST_UNLOCK(&deferred_attempts); + + /* Now, iterate through the file to look for a match. + * Although not strictly necessary, we do this in case the file was modified since we added it to the deferred list, + * to avoid possibly adding a duplicate. */ + while (fgets(buf, sizeof(buf), fp)) { + /* We need an exact match, but case-insensitive is fine for email addresses */ + if (!strcasecmp(buf, line)) { + found_match = 1; + break; + } + } + + if (found_match) { + fclose(fp); + /* Already exists in file, accept */ + bbs_debug(4, "From/Recipient set already exists, accepting\n"); + return 0; + } else if (was_deferred) { + size_t line_strlen = (size_t) (linebuf - line); + /* Append to end of file so it will match in the future, and accept */ + fseek(fp, 0, SEEK_END); + fwrite(line, 1, line_strlen, fp); + fclose(fp); + bbs_debug(4, "Added deferred From/Recipient set, accepting\n"); + return 0; + } + + fclose(fp); + + /* Temporarily reject the message to warn user that he or she is sending to a new set of recipients from the given From identity. + * This gives the user a chance to correct the message if needed (either the wrong From identity, or wrong set of recipients). + * If it's not a mistake, the user can immediately reattempt submission to force it to succeed and add the set to the file + * so it will be immediately accepted in the future. */ + + /* Now, keep track that this set was attempted once, and we deferred it, + * so if we detect the same set again, we can accept it. */ + attempt = calloc(1, sizeof(*attempt) + strlen(line) + 1); + if (ALLOC_SUCCESS(attempt)) { + strcpy(attempt->line, line); /* Safe */ + attempt->userid = mproc->userid; + attempt->time = time(NULL); + RWLIST_WRLOCK(&deferred_attempts); + RWLIST_INSERT_HEAD(&deferred_attempts, attempt, entry); + RWLIST_UNLOCK(&deferred_attempts); + } + + bbs_debug(4, "From/Recipient set not seen before, deferring\n"); + bbs_smtp_log(4, mproc->smtp, "Submission deferred: From/Recipient set not seen before: %s\n", line); + /* The effectiveness of this depends heavily on the user's mail user agent presenting + * the entire SMTP failure code/message directly to the user. + * But if that doesn't happen, then that's a problem with the user's client. */ + smtp_reply_nostatus(mproc->smtp, 421, "From/Recipient set not seen before, resubmit if intentional"); + return 1; +} + +static int load_module(void) +{ + return smtp_register_processor(processor); +} + +static int unload_module(void) +{ + int res = smtp_unregister_processor(processor); + RWLIST_WRLOCK_REMOVE_ALL(&deferred_attempts, entry, free); + return res; +} + +BBS_MODULE_INFO_DEPENDENT("Recipient Monitor", "net_smtp.so"); diff --git a/nets/net_smtp.c b/nets/net_smtp.c index 61ed80d..95093ea 100644 --- a/nets/net_smtp.c +++ b/nets/net_smtp.c @@ -101,18 +101,6 @@ static unsigned int max_message_size = 300000; /*! \brief Maximum number of hops, per local policy */ static unsigned int max_hops = MAX_HOPS; -#define _smtp_reply(smtp, fmt, ...) \ - bbs_debug(6, "%p <= " fmt, smtp, ## __VA_ARGS__); \ - bbs_auto_fd_writef(smtp->node, smtp->node ? smtp->node->wfd : -1, fmt, ## __VA_ARGS__); \ - -/*! \brief Final SMTP response with this code */ -#define smtp_resp_reply(smtp, code, subcode, reply) _smtp_reply(smtp, "%d %s %s\r\n", code, subcode, reply) -#define smtp_reply(smtp, code, status, fmt, ...) _smtp_reply(smtp, "%d %s " fmt "\r\n", code, #status, ## __VA_ARGS__) -#define smtp_reply_nostatus(smtp, code, fmt, ...) _smtp_reply(smtp, "%d " fmt "\r\n", code, ## __VA_ARGS__) - -/*! \brief Non-final SMTP response (subsequent responses with the same code follow) */ -#define smtp_reply0_nostatus(smtp, code, fmt, ...) _smtp_reply(smtp, "%d-" fmt "\r\n", code, ## __VA_ARGS__) - void bbs_smtp_log(int level, struct smtp_session *smtp, const char *fmt, ...) { va_list ap; @@ -715,6 +703,7 @@ static int handle_auth(struct smtp_session *smtp, char *s) smtp_reply(smtp, 502, 5.5.2, "Decoding failure"); return 0; } + bbs_strterm((char*) user, '@'); /* Strip domain */ res = bbs_authenticate(smtp->node, (char*) user, (char*) pass); free(user); free(pass); @@ -1502,6 +1491,7 @@ static int cli_filters(struct bbs_cli_args *a) void smtp_mproc_init(struct smtp_session *smtp, struct smtp_msg_process *mproc) { memset(mproc, 0, sizeof(struct smtp_msg_process)); + mproc->smtp = smtp; mproc->datafile = smtp->datafile; mproc->node = smtp->node; mproc->fd = mproc->node ? smtp->node->wfd : -1; @@ -2291,10 +2281,18 @@ static int do_deliver(struct smtp_session *smtp, const char *filename, size_t da int srcfd = -1; smtp_mproc_init(smtp, &mproc); mproc.size = (int) datalen; + mproc.dir = smtp->msa ? SMTP_DIRECTION_SUBMIT : SMTP_DIRECTION_OUT; mproc.direction = SMTP_MSG_DIRECTION_OUT; mproc.mbox = NULL; - mproc.userid = (int) smtp->node->user->id; + mproc.userid = smtp->node->user->id; mproc.user = smtp->node->user; + if (smtp->msa) { + /* All envelope recipients of the message, only added for message submissions. + * We don't do this for SMTP_DIRECTION_IN, because allowing one recipient to view + * who the other recipients were (for example, if they were Bcc'd) would leak information. + * If the user is the one sending it, then this information is already known and not secret. */ + mproc.recipients = &smtp->recipients; + } /* Note that mproc.to here is NULL, since we don't process recipients until expand_and_deliver, * i.e. we run the callbacks here per-message, not per-recipient, so we don't have access * to a specific recipient for this outgoing rules. This is "pre transaction"