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)