From 14fbb55a3890ceffcc2594e4682f2982c620f953 Mon Sep 17 00:00:00 2001 From: Mario Fetka Date: Fri, 24 Jul 2026 07:37:25 +0200 Subject: [PATCH] Verify internal SMTP sender normalization --- contrib/testing/README.md | 24 ++ .../smtp-internal-normalization-check.py | 363 ++++++++++++++++++ src/agents/smtp/CMakeLists.txt | 10 + src/agents/smtp/internal-relay.c | 225 +++++++++++ src/agents/smtp/internal-relay.h | 53 +++ src/agents/smtp/smtpd.c | 147 ++----- src/agents/smtp/tests/internal-relay-test.c | 173 +++++++++ src/apps/config/tests/test_configuration.py | 35 +- src/libs/python/bongo/configuration/model.py | 16 +- 9 files changed, 918 insertions(+), 128 deletions(-) create mode 100755 contrib/testing/smtp-internal-normalization-check.py create mode 100644 src/agents/smtp/internal-relay.c create mode 100644 src/agents/smtp/internal-relay.h create mode 100644 src/agents/smtp/tests/internal-relay-test.c diff --git a/contrib/testing/README.md b/contrib/testing/README.md index e12f6ff..9a60248 100644 --- a/contrib/testing/README.md +++ b/contrib/testing/README.md @@ -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 diff --git a/contrib/testing/smtp-internal-normalization-check.py b/contrib/testing/smtp-internal-normalization-check.py new file mode 100755 index 0000000..0adff3d --- /dev/null +++ b/contrib/testing/smtp-internal-normalization-check.py @@ -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) diff --git a/src/agents/smtp/CMakeLists.txt b/src/agents/smtp/CMakeLists.txt index be8df90..279016f 100644 --- a/src/agents/smtp/CMakeLists.txt +++ b/src/agents/smtp/CMakeLists.txt @@ -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 diff --git a/src/agents/smtp/internal-relay.c b/src/agents/smtp/internal-relay.c new file mode 100644 index 0000000..20865ef --- /dev/null +++ b/src/agents/smtp/internal-relay.c @@ -0,0 +1,225 @@ +/**************************************************************************** + * + * 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. + * + ****************************************************************************/ + +#include "internal-relay.h" + +#include +#include +#include +#include +#include +#include + +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); +} diff --git a/src/agents/smtp/internal-relay.h b/src/agents/smtp/internal-relay.h new file mode 100644 index 0000000..ace0f51 --- /dev/null +++ b/src/agents/smtp/internal-relay.h @@ -0,0 +1,53 @@ +/**************************************************************************** + * + * 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. + * + ****************************************************************************/ + +#ifndef BONGO_SMTP_INTERNAL_RELAY_H +#define BONGO_SMTP_INTERNAL_RELAY_H + +#include +#include + +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 diff --git a/src/agents/smtp/smtpd.c b/src/agents/smtp/smtpd.c index bd80371..bef5e2c 100644 --- a/src/agents/smtp/smtpd.c +++ b/src/agents/smtp/smtpd.c @@ -50,6 +50,7 @@ #include #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; diff --git a/src/agents/smtp/tests/internal-relay-test.c b/src/agents/smtp/tests/internal-relay-test.c new file mode 100644 index 0000000..85efce0 --- /dev/null +++ b/src/agents/smtp/tests/internal-relay-test.c @@ -0,0 +1,173 @@ +/**************************************************************************** + * + * 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. + * + ****************************************************************************/ + +#include "internal-relay.h" + +#include +#include +#include +#include + +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; +} diff --git a/src/apps/config/tests/test_configuration.py b/src/apps/config/tests/test_configuration.py index 6bd973b..ee82031 100644 --- a/src/apps/config/tests/test_configuration.py +++ b/src/apps/config/tests/test_configuration.py @@ -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() diff --git a/src/libs/python/bongo/configuration/model.py b/src/libs/python/bongo/configuration/model.py index 10bef16..be34044 100644 --- a/src/libs/python/bongo/configuration/model.py +++ b/src/libs/python/bongo/configuration/model.py @@ -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)