From cf88aa3a9b199d0dfc4fbd11013e37cca48ff95d Mon Sep 17 00:00:00 2001 From: Mario Fetka Date: Tue, 21 Jul 2026 08:36:33 +0200 Subject: [PATCH] Enforce SMTP MTA-STS destination policies --- docs/protocols.md | 3 +- docs/release-testing-0.7.md | 1 + docs/tls.md | 11 + include/xpldns.h | 1 + init/bongo.tmpfiles.in | 1 + src/agents/smtp/CMakeLists.txt | 14 + src/agents/smtp/mta-sts.c | 507 +++++++++++++++++++ src/agents/smtp/mta-sts.h | 51 ++ src/agents/smtp/smtpc.c | 52 ++ src/agents/smtp/smtpc.h | 1 + src/agents/smtp/tests/mta-sts-test.c | 85 ++++ src/apps/config/config/smtp | 2 + src/apps/config/tests/test_configuration.py | 12 + src/libs/python/bongo/configuration/model.py | 5 + src/libs/xpl/CMakeLists.txt | 4 + src/libs/xpl/dns.c | 60 ++- src/libs/xpl/tests/dns-txt-test.c | 62 +++ 17 files changed, 868 insertions(+), 4 deletions(-) create mode 100644 src/agents/smtp/mta-sts.c create mode 100644 src/agents/smtp/mta-sts.h create mode 100644 src/agents/smtp/tests/mta-sts-test.c create mode 100644 src/libs/xpl/tests/dns-txt-test.c diff --git a/docs/protocols.md b/docs/protocols.md index 9a2bef1..5626c48 100644 --- a/docs/protocols.md +++ b/docs/protocols.md @@ -11,7 +11,8 @@ The SMTP server supports classic SMTP and ESMTP with `SIZE`, `PIPELINING`, SASL authentication, and `REQUIRETLS` on TLS-capable listeners. `BINARYMIME` is deliberately not advertised or accepted. Authenticated submission and implicit TLS use listener-specific policy; Internet delivery uses -opportunistic STARTTLS unless a destination policy requires TLS. +opportunistic STARTTLS unless REQUIRETLS, DNSSEC-authenticated DANE, or an +RFC 8461 MTA-STS destination policy requires authenticated TLS. LMTP transports are available for local downstream services such as Mailman. Trusted SMTP/LMTP peers can receive original-client metadata through diff --git a/docs/release-testing-0.7.md b/docs/release-testing-0.7.md index 38bee31..0b50d22 100644 --- a/docs/release-testing-0.7.md +++ b/docs/release-testing-0.7.md @@ -127,6 +127,7 @@ inputs and outputs is absent from the ZIP. | SMTP-06 | Local authenticated delivery from test1 to test2 | | | | | SMTP-07 | Internet delivery prefers STARTTLS and follows opportunistic fallback | | | | | SMTP-08 | Required TLS and `REQUIRETLS` defer rather than downgrade | | | | +| SMTP-27 | DANE takes precedence; MTA-STS enforce/testing/cache and MX wildcard policy | | | | | SMTP-09 | Relayhost hostname, port, TLS, authentication, failure, and recovery | | | | | SMTP-10 | Open-relay tests reject unauthenticated/untrusted third-party relay | | | | | SMTP-11 | `SIZE` exact limit, over-limit, absent size, and disk-space responses | | | | diff --git a/docs/tls.md b/docs/tls.md index ec42d72..bf4386c 100644 --- a/docs/tls.md +++ b/docs/tls.md @@ -28,6 +28,17 @@ mandatory. Bogus or indeterminate DNSSEC results defer delivery. Insecure DNS or securely proven TLSA absence leaves the existing opportunistic STARTTLS policy in place. +`outbound_mta_sts_enabled` also defaults to `true`. When DANE is inactive, +Bongo discovers the RFC 8461 policy at `_mta-sts.`, downloads it only +over certificate-validated HTTPS, and keeps the validated policy in +`outbound_mta_sts_cache_directory`. An `enforce` policy permits only listed +MX hosts and requires authenticated TLS 1.2 or newer; policy, certificate, +STARTTLS, and handshake failures defer delivery without a plaintext retry. A +`testing` policy records the same failures without changing delivery. A valid +cached policy remains authoritative through its `max_age` when DNS discovery +or HTTPS refresh temporarily fails. DANE always takes precedence over +MTA-STS. + The resolver named in `resolv.conf` must therefore be a trusted validating resolver reached over a trusted local path; Bongo must not trust an AD bit received from an untrusted network resolver. GnuTLS/libdane also needs its diff --git a/include/xpldns.h b/include/xpldns.h index 4d81bd8..5bf7328 100644 --- a/include/xpldns.h +++ b/include/xpldns.h @@ -105,6 +105,7 @@ typedef struct { int XplDnsInit(void); void XplDnsResultFree(XplDns_Result *result); XplDns_Result * XplDnsLookupIP(const char *domain); +XplDns_Result * XplDnsLookupTXT(const char *domain); int XplDnsMxRecordsAreNull(const XplDns_RecordList *records); XplDns_MxLookup *XplDnsNewMxLookup(const char *domain); XplDns_IpList * XplDnsNextMxLookupIpList(XplDns_MxLookup *mx); diff --git a/init/bongo.tmpfiles.in b/init/bongo.tmpfiles.in index 8869487..e1981d3 100644 --- a/init/bongo.tmpfiles.in +++ b/init/bongo.tmpfiles.in @@ -23,6 +23,7 @@ d @XPL_DEFAULT_STATE_DIR@ 0750 @BONGO_USER@ @BONGO_USER@ - d @XPL_DEFAULT_DBF_DIR@ 0750 @BONGO_USER@ @BONGO_USER@ - d @XPL_DEFAULT_WORK_DIR@ 0750 @BONGO_USER@ @BONGO_USER@ - d @XPL_DEFAULT_CACHE_DIR@ 0750 @BONGO_USER@ @BONGO_USER@ - +d @XPL_DEFAULT_CACHE_DIR@/mta-sts 0750 @BONGO_USER@ @BONGO_USER@ - d @XPL_DEFAULT_SCMS_DIR@ 0750 @BONGO_USER@ @BONGO_USER@ - d @XPL_DEFAULT_SPOOL_DIR@ 0750 @BONGO_USER@ @BONGO_USER@ - d @XPL_DEFAULT_SYSTEM_DIR@ 0750 @BONGO_USER@ @BONGO_USER@ - diff --git a/src/agents/smtp/CMakeLists.txt b/src/agents/smtp/CMakeLists.txt index 79da072..97d13ba 100644 --- a/src/agents/smtp/CMakeLists.txt +++ b/src/agents/smtp/CMakeLists.txt @@ -22,6 +22,7 @@ target_link_libraries(bongosmtp add_executable(bongosmtpc smtpc.c dane.c + mta-sts.c capabilities.c protocol.c proxy.c @@ -93,4 +94,17 @@ if(BUILD_TESTING) GnuTLS::GnuTLS ) add_test(NAME smtp-dane COMMAND smtp-dane-test) + + add_executable(smtp-mta-sts-test + tests/mta-sts-test.c + mta-sts.c + ) + target_include_directories(smtp-mta-sts-test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ) + target_link_libraries(smtp-mta-sts-test PRIVATE + bongoxpl + CURL::libcurl + ) + add_test(NAME smtp-mta-sts COMMAND smtp-mta-sts-test) endif() diff --git a/src/agents/smtp/mta-sts.c b/src/agents/smtp/mta-sts.c new file mode 100644 index 0000000..be61e5f --- /dev/null +++ b/src/agents/smtp/mta-sts.c @@ -0,0 +1,507 @@ +/**************************************************************************** + * + * Copyright (c) 2001 Novell, Inc. All Rights Reserved. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of version 2 of the GNU General Public License + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, contact Novell, Inc. + * + ****************************************************************************/ +// Parts Copyright (C) 2026 Mario Fetka. See COPYING for details. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "mta-sts.h" + +#define POLICY_LIMIT 65536U +#define POLICY_REFRESH 86400 + +typedef struct { + char data[POLICY_LIMIT + 1U]; + size_t length; +} PolicyDownload; + +static char * +Trim(char *value) +{ + char *end; + + while (*value == ' ' || *value == '\t') value++; + end = value + strlen(value); + while (end > value && (end[-1] == ' ' || end[-1] == '\t' || + end[-1] == '\r')) + *--end = '\0'; + return value; +} + +static int +ValidIdentifier(const char *value) +{ + size_t index; + size_t length = strlen(value); + + if (length == 0U || length > 32U) return 0; + for (index = 0U; index < length; index++) + if (!isalnum((unsigned char)value[index])) return 0; + return 1; +} + +int +SMTPMtaStsParseRecord(const char *record, char id[33]) +{ + char copy[512]; + char *field; + char *save = NULL; + int foundId = 0; + + if (record == NULL || id == NULL || strlen(record) >= sizeof(copy)) + return 0; + strcpy(copy, record); + field = strtok_r(copy, ";", &save); + if (field == NULL || strcmp(Trim(field), "v=STSv1") != 0) return 0; + while ((field = strtok_r(NULL, ";", &save)) != NULL) { + char *name = Trim(field); + char *equals = strchr(name, '='); + char *value; + + if (equals == NULL) return 0; + *equals = '\0'; + name = Trim(name); + value = Trim(equals + 1); + if (strcmp(name, "id") == 0 && !foundId) { + if (!ValidIdentifier(value)) return 0; + strcpy(id, value); + foundId = 1; + } + } + return foundId; +} + +static int +ValidDomainPattern(const char *pattern) +{ + const char *value = pattern; + const char *label; + size_t length; + size_t index; + + if (strncmp(value, "*.", 2U) == 0) value += 2; + length = strlen(value); + if (length == 0U || length > 253U || value[0] == '.' || + value[length - 1U] == '.') + return 0; + for (index = 0U; index < length; index++) { + unsigned char byte = (unsigned char)value[index]; + if (!(isalnum(byte) || byte == '-' || byte == '.')) return 0; + } + for (label = value; *label != '\0';) { + const char *dot = strchr(label, '.'); + size_t labelLength = dot == NULL ? strlen(label) : (size_t)(dot - label); + + if (labelLength == 0U || labelLength > 63U || label[0] == '-' || + label[labelLength - 1U] == '-') + return 0; + label = dot == NULL ? label + labelLength : dot + 1; + } + return 1; +} + +int +SMTPMtaStsParsePolicy(const char *text, SMTPMtaStsPolicy *policy) +{ + char *copy; + char *line; + char *save = NULL; + int haveVersion = 0; + int haveMode = 0; + int haveMaxAge = 0; + unsigned int fieldNumber = 0U; + + if (text == NULL || policy == NULL || strlen(text) > POLICY_LIMIT) + return 0; + memset(policy, 0, sizeof(*policy)); + copy = strdup(text); + if (copy == NULL) return 0; + for (line = strtok_r(copy, "\n", &save); line != NULL; + line = strtok_r(NULL, "\n", &save)) { + char *name = Trim(line); + char *colon = strchr(name, ':'); + char *value; + + if (colon == NULL) goto invalid; + *colon = '\0'; + value = Trim(colon + 1); + name = Trim(name); + fieldNumber++; + if (strcmp(name, "version") == 0) { + if (!haveVersion) { + if (fieldNumber != 1U || strcmp(value, "STSv1") != 0) + goto invalid; + haveVersion = 1; + } + } else if (strcmp(name, "mode") == 0) { + if (!haveMode) { + if (strcmp(value, "enforce") == 0) + policy->mode = SMTP_MTA_STS_ENFORCE; + else if (strcmp(value, "testing") == 0) + policy->mode = SMTP_MTA_STS_TESTING; + else if (strcmp(value, "none") == 0) + policy->mode = SMTP_MTA_STS_INACTIVE; + else + goto invalid; + haveMode = 1; + } + } else if (strcmp(name, "max_age") == 0) { + if (!haveMaxAge) { + char *end = NULL; + const char *digit; + unsigned long age; + + for (digit = value; *digit != '\0'; digit++) + if (!isdigit((unsigned char)*digit)) goto invalid; + errno = 0; + age = strtoul(value, &end, 10); + if (errno != 0 || end == value || *end != '\0' || + age > 31557600UL) + goto invalid; + policy->maxAge = age; + haveMaxAge = 1; + } + } else if (strcmp(name, "mx") == 0) { + size_t length; + + if (policy->mxCount >= SMTP_MTA_STS_MAX_MX || + !ValidDomainPattern(value)) + goto invalid; + length = strlen(value); + memcpy(policy->mx[policy->mxCount], value, length + 1U); + policy->mxCount++; + } + } + free(copy); + return haveVersion && haveMode && haveMaxAge && + (policy->mode == SMTP_MTA_STS_INACTIVE || policy->mxCount > 0U); + +invalid: + free(copy); + return 0; +} + +int +SMTPMtaStsMxMatches(const SMTPMtaStsPolicy *policy, const char *hostname) +{ + unsigned int index; + size_t hostLength; + + if (policy == NULL || hostname == NULL) return 0; + hostLength = strlen(hostname); + while (hostLength > 0U && hostname[hostLength - 1U] == '.') hostLength--; + for (index = 0U; index < policy->mxCount; index++) { + const char *pattern = policy->mx[index]; + size_t patternLength = strlen(pattern); + + if (pattern[0] != '*' || pattern[1] != '.') { + if (patternLength == hostLength && + strncasecmp(pattern, hostname, hostLength) == 0) + return 1; + continue; + } + pattern += 1; /* Keep the dot: wildcard must match at least one label. */ + patternLength--; + if (hostLength > patternLength && + memchr(hostname, '.', hostLength - patternLength) == NULL && + strncasecmp(hostname + hostLength - patternLength, pattern, + patternLength) == 0) + return 1; + } + return 0; +} + +static size_t +DownloadWrite(char *data, size_t size, size_t count, void *opaque) +{ + PolicyDownload *download = opaque; + size_t length; + + if (size != 0U && count > SIZE_MAX / size) return 0U; + length = size * count; + if (length > POLICY_LIMIT - download->length) return 0U; + memcpy(download->data + download->length, data, length); + download->length += length; + download->data[download->length] = '\0'; + return length; +} + +static int +FetchPolicy(const char *domain, SMTPMtaStsPolicy *policy) +{ + CURL *curl; + CURLcode result; + long response = 0L; + char url[320]; + char *contentType = NULL; + PolicyDownload download; + int written; + + written = snprintf(url, sizeof(url), + "https://mta-sts.%s/.well-known/mta-sts.txt", domain); + if (written < 0 || (size_t)written >= sizeof(url)) + return 0; + memset(&download, 0, sizeof(download)); + curl = curl_easy_init(); + if (curl == NULL) return 0; + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); + curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "Bongo MTA-STS/0.7"); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, DownloadWrite); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &download); + result = curl_easy_perform(curl); + if (result == CURLE_OK) { + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response); + curl_easy_getinfo(curl, CURLINFO_CONTENT_TYPE, &contentType); + } + if (result != CURLE_OK || response != 200L || contentType == NULL || + strncasecmp(contentType, "text/plain", 10U) != 0 || + (contentType[10] != '\0' && contentType[10] != ';' && + !isspace((unsigned char)contentType[10])) || + memchr(download.data, '\0', download.length) != NULL || + !SMTPMtaStsParsePolicy(download.data, policy)) { + curl_easy_cleanup(curl); + return 0; + } + curl_easy_cleanup(curl); + return 1; +} + +static int +CachePath(char *path, size_t pathSize, const char *directory, + const char *domain) +{ + size_t index; + int written; + + for (index = 0U; domain[index] != '\0'; index++) { + unsigned char byte = (unsigned char)domain[index]; + if (!(isalnum(byte) || byte == '-' || byte == '.')) return 0; + } + written = snprintf(path, pathSize, "%s/%s.policy", directory, domain); + return written >= 0 && (size_t)written < pathSize; +} + +static int +LoadCache(const char *directory, const char *domain, + SMTPMtaStsPolicy *policy, int requireCurrent) +{ + char path[PATH_MAX]; + char header[128]; + char *body = NULL; + char id[33]; + long long fetched; + long size; + FILE *file; + time_t now = time(NULL); + int result = 0; + + if (!CachePath(path, sizeof(path), directory, domain)) return 0; + file = fopen(path, "r"); + if (file == NULL) return 0; + if (fscanf(file, "id:%32s\nfetched:%lld\n", id, &fetched) != 2 || + fseek(file, 0L, SEEK_END) != 0 || (size = ftell(file)) < 0L || + size > (long)POLICY_LIMIT + 80L || fseek(file, 0L, SEEK_SET) != 0) + goto done; + body = malloc((size_t)size + 1U); + if (body == NULL || fgets(header, sizeof(header), file) == NULL || + fgets(header, sizeof(header), file) == NULL) + goto done; + size = (long)fread(body, 1U, POLICY_LIMIT, file); + body[size] = '\0'; + if (!SMTPMtaStsParsePolicy(body, policy)) goto done; + strcpy(policy->id, id); + snprintf(policy->domain, sizeof(policy->domain), "%s", domain); + policy->fetched = (time_t)fetched; + if (requireCurrent && + (now < policy->fetched || + (unsigned long)(now - policy->fetched) > policy->maxAge)) + goto done; + result = 1; + +done: + free(body); + fclose(file); + return result; +} + +static int +SaveCache(const char *directory, const SMTPMtaStsPolicy *policy, + const char *body) +{ + char path[PATH_MAX]; + char temporary[PATH_MAX]; + int descriptor; + FILE *file; + int result = 0; + int written; + + if ((mkdir(directory, 0750) != 0 && errno != EEXIST) || + !CachePath(path, sizeof(path), directory, policy->domain)) + return 0; + written = snprintf(temporary, sizeof(temporary), "%s/.policy.XXXXXX", + directory); + if (written < 0 || (size_t)written >= sizeof(temporary) || + (descriptor = mkstemp(temporary)) < 0) + return 0; + file = fdopen(descriptor, "w"); + if (file == NULL) { + close(descriptor); + unlink(temporary); + return 0; + } + if (fprintf(file, "id:%s\nfetched:%lld\n%s", policy->id, + (long long)policy->fetched, body) >= 0 && + fflush(file) == 0 && fsync(descriptor) == 0) result = 1; + if (fclose(file) != 0) result = 0; + if (result && rename(temporary, path) == 0) return 1; + unlink(temporary); + return 0; +} + +static int +AppendPolicyLine(char *body, size_t bodySize, size_t *used, + const char *format, const char *value) +{ + int written; + + if (*used >= bodySize) return 0; + written = snprintf(body + *used, bodySize - *used, format, value); + if (written < 0 || (size_t)written >= bodySize - *used) return 0; + *used += (size_t)written; + return 1; +} + +static int +DiscoverRecord(const char *domain, char id[33]) +{ + char name[300]; + XplDns_Result *result; + XplDns_RecordList *record; + unsigned int count = 0U; + + if (snprintf(name, sizeof(name), "_mta-sts.%s", domain) >= + (int)sizeof(name)) + return 0; + result = XplDnsLookupTXT(name); + if (result == NULL || result->status != XPLDNS_SUCCESS) { + XplDnsResultFree(result); + return 0; + } + for (record = result->list; record != NULL; record = record->next) { + char candidate[33]; + + if (record->type == XPLDNS_RR_TXT && + SMTPMtaStsParseRecord(record->record.TXT.txt, candidate)) { + if (++count == 1U) strcpy(id, candidate); + } + } + XplDnsResultFree(result); + return count == 1U; +} + +SMTPMtaStsMode +SMTPMtaStsLookup(const char *domain, const char *mxHostname, + const char *cacheDirectory, SMTPMtaStsPolicy *policy) +{ + SMTPMtaStsPolicy cached; + SMTPMtaStsPolicy fetched; + char id[33]; + char body[POLICY_LIMIT + 1U]; + int haveCache; + int haveRecord; + time_t now = time(NULL); + + if (domain == NULL || mxHostname == NULL || cacheDirectory == NULL || + policy == NULL) + return SMTP_MTA_STS_INACTIVE; + memset(policy, 0, sizeof(*policy)); + if (!ValidDomainPattern(domain) || strchr(domain, '*') != NULL || + !ValidDomainPattern(mxHostname) || strchr(mxHostname, '*') != NULL) + return SMTP_MTA_STS_INACTIVE; + haveCache = LoadCache(cacheDirectory, domain, &cached, 1); + haveRecord = DiscoverRecord(domain, id); + if (haveRecord && haveCache && strcmp(id, cached.id) == 0 && + now >= cached.fetched && now - cached.fetched < POLICY_REFRESH) { + *policy = cached; + } else if (haveRecord) { + if (FetchPolicy(domain, &fetched)) { + /* Serialize the validated representation for the durable cache. */ + size_t used; + unsigned int index; + int written; + + strcpy(fetched.id, id); + snprintf(fetched.domain, sizeof(fetched.domain), "%s", domain); + fetched.fetched = now; + used = 0U; + if (!AppendPolicyLine(body, sizeof(body), &used, + "version: STSv1\nmode: %s\n", + fetched.mode == SMTP_MTA_STS_ENFORCE + ? "enforce" + : fetched.mode == SMTP_MTA_STS_TESTING + ? "testing" + : "none")) + return SMTP_MTA_STS_INACTIVE; + for (index = 0U; index < fetched.mxCount; index++) + if (!AppendPolicyLine(body, sizeof(body), &used, "mx: %s\n", + fetched.mx[index])) + return SMTP_MTA_STS_INACTIVE; + if (used >= sizeof(body)) return SMTP_MTA_STS_INACTIVE; + written = snprintf(body + used, sizeof(body) - used, + "max_age: %lu\n", fetched.maxAge); + if (written < 0 || (size_t)written >= sizeof(body) - used) + return SMTP_MTA_STS_INACTIVE; + (void)SaveCache(cacheDirectory, &fetched, body); + *policy = fetched; + } else if (haveCache) { + *policy = cached; + } else { + return SMTP_MTA_STS_INACTIVE; + } + } else if (haveCache) { + *policy = cached; + } else { + return SMTP_MTA_STS_INACTIVE; + } + + if (policy->mode != SMTP_MTA_STS_INACTIVE && + !SMTPMtaStsMxMatches(policy, mxHostname)) + return policy->mode; + return policy->mode; +} diff --git a/src/agents/smtp/mta-sts.h b/src/agents/smtp/mta-sts.h new file mode 100644 index 0000000..112b2af --- /dev/null +++ b/src/agents/smtp/mta-sts.h @@ -0,0 +1,51 @@ +/**************************************************************************** + * + * Copyright (c) 2001 Novell, Inc. All Rights Reserved. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of version 2 of the GNU General Public License + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, contact Novell, Inc. + * + ****************************************************************************/ +// Parts Copyright (C) 2026 Mario Fetka. See COPYING for details. + +#ifndef _SMTP_MTA_STS_H +#define _SMTP_MTA_STS_H + +#include +#include + +#define SMTP_MTA_STS_MAX_MX 32U + +typedef enum { + SMTP_MTA_STS_INACTIVE = 0, + SMTP_MTA_STS_TESTING, + SMTP_MTA_STS_ENFORCE +} SMTPMtaStsMode; + +typedef struct { + SMTPMtaStsMode mode; + char id[33]; + char domain[256]; + unsigned long maxAge; + time_t fetched; + unsigned int mxCount; + char mx[SMTP_MTA_STS_MAX_MX][256]; +} SMTPMtaStsPolicy; + +int SMTPMtaStsParseRecord(const char *record, char id[33]); +int SMTPMtaStsParsePolicy(const char *text, SMTPMtaStsPolicy *policy); +int SMTPMtaStsMxMatches(const SMTPMtaStsPolicy *policy, const char *hostname); +SMTPMtaStsMode SMTPMtaStsLookup(const char *domain, const char *mxHostname, + const char *cacheDirectory, + SMTPMtaStsPolicy *policy); + +#endif diff --git a/src/agents/smtp/smtpc.c b/src/agents/smtp/smtpc.c index 06ae21a..510e8d4 100755 --- a/src/agents/smtp/smtpc.c +++ b/src/agents/smtp/smtpc.c @@ -54,6 +54,8 @@ static struct { BOOL outbound_tls_verify; BOOL outbound_tls_required; BOOL outbound_dane_enabled; + BOOL outbound_mta_sts_enabled; + char *outbound_mta_sts_cache_directory; GArray *lmtp_transports; GArray *xclient_trusted_destinations; GArray *xforward_trusted_destinations; @@ -84,6 +86,8 @@ static BongoConfigItem MailAuthConfig[] = { { BONGO_JSON_BOOL, "o:outbound_tls_verify/b", &MailAuth.outbound_tls_verify }, { BONGO_JSON_BOOL, "o:outbound_tls_required/b", &MailAuth.outbound_tls_required }, { BONGO_JSON_BOOL, "o:outbound_dane_enabled/b", &MailAuth.outbound_dane_enabled }, + { BONGO_JSON_BOOL, "o:outbound_mta_sts_enabled/b", &MailAuth.outbound_mta_sts_enabled }, + { BONGO_JSON_STRING, "o:outbound_mta_sts_cache_directory/s", &MailAuth.outbound_mta_sts_cache_directory }, { BONGO_JSON_ARRAY, "o:lmtp_transports/a", &LMTPTransportList }, { BONGO_JSON_ARRAY, "o:xclient_trusted_destinations/a", &XClientTrustedHostList }, { BONGO_JSON_ARRAY, "o:xforward_trusted_destinations/a", &XForwardTrustedHostList }, @@ -952,6 +956,9 @@ DeliverMessage(SMTPClient *Queue, SMTPClient *Remote, RecipStruct *Recip) { const char *envelopeSender = NULL; unsigned int ReplyCode = 0U; SMTPDanePolicy danePolicy = SMTP_DANE_INACTIVE; + SMTPMtaStsPolicy mtaStsPolicy; + SMTPMtaStsMode mtaStsMode = SMTP_MTA_STS_INACTIVE; + BOOL mtaStsMxValid = TRUE; requiresTLS = (Queue->flags & MSG_FLAG_REQUIRETLS) != 0; if (MailAuth.outbound_dane_enabled && !Remote->isLMTP) { @@ -966,6 +973,31 @@ DeliverMessage(SMTPClient *Queue, SMTPClient *Remote, RecipStruct *Recip) { goto finalization; } } + memset(&mtaStsPolicy, 0, sizeof(mtaStsPolicy)); + if (danePolicy == SMTP_DANE_INACTIVE && + MailAuth.outbound_mta_sts_enabled && !Remote->isLMTP) { + mtaStsMode = SMTPMtaStsLookup( + Remote->originalNextHopDomain, Remote->transportHost, + MailAuth.outbound_mta_sts_cache_directory != NULL && + MailAuth.outbound_mta_sts_cache_directory[0] != '\0' + ? MailAuth.outbound_mta_sts_cache_directory + : XPL_DEFAULT_CACHE_DIR "/mta-sts", + &mtaStsPolicy); + mtaStsMxValid = mtaStsMode == SMTP_MTA_STS_INACTIVE || + SMTPMtaStsMxMatches(&mtaStsPolicy, + Remote->transportHost); + if (!mtaStsMxValid && mtaStsMode == SMTP_MTA_STS_ENFORCE) { + Log(LOG_ERROR, + "MTA-STS policy for %.900s rejects MX %.900s", + Remote->originalNextHopDomain, Remote->transportHost); + Recip->Result = DELIVER_TRY_LATER; + goto finalization; + } + if (!mtaStsMxValid && mtaStsMode == SMTP_MTA_STS_TESTING) + Log(LOG_WARN, + "MTA-STS testing policy for %.900s does not match MX %.900s", + Remote->originalNextHopDomain, Remote->transportHost); + } beginSession: /* We are connected to the remote SMTP server, but have not started the @@ -1015,6 +1047,7 @@ beginConversation: /* REQUIRETLS overrides the legacy per-message no-TLS flag. */ if ((requiresTLS || danePolicy >= SMTP_DANE_TLS_REQUIRED || + mtaStsMode == SMTP_MTA_STS_ENFORCE || !(Queue->flags & MSG_FLAG_SMTPC_NOSSL)) && (Extensions & EXT_TLS) && !Remote->conn->ssl.enable && !plaintextFallback) { @@ -1029,8 +1062,14 @@ beginConversation: if ((Ret = ConnEncryptPeer(Remote->conn, SMTPAgent.SSL_Context, Remote->transportHost, (requiresTLS || + mtaStsMode == SMTP_MTA_STS_ENFORCE || MailAuth.outbound_tls_verify))) < 0) { + if (mtaStsMode == SMTP_MTA_STS_TESTING) + Log(LOG_WARN, + "MTA-STS testing policy detected a TLS negotiation or identity failure at %.900s", + Remote->transportHost); if (!requiresTLS && danePolicy == SMTP_DANE_INACTIVE && + mtaStsMode != SMTP_MTA_STS_ENFORCE && !MailAuth.outbound_tls_required && !MailAuth.outbound_tls_verify && ReconnectRemotePlaintext(Remote)) { @@ -1056,11 +1095,18 @@ beginConversation: Recip->Result = DELIVER_TRY_LATER; goto finalization; } + if (mtaStsMode == SMTP_MTA_STS_TESTING && + !ConnTLSVerifyPeer(Remote->conn->ssl.context, + Remote->transportHost)) + Log(LOG_WARN, + "MTA-STS testing policy detected an invalid TLS identity for %.900s", + Remote->transportHost); goto beginConversation; } } if ((MailAuth.outbound_tls_required || MailAuth.outbound_tls_verify || danePolicy >= SMTP_DANE_TLS_REQUIRED || + mtaStsMode == SMTP_MTA_STS_ENFORCE || requiresTLS) && !Remote->conn->ssl.enable) { Log(LOG_ERROR, "Remote server %s does not offer required TLS", @@ -1068,6 +1114,11 @@ beginConversation: Recip->Result = DELIVER_TRY_LATER; goto finalization; } + if (mtaStsMode == SMTP_MTA_STS_TESTING && + !Remote->conn->ssl.enable && !(Extensions & EXT_TLS)) + Log(LOG_WARN, + "MTA-STS testing policy detected missing STARTTLS at %.900s", + Remote->transportHost); if (requiresTLS && !(Extensions & EXT_REQUIRETLS)) { Log(LOG_ERROR, "Remote server %s does not support required REQUIRETLS transport", @@ -1758,6 +1809,7 @@ XplServiceMain(int argc, char *argv[]) MailAuth.outbound_tls_verify = FALSE; MailAuth.outbound_tls_required = FALSE; MailAuth.outbound_dane_enabled = TRUE; + MailAuth.outbound_mta_sts_enabled = TRUE; /* Initialize the Bongo libraries */ startupOpts = BA_STARTUP_CONNIO | BA_STARTUP_NMAP; diff --git a/src/agents/smtp/smtpc.h b/src/agents/smtp/smtpc.h index e96d08f..0febf76 100644 --- a/src/agents/smtp/smtpc.h +++ b/src/agents/smtp/smtpc.h @@ -34,6 +34,7 @@ #include "smtp.h" #include "dane.h" +#include "mta-sts.h" #define AGENT_NAME "smtpd_c" diff --git a/src/agents/smtp/tests/mta-sts-test.c b/src/agents/smtp/tests/mta-sts-test.c new file mode 100644 index 0000000..9f5ac01 --- /dev/null +++ b/src/agents/smtp/tests/mta-sts-test.c @@ -0,0 +1,85 @@ +/**************************************************************************** + * + * Copyright (c) 2001 Novell, Inc. All Rights Reserved. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of version 2 of the GNU General Public License + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, contact Novell, Inc. + * + ****************************************************************************/ +// Parts Copyright (C) 2026 Mario Fetka. See COPYING for details. + +#include +#include + +#include "mta-sts.h" + +static void +TestRecord(void) +{ + char id[33]; + + assert(SMTPMtaStsParseRecord("v=STSv1; id=20260721", id)); + assert(strcmp(id, "20260721") == 0); + assert(SMTPMtaStsParseRecord(" v=STSv1 ; id = abc123 ; ext=x ", id)); + assert(strcmp(id, "abc123") == 0); + assert(!SMTPMtaStsParseRecord("id=one; v=STSv1", id)); + assert(!SMTPMtaStsParseRecord("v=STSv2; id=one", id)); + assert(!SMTPMtaStsParseRecord("v=STSv1; id=bad-id", id)); + assert(!SMTPMtaStsParseRecord("v=STSv1", id)); +} + +static void +TestPolicy(void) +{ + SMTPMtaStsPolicy policy; + + assert(SMTPMtaStsParsePolicy( + "version: STSv1\nmode: enforce\nmx: mail.example.test\n" + "mx: *.mx.example.test\nmax_age: 86400\n", &policy)); + assert(policy.mode == SMTP_MTA_STS_ENFORCE); + assert(policy.maxAge == 86400UL); + assert(policy.mxCount == 2U); + assert(SMTPMtaStsMxMatches(&policy, "mail.example.test")); + assert(SMTPMtaStsMxMatches(&policy, "a.mx.example.test.")); + assert(!SMTPMtaStsMxMatches(&policy, "mx.example.test")); + assert(!SMTPMtaStsMxMatches(&policy, "a.b.mx.example.test")); + assert(!SMTPMtaStsMxMatches(&policy, "evil-example.test")); + + assert(SMTPMtaStsParsePolicy( + "version: STSv1\nmode: none\nmax_age: 0\n", &policy)); + assert(policy.mode == SMTP_MTA_STS_INACTIVE); + assert(!SMTPMtaStsParsePolicy( + "mode: enforce\nversion: STSv1\nmx: mx.example\nmax_age: 1\n", + &policy)); + assert(!SMTPMtaStsParsePolicy( + "version: STSv1\nmode: enforce\nmax_age: 1\n", &policy)); + assert(!SMTPMtaStsParsePolicy( + "version: STSv1\nmode: enforce\nmx: bad..example\nmax_age: 1\n", + &policy)); + assert(!SMTPMtaStsParsePolicy( + "version: STSv1\nmode: enforce\nmx: -bad.example\nmax_age: 1\n", + &policy)); + assert(!SMTPMtaStsParsePolicy( + "version: STSv1\nmode: enforce\nmx: mx.example\n" + "max_age: 31557601\n", &policy)); + assert(!SMTPMtaStsParsePolicy( + "version: STSv1\nmode: enforce\nmx: mx.example\nmax_age: +1\n", + &policy)); +} + +int +main(void) +{ + TestRecord(); + TestPolicy(); + return 0; +} diff --git a/src/apps/config/config/smtp b/src/apps/config/config/smtp index ce9ad8f..f473c17 100644 --- a/src/apps/config/config/smtp +++ b/src/apps/config/config/smtp @@ -26,6 +26,8 @@ "outbound_tls_verify": false, "outbound_tls_required": false, "outbound_dane_enabled": true, + "outbound_mta_sts_enabled": true, + "outbound_mta_sts_cache_directory": "/var/lib/bongo/cache/mta-sts", "smtp_utf8_enabled": true, "requiretls_enabled": true, "allow_auth": true, diff --git a/src/apps/config/tests/test_configuration.py b/src/apps/config/tests/test_configuration.py index 33d7f9b..9327513 100644 --- a/src/apps/config/tests/test_configuration.py +++ b/src/apps/config/tests/test_configuration.py @@ -165,6 +165,18 @@ class ConfigurationModelTest(unittest.TestCase): messages = [str(issue) for issue in validate_all(configs, defaults, False)] self.assertTrue(any("proxy_protocol_networks" in message for message in messages)) + def test_mta_sts_cache_requires_absolute_path(self): + defaults = templates() + configs = apply_scenario(defaults, Scenario( + hostname="mail.example.test", bind_address="192.0.2.10", + domains=["example.test"], admin_password="0123456789ab", + web_mode="direct", web_public_url="http://mail.example.test:8080", + )) + configs["smtp"]["outbound_mta_sts_cache_directory"] = "relative/cache" + messages = [str(issue) for issue in validate_all(configs, defaults, False)] + self.assertTrue(any("outbound_mta_sts_cache_directory" in message + for message in messages)) + def test_direct_https_binds_443_and_redirects_80(self): defaults = templates() configs = apply_scenario(defaults, Scenario( diff --git a/src/libs/python/bongo/configuration/model.py b/src/libs/python/bongo/configuration/model.py index f32b720..ffb5449 100644 --- a/src/libs/python/bongo/configuration/model.py +++ b/src/libs/python/bongo/configuration/model.py @@ -593,6 +593,11 @@ def _validate_smtp(config: Mapping[str, Any], issues: list[Issue], check_files: _issue(issues, "smtp", "srs_secret_file", "expected an absolute path") elif check_files and not os.path.isfile(secret): _issue(issues, "smtp", "srs_secret_file", "secret file does not exist", "warning") + if config.get("outbound_mta_sts_enabled"): + cache_directory = config.get("outbound_mta_sts_cache_directory") + if not isinstance(cache_directory, str) or not os.path.isabs(cache_directory): + _issue(issues, "smtp", "outbound_mta_sts_cache_directory", + "expected an absolute path") def _validate_mail_listener(name: str, config: Mapping[str, Any], issues: list[Issue]) -> None: diff --git a/src/libs/xpl/CMakeLists.txt b/src/libs/xpl/CMakeLists.txt index 187b591..ff06019 100644 --- a/src/libs/xpl/CMakeLists.txt +++ b/src/libs/xpl/CMakeLists.txt @@ -34,4 +34,8 @@ if(BUILD_TESTING) add_executable(xpl-dns-mx-test tests/dns-mx-test.c) target_link_libraries(xpl-dns-mx-test PRIVATE bongoxpl) add_test(NAME xpl-dns-mx COMMAND xpl-dns-mx-test) + + add_executable(xpl-dns-txt-test tests/dns-txt-test.c) + target_link_libraries(xpl-dns-txt-test PRIVATE bongoxpl) + add_test(NAME xpl-dns-txt COMMAND xpl-dns-txt-test) endif() diff --git a/src/libs/xpl/dns.c b/src/libs/xpl/dns.c index 15fe3ee..0633be8 100644 --- a/src/libs/xpl/dns.c +++ b/src/libs/xpl/dns.c @@ -420,6 +420,9 @@ _XplDns_ParseQuery(const unsigned char *answer_buffer, int answer_len, XplDns_Re int x; int res; + if (answer_buffer == NULL || result == NULL || + answer_len < (int)sizeof(XplDnsResponseHeader)) return; + // try to parse the answer header = (XplDnsResponseHeader *)answer_buffer; result->dnssec_authenticated = ((HEADER *)answer_buffer)->ad != 0; @@ -436,6 +439,7 @@ _XplDns_ParseQuery(const unsigned char *answer_buffer, int answer_len, XplDns_Re if (size < 0) return; // Skip QCODE / QCLASS + if ((size_t)(response_end - response) < (size_t)size + 4U) return; response += size + 4; // does the buffer extent look right? @@ -448,25 +452,39 @@ _XplDns_ParseQuery(const unsigned char *answer_buffer, int answer_len, XplDns_Re char answer_domain[XPLDNS_NAMELEN + 1]; item = MemMalloc(sizeof(XplDns_RecordList)); + if (item == NULL) return; item->next = NULL; answer_domain[0] = '\0'; res = _XplDns_ParseName(answer_buffer, answer_len, response, answer_domain, XPLDNS_NAMELEN); - if (res < 0) return; // couldn't parse name... + if (res < 0) { + MemFree(item); + return; // couldn't parse name... + } response += res; - if (response >= response_end) return; + if ((size_t)(response_end - response) < sizeof(XplDnsAnswer)) { + MemFree(item); + return; + } dns_answer = (XplDnsAnswer *)response; response += sizeof(XplDnsAnswer); - if (response >= response_end) return; + if ((size_t)(response_end - response) < ntohs(dns_answer->size)) { + MemFree(item); + return; + } // TODO : check ntohs(ans->class) is right switch(ntohs(dns_answer->rtype)) { case XPLDNS_RR_A: { + if (ntohs(dns_answer->size) != sizeof(XplDnsAnswerARecord)) { + MemFree(item); + return; + } item->type = XPLDNS_RR_A; XplDnsAnswerARecord *rec = (XplDnsAnswerARecord *)response; _XplDns_CopyName(item->record.A.name, answer_domain); @@ -474,6 +492,30 @@ _XplDns_ParseQuery(const unsigned char *answer_buffer, int answer_len, XplDns_Re _XplDnsResult_AppendRecord(result, item); } break; + case XPLDNS_RR_TXT: { + unsigned int offset = 0U; + unsigned int output = 0U; + unsigned int record_size = ntohs(dns_answer->size); + + item->type = XPLDNS_RR_TXT; + item->record.TXT.txt[0] = '\0'; + while (offset < record_size) { + unsigned int chunk = response[offset++]; + + if (chunk > record_size - offset || + chunk > XPLDNS_NAMELEN - output) { + MemFree(item); + return; + } + memcpy(item->record.TXT.txt + output, + response + offset, chunk); + output += chunk; + offset += chunk; + } + item->record.TXT.txt[output] = '\0'; + _XplDnsResult_AppendRecord(result, item); + } + break; case XPLDNS_RR_CNAME: { item->type = XPLDNS_RR_CNAME; @@ -489,6 +531,11 @@ _XplDns_ParseQuery(const unsigned char *answer_buffer, int answer_len, XplDns_Re XplDnsAnswerMxRecord *rec = (XplDnsAnswerMxRecord *)response; int r; + if (ntohs(dns_answer->size) < sizeof(XplDnsAnswerMxRecord)) { + MemFree(item); + return; + } + _XplDns_CopyName(item->record.MX.name, answer_domain); item->type = XPLDNS_RR_MX; item->record.MX.preference = ntohs(rec->pref); @@ -502,6 +549,7 @@ _XplDns_ParseQuery(const unsigned char *answer_buffer, int answer_len, XplDns_Re if (r >= 0) { _XplDnsResult_AppendRecord(result, item); } else { + MemFree(item); return; } } @@ -520,3 +568,9 @@ _XplDns_ParseQuery(const unsigned char *answer_buffer, int answer_len, XplDns_Re result->status = XPLDNS_SUCCESS; return; } + +XplDns_Result * +XplDnsLookupTXT(const char *domain) +{ + return _XplDns_Query(domain, XPLDNS_RR_TXT); +} diff --git a/src/libs/xpl/tests/dns-txt-test.c b/src/libs/xpl/tests/dns-txt-test.c new file mode 100644 index 0000000..e41d2e4 --- /dev/null +++ b/src/libs/xpl/tests/dns-txt-test.c @@ -0,0 +1,62 @@ +/**************************************************************************** + * + * Copyright (c) 2001 Novell, Inc. All Rights Reserved. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of version 2 of the GNU General Public License + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, contact Novell, Inc. + * + ****************************************************************************/ +// Parts Copyright (C) 2026 Mario Fetka. See COPYING for details. + +#include +#include + +#include + +#include "../dns.h" + +static const unsigned char TxtResponse[] = { + 0x12, 0x34, 0x81, 0x80, 0x00, 0x01, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, + 0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', + 0x04, 't', 'e', 's', 't', 0x00, + 0x00, 0x10, 0x00, 0x01, + 0xc0, 0x0c, 0x00, 0x10, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x3c, 0x00, 0x15, + 0x08, 'v', '=', 'S', 'T', 'S', 'v', '1', ';', + 0x0b, ' ', 'i', 'd', '=', '2', '0', '2', '6', '0', '7', '2' +}; + +int +main(void) +{ + XplDns_Result result; + XplDns_Result truncated; + + memset(&result, 0, sizeof(result)); + result.status = XPLDNS_FAIL; + _XplDns_ParseQuery(TxtResponse, sizeof(TxtResponse), &result); + assert(result.status == XPLDNS_SUCCESS); + assert(result.list != NULL); + assert(result.list->type == XPLDNS_RR_TXT); + assert(strcmp(result.list->record.TXT.txt, + "v=STSv1; id=2026072") == 0); + assert(result.list->next == NULL); + MemFree(result.list); + + memset(&truncated, 0, sizeof(truncated)); + truncated.status = XPLDNS_FAIL; + _XplDns_ParseQuery(TxtResponse, sizeof(TxtResponse) - 1U, &truncated); + assert(truncated.status == XPLDNS_FAIL); + assert(truncated.list == NULL); + return 0; +}