Enforce SMTP MTA-STS destination policies
Debian Trixie package bundle / packages (push) Successful in 14m54s

This commit is contained in:
Mario Fetka
2026-07-21 08:36:33 +02:00
parent 767eec8b3a
commit cf88aa3a9b
17 changed files with 868 additions and 4 deletions
+2 -1
View File
@@ -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
+1
View File
@@ -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 | | | |
+11
View File
@@ -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.<domain>`, 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
+1
View File
@@ -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);
+1
View File
@@ -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@ -
+14
View File
@@ -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()
+507
View File
@@ -0,0 +1,507 @@
/****************************************************************************
* <Novell-copyright>
* 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.
* </Novell-copyright>
****************************************************************************/
// Parts Copyright (C) 2026 Mario Fetka. See COPYING for details.
#include <config.h>
#include <ctype.h>
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/stat.h>
#include <unistd.h>
#include <curl/curl.h>
#include <xpldns.h>
#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;
}
+51
View File
@@ -0,0 +1,51 @@
/****************************************************************************
* <Novell-copyright>
* 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.
* </Novell-copyright>
****************************************************************************/
// Parts Copyright (C) 2026 Mario Fetka. See COPYING for details.
#ifndef _SMTP_MTA_STS_H
#define _SMTP_MTA_STS_H
#include <stddef.h>
#include <time.h>
#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
+52
View File
@@ -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;
+1
View File
@@ -34,6 +34,7 @@
#include "smtp.h"
#include "dane.h"
#include "mta-sts.h"
#define AGENT_NAME "smtpd_c"
+85
View File
@@ -0,0 +1,85 @@
/****************************************************************************
* <Novell-copyright>
* 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.
* </Novell-copyright>
****************************************************************************/
// Parts Copyright (C) 2026 Mario Fetka. See COPYING for details.
#include <assert.h>
#include <string.h>
#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;
}
+2
View File
@@ -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,
@@ -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(
@@ -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:
+4
View File
@@ -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()
+57 -3
View File
@@ -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);
}
+62
View File
@@ -0,0 +1,62 @@
/****************************************************************************
* <Novell-copyright>
* 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.
* </Novell-copyright>
****************************************************************************/
// Parts Copyright (C) 2026 Mario Fetka. See COPYING for details.
#include <assert.h>
#include <string.h>
#include <xpl.h>
#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;
}