add check for Received header

This commit is contained in:
Max Erenberg 2021-09-01 02:20:45 +00:00
parent 7f7e30a04a
commit 978c9216cf
4 changed files with 99 additions and 32 deletions

View File

@ -1,7 +1,16 @@
# CSC Milter
This is a milter ("mail filter") for CSC. Currently it only has one purpose:
prevent clients outside of the UW network from setting the 'From' header
to a CSC address *and* using port 25 (port 587 is OK).
This is a milter ("mail filter") for CSC.
It will reject a message if and only if the following conditions are satisfied:
* a message arrives on port 25 or 465
* the client is not authenticated
* the From: header (**not** the envelope header) contains a CSC address
* there is no Received: header indicating that the message was previously received by a CSC mail server
The objective is to reject messages from spammers spoofing the From: header
to a CSC address.
The last point (the Received header check) is to allow forwarding and mailing lists (e.g. a CSC member sends an email to the DSA mailing list, to which the syscom list is subscribed). This isn't perfect because the Received header can also be spoofed, but it should probably be good enough for now.
## Installation
As root:
@ -28,16 +37,15 @@ submission inet n - n - - smtpd
Notice how smtpd_milters above does not have the csc-milter socket path. Therefore
csc-milter will not be invoked on messages arriving on port 587 (submission).
Optional, but strongly recommended: modify milter\_connect\_macros in
/etc/postfix/main.cf to include `{daemon_port}`. This ensures that even if you
forget to exclude csc-milter from master.cf, clients using non-25 ports will not
be rejected.
You also need to modify milter\_connect\_macros in
/etc/postfix/main.cf to include `{daemon_port}` and `{auth_type}`. This allows
authenticated cilents to be skipped.
Example:
```
milter_connect_macros = j v _ {daemon_name} {daemon_port}
milter_connect_macros = j v _ {daemon_name} {daemon_port} {auth_type}
```
Optional, but recommended: add the following to /etc/postfix/main.cf:
Optional: add the following to /etc/postfix/main.cf:
```
smtpd_milter_maps = cidr:/etc/postfix/smtpd_milter_map
```
@ -53,16 +61,19 @@ Replace 'DISABLE' by any additional milters which should be run. Note that
even if you do not do this, csc-milter will still accept messages from local
clients.
### systemd
To run csc-milter as a systemd service:
### Running locally
For development purposes:
```
su -s /bin/bash postfix
python3 csc_milter/main.py
```
### systemd
The Debian package installs a systemd service named csc-milter. Usage:
```
cp systemd/csc-milter.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable csc-milter
systemctl start csc-milter
```
This assumes that csc-milter was installed globally as a Python package. If that
was not the case, edit `ExecStart` in the service file.
## Tests
Run the following from the root directory:

View File

@ -6,6 +6,7 @@ import ipaddress
import logging
import os
import pwd
import re
import subprocess
import Milter
@ -15,7 +16,9 @@ SOCKETPATH = "/run/csc-milter/csc-milter.sock"
TIMEOUT = 5
MYORIGIN = ''
MYNETWORKS = []
REJECT_MESSAGE = 'You may not send from a CSC address on port 25 - please use port 587 instead.'
REJECT_MESSAGE = 'You must be authenticated to send from a CSC address.'
# The regex looks for e.g. "by mail.csclub.uwaterloo.ca" in the Received: header
RECEIVED_REGEX = None
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
@ -52,32 +55,44 @@ class CSCMilter(Milter.Base):
(self, 'ip6.mxout.example.com', AF_INET6, ('3ffe:80e8:d8::1', 4720, 1, 0) )
"""
self.IP = hostaddr[0]
# accept clients on the local network
if is_in_mynetworks(self.IP):
return Milter.ACCEPT
self.daemon_port = self.getsymval('{daemon_port}')
if self.daemon_port is not None and self.daemon_port != '25':
# We only want to filter messages arriving on the SMTP ports
if self.daemon_port is not None and int(self.daemon_port) not in [25, 465]:
return Milter.ACCEPT
return Milter.CONTINUE
@Milter.noreply
def envfrom(self, mailfrom, *str):
def envfrom(self, mailfrom, *args):
# accept authenticated users
if self.getsymval('{auth_type}') is not None:
return Milter.ACCEPT
self.mailfrom = mailfrom
return Milter.CONTINUE
def header(self, field, value):
"""Called for each header field in the message body."""
if field.lower() != 'from':
field = field.lower()
# If the message was originally sent through a CSC mail server, then this is
# a valid forwarding
if field == 'received':
if RECEIVED_REGEX.search(value) is not None:
return Milter.ACCEPT
return Milter.CONTINUE
if field != 'from':
return Milter.CONTINUE
addr = parseaddr(value)[1]
if addr == '':
return Milter.CONTINUE
domain = addr.split('@')[1].lower()
if is_in_myorigin(domain):
logger.info('Rejecting message (MAIL FROM: %s, From: %s)' % (self.mailfrom, value))
# See RFC 1893
self.setreply('550', '5.7.1', REJECT_MESSAGE)
return Milter.REJECT
return Milter.CONTINUE
if not is_in_myorigin(domain):
return Milter.CONTINUE
# At this point, we know that the From: header contains a CSC address
logger.info('Rejecting message (MAIL FROM: %s, From: %s)' % (self.mailfrom, value))
# See RFC 1893
self.setreply('550', '5.7.1', REJECT_MESSAGE)
return Milter.REJECT
def check_postfix_user():
@ -101,6 +116,7 @@ def get_postconf(param):
def main():
global MYORIGIN
global MYNETWORKS
global RECEIVED_REGEX
check_postfix_user()
@ -126,6 +142,8 @@ def main():
)
for s in networks
]
re_myorigin = MYORIGIN.replace('.', r'\.')
RECEIVED_REGEX = re.compile(rf"\bby ([\w-]+\.)?{re_myorigin}\b")
Milter.factory = CSCMilter
logger.debug("milter startup")

View File

@ -16,7 +16,7 @@ class PyTest(TestCommand):
setup(
name='csc-milter',
version='0.1.0',
version='0.2.0',
description='Custom milter for CSC',
long_description=long_description,
long_description_content_type='text/markdown',

View File

@ -1,4 +1,5 @@
import ipaddress
import re
import socket
from unittest.mock import Mock
@ -12,16 +13,24 @@ def setup_module():
ipaddress.ip_network('129.97.134.0/24'),
]
csc_milter.MYORIGIN = 'csclub.uwaterloo.ca'
csc_milter.RECEIVED_REGEX = re.compile(r"\bby ([\w-]+\.)?csclub\.uwaterloo\.ca\b")
def teardown_module():
csc_milter.MYNETWORKS = []
csc_milter.MYORIGIN = ''
csc_milter.RECEIVED_REGEX = None
def get_milter(dst_port):
def get_milter(dst_port, auth=False):
milter = csc_milter.CSCMilter()
milter.getsymval = Mock(side_effect=lambda s: str(dst_port) if s == '{daemon_port}' else None)
def getsymval(s):
if s == '{daemon_port}':
return str(dst_port)
if s == '{auth_type}' and auth:
return 'PLAIN'
return None
milter.getsymval = Mock(side_effect=getsymval)
milter._protocol = 18266 # to make the @Milter.noreply work
milter.setreply = Mock()
return milter
@ -39,9 +48,13 @@ def test_local_port_587():
assert ret == Milter.ACCEPT
def remote_connect(milter):
return milter.connect('[24.114.29.182]', socket.AF_INET, ('24.114.29.182', 10000))
def test_remote_port_25_csc_address():
milter = get_milter(25)
ret = milter.connect('[24.114.29.182]', socket.AF_INET, ('24.114.29.182', 10000))
ret = remote_connect(milter)
assert ret == Milter.CONTINUE
milter.envfrom('<user@example.com>')
ret = milter.header('From', '<syscom@csclub.uwaterloo.ca>')
@ -50,7 +63,7 @@ def test_remote_port_25_csc_address():
def test_remote_port_25_non_csc_address():
milter = get_milter(25)
milter.connect('[24.114.29.182]', socket.AF_INET, ('24.114.29.182', 10000))
remote_connect(milter)
milter.envfrom('<user@example.com>')
ret = milter.header('From', 'John Doe <user@example.com>')
assert ret == Milter.CONTINUE
@ -58,5 +71,30 @@ def test_remote_port_25_non_csc_address():
def test_remote_port_587():
milter = get_milter(587)
ret = milter.connect('[24.114.29.182]', socket.AF_INET, ('24.114.29.182', 10000))
ret = remote_connect(milter)
assert ret == Milter.ACCEPT
def test_remote_port_25_with_auth():
milter = get_milter(25, auth=True)
remote_connect(milter)
ret = milter.envfrom('<user@csclub.uwaterloo.ca>')
assert ret == Milter.ACCEPT
def test_remote_port_25_with_received_origin():
milter = get_milter(25)
remote_connect(milter)
milter.envfrom('<user@csclub.uwaterloo.ca>')
ret = milter.header('Received', 'from 24.114.29.182\n\tby mail.csclub.uwaterloo.ca (Postfix)')
assert ret == Milter.ACCEPT
def test_remote_port_25_with_received_nonorigin():
milter = get_milter(25)
remote_connect(milter)
milter.envfrom('<user@csclub.uwaterloo.ca>')
ret = milter.header('Received', 'from 24.114.29.182\n\tby mail.example.com (Postfix)')
assert ret == Milter.CONTINUE
ret = milter.header('From', '<syscom@csclub.uwaterloo.ca>')
assert ret == Milter.REJECT