add check for Received header
This commit is contained in:
parent
7f7e30a04a
commit
978c9216cf
41
README.md
41
README.md
|
@ -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:
|
||||
|
|
|
@ -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")
|
||||
|
|
2
setup.py
2
setup.py
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue