mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
Under SSO the role tried to let applications relay without SMTP AUTH by widening the relay rule to the private networks. The rule compared the peer address to the text of a CIDR, which never matches, so an application could reach local mailboxes but got "550 5.1.2 Relay not allowed" for every external recipient. Repairing the comparison would have opened a relay, because published ports, Tor and the swarm mesh all hand outside clients an address inside those networks. Stalwart checks app passwords before it asks the external directory, so a bot can authenticate while the directory is OIDC. With SSO enabled the role now creates one app password per bot, acting for that account as the recovery admin, and stores it in the token store the SMTP password is already read from. A bot gets a new one when its entry is deleted in Stalwart or its token is removed. The relay rule is back to authenticated sessions only. The lookup loses its SSO relay branch and the role its submission_via_relay flag, so an SSO deployment submits on 465 with AUTH like every other one. The role's CLI test now fails when an unauthenticated session is given an external recipient, or when the bot credential is refused one. Closes #793 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
112 lines
3.9 KiB
Python
112 lines
3.9 KiB
Python
"""Whether GitLab announces SMTP AUTH, and on which listener.
|
|
|
|
Ruby's ``net/smtp`` authenticates whenever a ``user_name`` or ``password`` is
|
|
present, falling back to its default auth type — ``authentication: nil`` does
|
|
NOT switch it off. So on the provider's MX listener, where port 25 offers no
|
|
AUTH at all, leaving the credentials in the hash makes every send die with
|
|
``503 5.5.1 AUTH not allowed`` while GitLab still reports the mail as queued.
|
|
The credentials therefore have to be omitted, not merely unaccompanied by an
|
|
auth type.
|
|
|
|
``tls`` and ``enable_starttls_auto`` are the other half: they are mutually
|
|
exclusive in ActionMailer, implicit TLS on 465 or STARTTLS on the relay.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import unittest
|
|
|
|
from jinja2 import Environment, StrictUndefined, select_autoescape
|
|
|
|
from utils.cache.files import read_text
|
|
|
|
from . import PROJECT_ROOT
|
|
|
|
TEMPLATE = (
|
|
PROJECT_ROOT
|
|
/ "roles"
|
|
/ "web-app-gitlab"
|
|
/ "templates"
|
|
/ "config"
|
|
/ "smtp_settings.rb.j2"
|
|
)
|
|
|
|
RELAY = {
|
|
"host": "mail.infinito.test",
|
|
"port": 25,
|
|
"username": "no-reply@infinito.test",
|
|
"password": "s3cr3t",
|
|
"domain": "infinito.test",
|
|
"auth": False,
|
|
"start_tls": True,
|
|
"tls": False,
|
|
}
|
|
SUBMISSION = dict(RELAY, port=465, auth=True, start_tls=False, tls=True)
|
|
|
|
|
|
def _render(email: dict, ca_file: str = "") -> str:
|
|
env = Environment(
|
|
trim_blocks=True,
|
|
lstrip_blocks=True,
|
|
undefined=StrictUndefined,
|
|
autoescape=select_autoescape(default_for_string=False),
|
|
)
|
|
env.filters["bool"] = bool
|
|
env.filters["ruby_dq"] = lambda value: '"' + str(value).replace('"', '\\"') + '"'
|
|
return env.from_string(read_text(TEMPLATE)).render(
|
|
GITLAB_EMAIL=email,
|
|
GITLAB_EMAIL_CA_FILE=ca_file,
|
|
)
|
|
|
|
|
|
class TestGitlabSmtpSettings(unittest.TestCase):
|
|
def test_verification_is_never_weakened(self):
|
|
"""An onion relay is handled by turning start_tls off in the email
|
|
lookup, not by telling Net::SMTP to skip peer verification."""
|
|
self.assertNotIn("openssl_verify_mode", _render(RELAY))
|
|
|
|
def test_our_own_ca_is_trusted_where_it_is_mounted(self):
|
|
rendered = _render(RELAY, ca_file="/tmp/infinito/ca/root-ca.crt")
|
|
self.assertIn('ca_file: "/tmp/infinito/ca/root-ca.crt"', rendered)
|
|
|
|
def test_without_our_ca_no_trust_store_override_is_emitted(self):
|
|
self.assertNotIn("ca_file", _render(RELAY))
|
|
|
|
def test_the_relay_carries_no_credentials_at_all(self):
|
|
rendered = _render(RELAY)
|
|
self.assertNotIn("user_name", rendered)
|
|
self.assertNotIn("password", rendered)
|
|
self.assertNotIn("s3cr3t", rendered)
|
|
|
|
def test_the_relay_announces_no_auth_type(self):
|
|
self.assertNotIn("authentication", _render(RELAY))
|
|
|
|
def test_authenticated_submission_still_sends_credentials(self):
|
|
rendered = _render(SUBMISSION)
|
|
self.assertIn('user_name: "no-reply@infinito.test"', rendered)
|
|
self.assertIn("authentication: :login", rendered)
|
|
|
|
def test_tls_and_starttls_are_never_both_on(self):
|
|
for email in (RELAY, SUBMISSION):
|
|
rendered = _render(email)
|
|
tls = re.search(r"^\s*tls:\s*(\w+)", rendered, re.MULTILINE).group(1)
|
|
starttls = re.search(
|
|
r"^\s*enable_starttls_auto:\s*(\w+)", rendered, re.MULTILINE
|
|
).group(1)
|
|
self.assertNotEqual((tls, starttls), ("true", "true"))
|
|
|
|
def test_the_relay_uses_starttls_and_the_submission_port_implicit_tls(self):
|
|
self.assertIn("enable_starttls_auto: true", _render(RELAY))
|
|
self.assertIn("tls: false", _render(RELAY))
|
|
self.assertIn("tls: true", _render(SUBMISSION))
|
|
|
|
def test_the_hash_stays_syntactically_valid_without_credentials(self):
|
|
rendered = _render(RELAY).strip()
|
|
self.assertTrue(rendered.endswith("}"))
|
|
self.assertNotIn(",\n}", rendered)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|