Verify internal SMTP sender normalization
Debian Trixie package bundle / packages (push) Successful in 21m44s

This commit is contained in:
Mario Fetka
2026-07-24 07:37:25 +02:00
parent c7fdefe9d3
commit 14fbb55a38
9 changed files with 918 additions and 128 deletions
+24
View File
@@ -30,6 +30,10 @@ one-off files in `/tmp`:
limits, verifies allowed and denied source addresses plus connection,
recipient, message, and byte limits, then restores the exact SMTP
configuration without committing a message.
- `smtp-internal-normalization-check.py` sends only to the reserved
`.invalid` namespace through trusted port 26, inspects the real Queue
routing envelope for eight sender-normalization cases, deletes every test
entry, and restores the exact SMTP configuration.
- `c-literal-length-check.py` audits manually sized string literals passed to
Bongo connection I/O helpers and rejects lengths that include a trailing
NUL, truncate a protocol line, or read past the literal.
@@ -378,6 +382,26 @@ and then requires two successful SMTP readiness sessions. Override the grace
period with `BONGO_TEST_STARTUP_GRACE` if slower test hardware needs it. Its
DATA transactions are deliberately rejected before Queue commit.
The companion SMTP-05 check temporarily maps the allowed source IP to the
device name `matrix` and points remote delivery at unreachable loopback
`127.0.0.2`. This safely leaves each accepted `.invalid` message available
for `bongo-queuetool show`, where the script verifies the actual Queue
envelope rather than log text:
```sh
export BONGO_ALLOW_LIVE_SMTP_TEST=1
./contrib/testing/smtp-internal-normalization-check.py
```
It covers `root@localhost`, another local user, case-insensitive
`localhost.localdomain`, single and nested internal subdomains, a lookalike
domain-boundary attack, an unrelated valid domain, and the null reverse
path. Focused native tests cover explicit IP mapping, DNS suffix boundaries,
forward-confirmed reverse DNS, invalid host labels, truncation, and the rule
that either authenticated submission or a trusted internal connection may
relay to a remote recipient. A source outside
`internal_relay_networks` remains rejected before the SMTP greeting.
## Authenticated external SMTP provider
Xeams DevNullSMTP is useful as a deliberately simple relay capture, but it
+363
View File
@@ -0,0 +1,363 @@
#!/usr/bin/env python3
# This program is free software, licensed under the terms of the GNU GPL.
# See the Bongo COPYING file for full details.
# Copyright (c) 2026 Bongo Project contributors
"""Verify internal SMTP sender normalization in the real Queue envelope."""
from __future__ import annotations
import copy
import json
import os
import re
import socket
import subprocess
import sys
import time
ADMIN = os.environ.get("BONGO_TEST_ADMIN", "/usr/bin/bongo-admin")
QUEUE_TOOL = os.environ.get(
"BONGO_TEST_QUEUE_TOOL", "/usr/bin/bongo-queuetool")
SYSTEMCTL = os.environ.get("BONGO_TEST_SYSTEMCTL", "/usr/bin/systemctl")
HOST = os.environ.get("BONGO_TEST_INTERNAL_HOST", "172.16.11.190")
PORT = int(os.environ.get("BONGO_TEST_INTERNAL_PORT", "26"))
SOURCE = os.environ.get("BONGO_TEST_ALLOWED_SOURCE", HOST)
DOMAIN = os.environ.get("BONGO_TEST_DOMAIN", "bongo.test")
DEVICE = os.environ.get("BONGO_TEST_DEVICE", "matrix")
RECIPIENT = os.environ.get(
"BONGO_TEST_REMOTE_RECIPIENT", "capture@normalization.invalid")
TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "10"))
STARTUP_GRACE = float(os.environ.get("BONGO_TEST_STARTUP_GRACE", "2"))
ALLOW_LIVE = os.environ.get("BONGO_ALLOW_LIVE_SMTP_TEST") == "1"
RESPONSE = re.compile(rb"^([0-9]{3})([- ])(.*)\r\n$")
QUEUE_ID = re.compile(r"^[0-9]{3}-[0-9a-f]+$")
TOKEN = f"smtp05-{os.getpid()}-{int(time.time())}"
class NormalizationCheckError(RuntimeError):
"""Raised when live sender normalization differs from its contract."""
def run(
arguments: list[str],
*,
input_text: str | None = None,
binary: bool = False,
check: bool = True,
) -> subprocess.CompletedProcess:
completed = subprocess.run(
arguments,
input=input_text,
capture_output=True,
text=not binary,
check=False,
)
if check and completed.returncode:
stdout = completed.stdout
stderr = completed.stderr
if binary:
stdout = stdout.decode("utf-8", "replace")
stderr = stderr.decode("utf-8", "replace")
raise NormalizationCheckError(
f"{' '.join(arguments)} failed: "
f"{stderr.strip() or stdout.strip()}")
return completed
def admin(*arguments: str, input_text: str | None = None) -> str:
return run(
["sudo", "-n", ADMIN, *arguments],
input_text=input_text,
).stdout
def queue(*arguments: str, binary: bool = False,
check: bool = True) -> subprocess.CompletedProcess:
return run(
["sudo", "-n", "-u", "bongo", QUEUE_TOOL, *arguments],
binary=binary,
check=check,
)
def read_configuration() -> dict:
try:
configuration = json.loads(admin("__config-read", "smtp"))
except json.JSONDecodeError as error:
raise NormalizationCheckError(
"bongo-admin returned invalid SMTP configuration") from error
if not isinstance(configuration, dict):
raise NormalizationCheckError("SMTP configuration is not an object")
return configuration
def replace_configuration(configuration: dict) -> None:
admin(
"__config-replace", "smtp",
input_text=json.dumps(configuration, separators=(",", ":")),
)
def queue_ids() -> set[str]:
identifiers: set[str] = set()
for line in queue("list").stdout.splitlines():
fields = line.split()
if fields and QUEUE_ID.fullmatch(fields[0]):
identifiers.add(fields[0])
return identifiers
def token_queue_ids(baseline: set[str]) -> list[str]:
identifiers: list[str] = []
for queue_id in queue_ids() - baseline:
message = queue("message", queue_id, binary=True, check=False)
if message.returncode == 0 and TOKEN.encode("ascii") in message.stdout:
identifiers.append(queue_id)
return identifiers
def cleanup_entries(baseline: set[str]) -> None:
for queue_id in token_queue_ids(baseline):
queue("delete", queue_id, check=False)
def wait_port() -> None:
deadline = time.monotonic() + 30
consecutive = 0
while time.monotonic() < deadline:
client = None
try:
client = SMTPConnection()
client.response(220)
client.command("QUIT", 221)
consecutive += 1
if consecutive == 2:
return
time.sleep(0.25)
except (OSError, NormalizationCheckError):
consecutive = 0
time.sleep(0.1)
finally:
if client is not None:
client.close()
raise NormalizationCheckError(
f"internal SMTP did not become ready at {HOST}:{PORT}")
def restart_bongo() -> None:
run(["sudo", "-n", SYSTEMCTL, "restart", "bongo.service"])
time.sleep(STARTUP_GRACE)
wait_port()
if run(
["sudo", "-n", SYSTEMCTL, "is-active", "bongo.service"]
).stdout.strip() != "active":
raise NormalizationCheckError("bongo.service is not active")
class SMTPConnection:
def __init__(self) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(TIMEOUT)
try:
sock.bind((SOURCE, 0))
sock.connect((HOST, PORT))
except BaseException:
sock.close()
raise
self.socket = sock
self.reader = sock.makefile("rb")
def close(self) -> None:
try:
self.reader.close()
finally:
self.socket.close()
def response(self, expected: int) -> list[bytes]:
lines: list[bytes] = []
response_code = -1
while True:
line = self.reader.readline(8194)
if not line:
raise NormalizationCheckError(
f"connection closed while waiting for SMTP {expected}")
match = RESPONSE.match(line)
if match is None:
raise NormalizationCheckError(
f"malformed SMTP response: {line!r}")
code = int(match.group(1))
if response_code == -1:
response_code = code
elif code != response_code:
raise NormalizationCheckError(
f"multiline SMTP response changed code: {line!r}")
if code != expected:
raise NormalizationCheckError(
f"expected SMTP {expected}, received {line!r}")
lines.append(match.group(3))
if match.group(2) == b" ":
return lines
def command(self, command: str, expected: int) -> list[bytes]:
self.socket.sendall(command.encode("ascii") + b"\r\n")
return self.response(expected)
def submit(sender: str | None, label: str) -> None:
client = SMTPConnection()
try:
client.response(220)
client.command("EHLO smtp05-device.bongo.test", 250)
if sender is None:
client.command("MAIL FROM:<>", 250)
else:
client.command(f"MAIL FROM:<{sender}>", 250)
client.command(f"RCPT TO:<{RECIPIENT}>", 250)
client.command("DATA", 354)
message = (
f"From: device@{DOMAIN}\r\n"
f"To: {RECIPIENT}\r\n"
f"Subject: SMTP-05 {label} {TOKEN}\r\n"
f"Message-ID: <{TOKEN}-{label}@{DOMAIN}>\r\n"
"\r\n"
f"Internal sender normalization check {TOKEN} {label}\r\n"
).encode("ascii")
client.socket.sendall(message + b".\r\n")
client.response(250)
client.command("QUIT", 221)
finally:
client.close()
def wait_envelope(baseline: set[str], expected_sender: str) -> str:
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
matches = token_queue_ids(baseline)
if len(matches) > 1:
raise NormalizationCheckError(
f"test created multiple Queue entries: {matches!r}")
if matches:
queue_id = matches[0]
envelope = queue("show", queue_id, binary=True).stdout
from_lines = [
line[1:].decode("utf-8", "strict").split()
for line in envelope.splitlines() if line.startswith(b"F")
]
if len(from_lines) != 1 or not from_lines[0]:
raise NormalizationCheckError(
f"Queue entry {queue_id} has invalid FROM envelope")
actual_sender = from_lines[0][0]
if actual_sender != expected_sender:
raise NormalizationCheckError(
f"expected envelope sender {expected_sender!r}, "
f"received {actual_sender!r}")
return queue_id
time.sleep(0.1)
raise NormalizationCheckError(
f"no deferred Queue entry containing {TOKEN} appeared")
def check_case(
baseline: set[str], label: str, sender: str | None, expected: str
) -> None:
submit(sender, label)
queue_id = wait_envelope(baseline, expected)
queue("delete", queue_id)
def main() -> int:
if not ALLOW_LIVE:
raise NormalizationCheckError(
"set BONGO_ALLOW_LIVE_SMTP_TEST=1 for this temporary "
"live-configuration test")
if STARTUP_GRACE < 0:
raise NormalizationCheckError(
"BONGO_TEST_STARTUP_GRACE cannot be negative")
original = read_configuration()
if not original.get("internal_relay_enabled"):
raise NormalizationCheckError("internal SMTP is not enabled")
if original.get("internal_relay_bind_address") != HOST:
raise NormalizationCheckError(
"BONGO_TEST_INTERNAL_HOST does not match live configuration")
if int(original.get("internal_relay_port", 0)) != PORT:
raise NormalizationCheckError(
"BONGO_TEST_INTERNAL_PORT does not match live configuration")
if original.get("internal_relay_domain") != DOMAIN:
raise NormalizationCheckError(
"BONGO_TEST_DOMAIN does not match internal relay domain")
baseline = queue_ids()
test_configuration = copy.deepcopy(original)
test_configuration["internal_relay_device_mappings"] = [
f"{SOURCE}={DEVICE}"]
test_configuration["internal_relay_dns_suffixes"] = []
test_configuration["use_relay_host"] = True
test_configuration["relay_host"] = "127.0.0.2"
test_configuration["internal_relay_messages_per_minute"] = max(
100, int(original.get("internal_relay_messages_per_minute", 0)))
test_configuration["internal_relay_recipients_per_minute"] = max(
100, int(original.get("internal_relay_recipients_per_minute", 0)))
restored = False
try:
replace_configuration(test_configuration)
restart_bongo()
cases = [
("device-root", "root@localhost",
f"root+{DEVICE}@{DOMAIN}"),
("local-daemon", "daemon@localdomain",
f"daemon@{DOMAIN}"),
("localhost-fqdn", "ROOT@LOCALHOST.LOCALDOMAIN",
f"ROOT+{DEVICE}@{DOMAIN}"),
("subdomain", f"root@matrix.{DOMAIN}",
f"root+matrix@{DOMAIN}"),
("nested-subdomain", f"alerts@rack3.matrix.{DOMAIN}",
f"alerts+rack3.matrix@{DOMAIN}"),
("external-boundary", f"root@attacker{DOMAIN}",
f"root@attacker{DOMAIN}"),
("external", "sender@example.net", "sender@example.net"),
("null-sender", None, "-"),
]
for label, sender, expected in cases:
print(f"SMTP-05: {label}", file=sys.stderr, flush=True)
check_case(baseline, label, sender, expected)
cleanup_entries(baseline)
replace_configuration(original)
restart_bongo()
restored = read_configuration() == original
if not restored:
raise NormalizationCheckError(
"SMTP configuration did not round-trip after restoration")
finally:
cleanup_entries(baseline)
if not restored:
replace_configuration(original)
restart_bongo()
if token_queue_ids(baseline):
raise NormalizationCheckError("test Queue entries remain after cleanup")
print(
"SMTP-05 PASS "
f"host={HOST}:{PORT} source={SOURCE} device={DEVICE} cases=8 "
"queue-envelope=yes fcrdns=unit-tested external-boundary=yes "
"internet-delivery=no restored=yes"
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (
NormalizationCheckError,
json.JSONDecodeError,
OSError,
UnicodeError,
ValueError,
) as error:
print(f"SMTP-05 FAIL: {error}")
raise SystemExit(1)
+10
View File
@@ -2,6 +2,7 @@
add_executable(bongosmtp
smtpd.c
internal-relay.c
capabilities.c
auth-results.c
protocol.c
@@ -75,6 +76,15 @@ if(BUILD_TESTING)
)
add_test(NAME smtp-protocol COMMAND smtp-protocol-test)
add_executable(smtp-internal-relay-test
tests/internal-relay-test.c
internal-relay.c
)
target_include_directories(smtp-internal-relay-test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
)
add_test(NAME smtp-internal-relay COMMAND smtp-internal-relay-test)
add_executable(smtp-proxy-test
tests/proxy-test.c
proxy.c
+225
View File
@@ -0,0 +1,225 @@
/****************************************************************************
* <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.
*
* To contact Novell about this file by physical or electronic mail, you
* may find current contact information at www.novell.com.
* </Novell-copyright>
****************************************************************************/
#include "internal-relay.h"
#include <arpa/inet.h>
#include <ctype.h>
#include <netdb.h>
#include <stdio.h>
#include <string.h>
#include <strings.h>
int
SMTPNormalizeInternalSender(const char *sender, const char *device,
const char *relay_domain, char *normalized,
size_t normalized_size)
{
const char *at;
const char *domain;
size_t local_length;
size_t relay_length;
size_t domain_length;
int length;
if (sender == NULL || normalized == NULL || normalized_size == 0U ||
relay_domain == NULL || *relay_domain == '\0')
return 0;
at = strrchr(sender, '@');
if (at == NULL || at == sender || at[1] == '\0')
return 0;
domain = at + 1;
local_length = (size_t)(at - sender);
relay_length = strlen(relay_domain);
domain_length = strlen(domain);
if (strcasecmp(domain, "localhost") == 0 ||
strcasecmp(domain, "localdomain") == 0 ||
strcasecmp(domain, "localhost.localdomain") == 0) {
if (device != NULL && *device != '\0' &&
local_length == 4U && strncasecmp(sender, "root", 4U) == 0) {
length = snprintf(normalized, normalized_size, "%.*s+%s@%s",
(int)local_length, sender, device, relay_domain);
} else {
length = snprintf(normalized, normalized_size, "%.*s@%s",
(int)local_length, sender, relay_domain);
}
return length >= 0 && (size_t)length < normalized_size;
}
if (domain_length > relay_length + 1U &&
domain[domain_length - relay_length - 1U] == '.' &&
strcasecmp(domain + domain_length - relay_length,
relay_domain) == 0) {
size_t subdomain_length = domain_length - relay_length - 1U;
length = snprintf(normalized, normalized_size, "%.*s+%.*s@%s",
(int)local_length, sender, (int)subdomain_length,
domain, relay_domain);
return length >= 0 && (size_t)length < normalized_size;
}
return 0;
}
int
SMTPRemoteRecipientAllowed(int authenticated, int trusted)
{
return authenticated || trusted;
}
int
SMTPInternalRelayDeviceNameValid(const char *name)
{
const unsigned char *position = (const unsigned char *)name;
size_t length;
if (position == NULL)
return 0;
length = strlen(name);
if (length == 0U || length > 63U ||
!isalnum(position[0]) || !isalnum(position[length - 1U]))
return 0;
while (*position != '\0') {
if (!isalnum(*position) && *position != '-')
return 0;
position++;
}
return 1;
}
int
SMTPInternalRelayHostHasAllowedSuffix(
const char *host, const char *const *suffixes, size_t suffix_count)
{
size_t index;
size_t host_length;
if (host == NULL || suffixes == NULL)
return 0;
host_length = strlen(host);
for (index = 0U; index < suffix_count; index++) {
const char *suffix = suffixes[index];
size_t suffix_length = suffix != NULL ? strlen(suffix) : 0U;
if (suffix_length != 0U && host_length > suffix_length + 1U &&
host[host_length - suffix_length - 1U] == '.' &&
strcasecmp(host + host_length - suffix_length, suffix) == 0)
return 1;
}
return 0;
}
static int
SystemReverseLookup(struct in_addr address, char *host, size_t host_size,
void *context)
{
struct sockaddr_in peer;
(void)context;
memset(&peer, 0, sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_addr = address;
return getnameinfo((struct sockaddr *)&peer, sizeof(peer), host,
(socklen_t)host_size, NULL, 0, NI_NAMEREQD) == 0;
}
static int
SystemForwardMatch(const char *host, struct in_addr address, void *context)
{
struct addrinfo hints;
struct addrinfo *answers = NULL;
struct addrinfo *answer;
int matched = 0;
(void)context;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
if (getaddrinfo(host, NULL, &hints, &answers) != 0)
return 0;
for (answer = answers; answer != NULL; answer = answer->ai_next) {
const struct sockaddr_in *resolved =
(const struct sockaddr_in *)answer->ai_addr;
if (resolved->sin_addr.s_addr == address.s_addr) {
matched = 1;
break;
}
}
freeaddrinfo(answers);
return matched;
}
int
SMTPIdentifyInternalDeviceWithResolvers(
struct in_addr address,
const char *const *mappings, size_t mapping_count,
const char *const *suffixes, size_t suffix_count,
SMTPInternalRelayReverseLookup reverse_lookup,
SMTPInternalRelayForwardMatch forward_match,
void *context, char *device, size_t device_size)
{
char ip[INET_ADDRSTRLEN];
char host[NI_MAXHOST];
size_t index;
char *dot;
int length;
if (device == NULL || device_size == 0U ||
reverse_lookup == NULL || forward_match == NULL ||
inet_ntop(AF_INET, &address, ip, sizeof(ip)) == NULL)
return 0;
for (index = 0U; mappings != NULL && index < mapping_count; index++) {
const char *mapping = mappings[index];
const char *equals = mapping != NULL ? strchr(mapping, '=') : NULL;
if (equals != NULL && (size_t)(equals - mapping) == strlen(ip) &&
strncmp(mapping, ip, strlen(ip)) == 0 &&
SMTPInternalRelayDeviceNameValid(equals + 1)) {
length = snprintf(device, device_size, "%s", equals + 1);
return length >= 0 && (size_t)length < device_size;
}
}
if (!reverse_lookup(address, host, sizeof(host), context) ||
!SMTPInternalRelayHostHasAllowedSuffix(host, suffixes, suffix_count) ||
!forward_match(host, address, context))
return 0;
dot = strchr(host, '.');
if (dot == NULL)
return 0;
*dot = '\0';
if (!SMTPInternalRelayDeviceNameValid(host))
return 0;
length = snprintf(device, device_size, "%s", host);
return length >= 0 && (size_t)length < device_size;
}
int
SMTPIdentifyInternalDevice(
struct in_addr address,
const char *const *mappings, size_t mapping_count,
const char *const *suffixes, size_t suffix_count,
char *device, size_t device_size)
{
return SMTPIdentifyInternalDeviceWithResolvers(
address, mappings, mapping_count, suffixes, suffix_count,
SystemReverseLookup, SystemForwardMatch, NULL, device, device_size);
}
+53
View File
@@ -0,0 +1,53 @@
/****************************************************************************
* <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.
*
* To contact Novell about this file by physical or electronic mail, you
* may find current contact information at www.novell.com.
* </Novell-copyright>
****************************************************************************/
#ifndef BONGO_SMTP_INTERNAL_RELAY_H
#define BONGO_SMTP_INTERNAL_RELAY_H
#include <netinet/in.h>
#include <stddef.h>
typedef int (*SMTPInternalRelayReverseLookup)(
struct in_addr address, char *host, size_t host_size, void *context);
typedef int (*SMTPInternalRelayForwardMatch)(
const char *host, struct in_addr address, void *context);
int SMTPNormalizeInternalSender(const char *sender, const char *device,
const char *relay_domain, char *normalized,
size_t normalized_size);
int SMTPRemoteRecipientAllowed(int authenticated, int trusted);
int SMTPInternalRelayDeviceNameValid(const char *name);
int SMTPInternalRelayHostHasAllowedSuffix(
const char *host, const char *const *suffixes, size_t suffix_count);
int SMTPIdentifyInternalDeviceWithResolvers(
struct in_addr address,
const char *const *mappings, size_t mapping_count,
const char *const *suffixes, size_t suffix_count,
SMTPInternalRelayReverseLookup reverse_lookup,
SMTPInternalRelayForwardMatch forward_match,
void *context, char *device, size_t device_size);
int SMTPIdentifyInternalDevice(
struct in_addr address,
const char *const *mappings, size_t mapping_count,
const char *const *suffixes, size_t suffix_count,
char *device, size_t device_size);
#endif
+22 -125
View File
@@ -50,6 +50,7 @@
#include <bongoutil.h>
#include "mailauth.h"
#include "smtpd.h"
#include "internal-relay.h"
#include "auth-results.h"
#include "external_accounts.h"
#include "capabilities.h"
@@ -316,45 +317,6 @@ InternalRelayConsume(ConnectionStruct *client, unsigned int messages,
return allowed;
}
static BOOL
NormalizeInternalSender(const char *sender, const char *device, char *normalized,
size_t normalizedSize)
{
const char *at;
const char *domain;
size_t localLength;
size_t domainLength;
if (sender == NULL || normalized == NULL || normalizedSize == 0 ||
SMTP.internal_relay_domain == NULL || *SMTP.internal_relay_domain == '\0') return FALSE;
at = strrchr(sender, '@');
if (at == NULL || at == sender || at[1] == '\0') return FALSE;
domain = at + 1;
localLength = (size_t) (at - sender);
domainLength = strlen(domain);
if (strcasecmp(domain, "localhost") == 0 ||
strcasecmp(domain, "localdomain") == 0 ||
strcasecmp(domain, "localhost.localdomain") == 0) {
if (device != NULL && *device != '\0' &&
(localLength == 4 && strncasecmp(sender, "root", 4) == 0)) {
return snprintf(normalized, normalizedSize, "%.*s+%s@%s", (int) localLength,
sender, device, SMTP.internal_relay_domain) < (int) normalizedSize;
}
return snprintf(normalized, normalizedSize, "%.*s@%s", (int) localLength,
sender, SMTP.internal_relay_domain) < (int) normalizedSize;
}
if (domainLength > strlen(SMTP.internal_relay_domain) + 1 &&
domain[domainLength - strlen(SMTP.internal_relay_domain) - 1] == '.' &&
strcasecmp(domain + domainLength - strlen(SMTP.internal_relay_domain),
SMTP.internal_relay_domain) == 0) {
size_t subdomainLength = domainLength - strlen(SMTP.internal_relay_domain) - 1;
return snprintf(normalized, normalizedSize, "%.*s+%.*s@%s", (int) localLength,
sender, (int) subdomainLength, domain,
SMTP.internal_relay_domain) < (int) normalizedSize;
}
return FALSE;
}
static BOOL
AddressInNetwork(struct in_addr address, const char *network)
{
@@ -419,85 +381,6 @@ InternalRelayAddressAllowed(struct in_addr address)
return FALSE;
}
static BOOL
ValidDeviceName(const char *name)
{
const unsigned char *p = (const unsigned char *) name;
if (p == NULL || *p == '\0') return FALSE;
while (*p != '\0') {
if (!isalnum(*p) && *p != '-') return FALSE;
p++;
}
return TRUE;
}
static BOOL
HostHasAllowedSuffix(const char *host)
{
unsigned int index;
size_t hostLength = strlen(host);
for (index = 0; SMTP.internal_relay_dns_suffixes != NULL &&
index < SMTP.internal_relay_dns_suffixes->len; index++) {
const char *suffix = g_array_index(SMTP.internal_relay_dns_suffixes, char *, index);
size_t suffixLength = suffix != NULL ? strlen(suffix) : 0;
if (suffixLength && hostLength > suffixLength &&
host[hostLength - suffixLength - 1] == '.' &&
strcasecmp(host + hostLength - suffixLength, suffix) == 0) return TRUE;
}
return FALSE;
}
static BOOL
IdentifyInternalDevice(struct in_addr address, char *device, size_t deviceSize)
{
unsigned int index;
char ip[INET_ADDRSTRLEN];
char host[NI_MAXHOST];
struct sockaddr_in peer;
struct addrinfo hints;
struct addrinfo *answers = NULL;
struct addrinfo *answer;
BOOL forwardMatch = FALSE;
if (inet_ntop(AF_INET, &address, ip, sizeof(ip)) == NULL) return FALSE;
for (index = 0; SMTP.internal_relay_device_mappings != NULL &&
index < SMTP.internal_relay_device_mappings->len; index++) {
const char *mapping = g_array_index(SMTP.internal_relay_device_mappings, char *, index);
const char *equals = mapping != NULL ? strchr(mapping, '=') : NULL;
if (equals != NULL && (size_t) (equals - mapping) == strlen(ip) &&
strncmp(mapping, ip, strlen(ip)) == 0 && ValidDeviceName(equals + 1)) {
snprintf(device, deviceSize, "%s", equals + 1);
return TRUE;
}
}
memset(&peer, 0, sizeof(peer));
peer.sin_family = AF_INET;
peer.sin_addr = address;
if (getnameinfo((struct sockaddr *) &peer, sizeof(peer), host, sizeof(host),
NULL, 0, NI_NAMEREQD) != 0 || !HostHasAllowedSuffix(host)) return FALSE;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
if (getaddrinfo(host, NULL, &hints, &answers) != 0) return FALSE;
for (answer = answers; answer != NULL; answer = answer->ai_next) {
struct sockaddr_in *resolved = (struct sockaddr_in *) answer->ai_addr;
if (resolved->sin_addr.s_addr == address.s_addr) {
forwardMatch = TRUE;
break;
}
}
freeaddrinfo(answers);
if (forwardMatch) {
char *dot = strchr(host, '.');
if (dot != NULL) *dot = '\0';
if (ValidDeviceName(host)) {
snprintf(device, deviceSize, "%s", host);
return TRUE;
}
}
return FALSE;
}
static const char *
SPFResultName(BongoSPFResult result)
{
@@ -1164,9 +1047,19 @@ HandleConnection (void *param)
time (&connectionTime);
if (Client->InternalRelay &&
IdentifyInternalDevice(Client->client.conn->socketAddress.sin_addr,
Client->InternalDevice,
sizeof(Client->InternalDevice))) {
SMTPIdentifyInternalDevice(
Client->client.conn->socketAddress.sin_addr,
SMTP.internal_relay_device_mappings != NULL
? (const char *const *)SMTP.internal_relay_device_mappings->data
: NULL,
SMTP.internal_relay_device_mappings != NULL
? SMTP.internal_relay_device_mappings->len : 0U,
SMTP.internal_relay_dns_suffixes != NULL
? (const char *const *)SMTP.internal_relay_dns_suffixes->data
: NULL,
SMTP.internal_relay_dns_suffixes != NULL
? SMTP.internal_relay_dns_suffixes->len : 0U,
Client->InternalDevice, sizeof(Client->InternalDevice))) {
Log(LOG_INFO, "Identified internal SMTP device %s at %s",
Client->InternalDevice, LOGIP(Client->client.conn->socketAddress));
}
@@ -1683,7 +1576,8 @@ HandleConnection (void *param)
}
switch (ReplyInt) {
case MAIL_REMOTE:{
if (!IsAuthed) {
if (!SMTPRemoteRecipientAllowed(
IsAuthed, IsTrusted)) {
Log(LOG_INFO, "Recipient %s by host %s blocked",
name, LOGIP(Client->client.conn->socketAddress));
ConnWrite (Client->client.conn,
@@ -2052,8 +1946,10 @@ HandleConnection (void *param)
name = canonicalSender;
if (Client->InternalRelay &&
NormalizeInternalSender(name, Client->InternalDevice, normalizedSender,
sizeof(normalizedSender))) {
SMTPNormalizeInternalSender(
name, Client->InternalDevice,
SMTP.internal_relay_domain, normalizedSender,
sizeof(normalizedSender))) {
Log(LOG_INFO, "Normalized internal sender %s to %s for %s",
name, normalizedSender, LOGIP(Client->client.conn->socketAddress));
name = normalizedSender;
@@ -5720,7 +5616,8 @@ ValidateListenerConfiguration(void)
char ip[INET_ADDRSTRLEN];
struct in_addr parsed;
size_t length = equals != NULL ? (size_t) (equals - mapping) : 0;
if (length == 0 || length >= sizeof(ip) || !ValidDeviceName(equals + 1)) {
if (length == 0 || length >= sizeof(ip) ||
!SMTPInternalRelayDeviceNameValid(equals + 1)) {
Log(LOG_ERROR, "Invalid internal SMTP device mapping: %s",
mapping != NULL ? mapping : "(null)");
return FALSE;
+173
View File
@@ -0,0 +1,173 @@
/****************************************************************************
* <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.
*
* To contact Novell about this file by physical or electronic mail, you
* may find current contact information at www.novell.com.
* </Novell-copyright>
****************************************************************************/
#include "internal-relay.h"
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <string.h>
typedef struct {
const char *reverse_host;
int forward_match;
unsigned int reverse_calls;
unsigned int forward_calls;
} ResolverFixture;
static int
ReverseLookup(struct in_addr address, char *host, size_t host_size,
void *context)
{
ResolverFixture *fixture = context;
int length;
(void)address;
fixture->reverse_calls++;
if (fixture->reverse_host == NULL)
return 0;
length = snprintf(host, host_size, "%s", fixture->reverse_host);
return length >= 0 && (size_t)length < host_size;
}
static int
ForwardMatch(const char *host, struct in_addr address, void *context)
{
ResolverFixture *fixture = context;
(void)host;
(void)address;
fixture->forward_calls++;
return fixture->forward_match;
}
static void
CheckNormalization(void)
{
char result[256];
char short_result[12];
assert(SMTPNormalizeInternalSender(
"root@localhost", "matrix", "example.test",
result, sizeof(result)));
assert(strcmp(result, "root+matrix@example.test") == 0);
assert(SMTPNormalizeInternalSender(
"ROOT@LOCALHOST.LOCALDOMAIN", "UPS-01", "example.test",
result, sizeof(result)));
assert(strcmp(result, "ROOT+UPS-01@example.test") == 0);
assert(SMTPNormalizeInternalSender(
"cron@localdomain", "matrix", "example.test",
result, sizeof(result)));
assert(strcmp(result, "cron@example.test") == 0);
assert(SMTPNormalizeInternalSender(
"root@matrix.example.test", NULL, "example.test",
result, sizeof(result)));
assert(strcmp(result, "root+matrix@example.test") == 0);
assert(SMTPNormalizeInternalSender(
"alerts@rack3.matrix.example.test", NULL, "example.test",
result, sizeof(result)));
assert(strcmp(result, "alerts+rack3.matrix@example.test") == 0);
assert(!SMTPNormalizeInternalSender(
"root@example.test", "matrix", "example.test",
result, sizeof(result)));
assert(!SMTPNormalizeInternalSender(
"root@attackerexample.test", "matrix", "example.test",
result, sizeof(result)));
assert(!SMTPNormalizeInternalSender(
"root@example.net", "matrix", "example.test",
result, sizeof(result)));
assert(!SMTPNormalizeInternalSender(
"root@localhost", "matrix", "example.test",
short_result, sizeof(short_result)));
assert(SMTPRemoteRecipientAllowed(1, 0));
assert(SMTPRemoteRecipientAllowed(0, 1));
assert(SMTPRemoteRecipientAllowed(1, 1));
assert(!SMTPRemoteRecipientAllowed(0, 0));
}
static void
CheckDeviceIdentity(void)
{
struct in_addr address;
const char *mappings[] = {
"192.0.2.8=wrong",
"192.0.2.7=ups-01"
};
const char *invalid_mappings[] = {
"192.0.2.7=ups_01"
};
const char *suffixes[] = { "example.test" };
char device[64];
ResolverFixture fixture = {
.reverse_host = "printer.example.test",
.forward_match = 1
};
assert(inet_pton(AF_INET, "192.0.2.7", &address) == 1);
assert(SMTPIdentifyInternalDeviceWithResolvers(
address, mappings, 2U, suffixes, 1U,
ReverseLookup, ForwardMatch, &fixture, device, sizeof(device)));
assert(strcmp(device, "ups-01") == 0);
assert(fixture.reverse_calls == 0U && fixture.forward_calls == 0U);
assert(SMTPIdentifyInternalDeviceWithResolvers(
address, invalid_mappings, 1U, suffixes, 1U,
ReverseLookup, ForwardMatch, &fixture, device, sizeof(device)));
assert(strcmp(device, "printer") == 0);
assert(fixture.reverse_calls == 1U && fixture.forward_calls == 1U);
fixture.reverse_host = "printer.attackerexample.test";
assert(!SMTPIdentifyInternalDeviceWithResolvers(
address, NULL, 0U, suffixes, 1U,
ReverseLookup, ForwardMatch, &fixture, device, sizeof(device)));
fixture.reverse_host = "printer.example.test";
fixture.forward_match = 0;
assert(!SMTPIdentifyInternalDeviceWithResolvers(
address, NULL, 0U, suffixes, 1U,
ReverseLookup, ForwardMatch, &fixture, device, sizeof(device)));
fixture.reverse_host = "bad_name.example.test";
fixture.forward_match = 1;
assert(!SMTPIdentifyInternalDeviceWithResolvers(
address, NULL, 0U, suffixes, 1U,
ReverseLookup, ForwardMatch, &fixture, device, sizeof(device)));
assert(SMTPInternalRelayHostHasAllowedSuffix(
"PRINTER.Example.Test", suffixes, 1U));
assert(!SMTPInternalRelayHostHasAllowedSuffix(
"example.test", suffixes, 1U));
assert(!SMTPInternalRelayHostHasAllowedSuffix(
"printer.attackerexample.test", suffixes, 1U));
assert(SMTPInternalRelayDeviceNameValid("ilo-4"));
assert(!SMTPInternalRelayDeviceNameValid("ilo_4"));
assert(!SMTPInternalRelayDeviceNameValid("-ilo"));
assert(!SMTPInternalRelayDeviceNameValid("ilo-"));
}
int
main(void)
{
CheckNormalization();
CheckDeviceIdentity();
return 0;
}
+33 -2
View File
@@ -178,8 +178,39 @@ class ConfigurationModelTest(unittest.TestCase):
self.assertTrue(configs[name]["proxy_protocol_enabled"])
self.assertEqual(configs[name]["proxy_protocol_networks"],
["10.20.30.40/32"])
self.assertFalse(errors(validate_all(
configs, defaults, check_files=False)))
def test_internal_relay_identity_inputs_are_strict(self):
defaults = templates()
configs = templates()
smtp = configs["smtp"]
smtp["internal_relay_enabled"] = True
smtp["internal_relay_networks"] = ["192.0.2.0/24"]
smtp["internal_relay_domain"] = "example.test"
smtp["internal_relay_bind_address"] = "192.0.2.1"
smtp["internal_relay_device_mappings"] = [
"192.0.2.7=ups_01",
"192.0.2.999=ups-01",
]
smtp["internal_relay_dns_suffixes"] = [
"example.test",
"bad suffix",
]
problems = errors(validate_all(
configs, defaults, check_files=False))
paths = {
issue.path for issue in problems if issue.document == "smtp"
}
self.assertIn("internal_relay_device_mappings[0]", paths)
self.assertIn("internal_relay_device_mappings[1]", paths)
self.assertIn("internal_relay_dns_suffixes[1]", paths)
smtp["internal_relay_device_mappings"] = ["192.0.2.7=ups-01"]
smtp["internal_relay_dns_suffixes"] = ["devices.example.test"]
self.assertFalse([
issue for issue in errors(validate_all(
configs, defaults, check_files=False))
if issue.document == "smtp"
])
def test_international_domains_are_stored_as_idna2008_alabels(self):
defaults = templates()
+15 -1
View File
@@ -66,6 +66,8 @@ _HEADER_NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
_ATTRIBUTE_NAME = re.compile(r"^[A-Za-z0-9.-]{1,63}$")
_SELECTOR = re.compile(r"^[A-Za-z0-9_-]{1,63}$")
_DEVICE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
_INTERNAL_RELAY_DEVICE_NAME = re.compile(
r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$")
_WORKER_JOB_NAMES = frozenset({"acme-renew", "scanner-health",
"tlsrpt-delivery"})
@@ -558,9 +560,21 @@ def _validate_smtp(config: Mapping[str, Any], issues: list[Issue], check_files:
"expected IPv4=device-name")
continue
address, device = mapping.split("=", 1)
if not _valid_ipv4(address) or not _DEVICE_NAME.fullmatch(device):
if (not _valid_ipv4(address) or
not _INTERNAL_RELAY_DEVICE_NAME.fullmatch(device)):
_issue(issues, "smtp", f"internal_relay_device_mappings[{index}]",
"expected IPv4=device-name")
suffixes = config.get("internal_relay_dns_suffixes")
if not isinstance(suffixes, list):
_issue(issues, "smtp", "internal_relay_dns_suffixes",
"expected a list of DNS suffixes")
else:
for index, suffix in enumerate(suffixes):
if not _valid_domain(suffix):
_issue(
issues, "smtp",
f"internal_relay_dns_suffixes[{index}]",
"expected a valid DNS suffix")
networks = config.get("proxy_protocol_networks")
_validate_networks(issues, "smtp", "proxy_protocol_networks", networks)