1#!/usr/local/bin/python3.8
2# Derived from dkim-milter.py code:
3# Author: Stuart D. Gathman <stuart@bmsi.com>
4# Copyright 2007 Business Management Systems, Inc.
5# This code is under GPL.  See COPYING for details.
6# and:
7# dkimpy-milter: A DKIM signing/verification Milter application
8# Author: Scott Kitterman <scott@kitterman.com>
9# Copyright 2018 Scott Kitterman
10# spf-milter.py:
11# Author: Scott Kitterman <scott@kitterman.com>
12# Copyright 2019 Scott Kitterman
13"""    This program is free software; you can redistribute it and/or modify
14    it under the terms of the GNU General Public License as published by
15    the Free Software Foundation; either version 2 of the License, or
16    (at your option) any later version.
17
18    This program is distributed in the hope that it will be useful,
19    but WITHOUT ANY WARRANTY; without even the implied warranty of
20    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
21    GNU General Public License for more details.
22
23    You should have received a copy of the GNU General Public License along
24    with this program; if not, write to the Free Software Foundation, Inc.,
25    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA."""
26
27import sys
28import syslog
29import Milter
30import authres
31import os
32import tempfile
33import re
34from Milter.utils import parse_addr, parseaddr
35import spf_engine
36import spf_engine.policydspfsupp as config
37from spf_engine.util import drop_privileges
38from spf_engine.policydspfsupp import _setExceptHook
39from spf_engine.util import write_pid
40from spf_engine.util import fold
41
42__version__ = "2.9.2"
43FWS = re.compile(r'\r?\n[ \t]+')
44
45
46class spfMilter(Milter.Base):
47    "Milter to check SPF.  Each connection gets its own instance."
48
49    def __init__(self):
50        self.mailfrom = None
51        self.id = Milter.uniqueID()
52        self.instance_dict = {'0':'init',}
53        self.instance_dict.clear()
54        self.data = {}
55
56    @Milter.noreply
57    def connect(self, hostname, unused, hostaddr):
58        self.internal_connection = False
59        self.external_connection = False
60        self.hello_name = None
61        # sometimes people put extra space in sendmail config, so we strip
62        self.receiver = self.getsymval('j').strip()
63        try:
64            self.Authserv_Id = milterconfig['Authserv_Id']
65        except:
66            self.Authserv_Id = self.receiver
67        if hostaddr and len(hostaddr) > 0:
68            ipaddr = hostaddr[0]
69            if milterconfig['IntHosts']:
70                if milterconfig['IntHosts'].match(ipaddr):
71                    self.internal_connection = True
72        else:
73            ipaddr = ''
74        self.connectip = ipaddr
75        self.data['client_address'] = ipaddr
76        self.data['helo_name'] = hostname
77        self.data['recipient'] = '<UNKNOWN>'
78        if milterconfig.get('MacroList') and not self.internal_connection:
79            macrolist = milterconfig.get('MacroList')
80            for macro in macrolist:
81                macroname = macro.split('|')[0]
82                macroname = '{' + macroname + '}'
83                macroresult = self.getsymval(macroname)
84                if ((len(macro.split('|')) == 1 and macroresult) or macroresult
85                        in macro.split('|')[1:]):
86                    self.external_connection = True
87        if self.internal_connection:
88            connecttype = 'INTERNAL'
89        else:
90            connecttype = 'EXTERNAL'
91        if milterconfig.get('debugLevel') >= 1:
92            syslog.syslog("connect from {0} at {1} {2}"
93                          .format(hostname, hostaddr, connecttype))
94        return Milter.CONTINUE
95
96    # multiple messages can be received on a single connection
97    # envfrom (MAIL FROM in the SMTP protocol) seems to mark the start
98    # of each message.
99    def envfrom(self, f, *str):
100        if milterconfig.get('debugLevel') >= 2:
101            syslog.syslog("mail from: {0} {1}".format(f, str))
102        self.mailfrom = f
103        t = parse_addr(f)
104        if len(t) == 2:
105            t[1] = t[1].lower()
106            domain = t[1]
107        else:
108            domain = 'localhost.localdomain'
109        self.canon_from = '@'.join(t)
110        self.arheaders = []
111        self.arresults = []
112        self.data['sender'] = self.canon_from
113        if (not self.internal_connection or self.external_connection) and self.connectip:
114            return self.check_spf()
115        return Milter.CONTINUE
116
117    @Milter.noreply
118    def header(self, name, val):
119        lname = name.lower()
120        if lname == 'authentication-results':
121            self.arheaders.append(val)
122        return Milter.CONTINUE
123
124    @Milter.noreply
125    def eoh(self):
126        self.bodysize = 0
127        return Milter.CONTINUE
128
129    @Milter.noreply
130    def body(self, chunk):        # copy body to temp file
131        return Milter.CONTINUE
132
133    def eom(self):
134        if self.arresults:
135            h = results=self.arresults[0]
136            h = fold(str(h))
137            if milterconfig.get('debugLevel') >= 2:
138                syslog.syslog(str(h))
139            name, val = str(h).split(': ', 1)
140            self.addheader(name, val, 0)
141        return Milter.CONTINUE
142
143
144    def check_spf(self):
145        peruser = False
146        perusermilterconfig = []
147        rejectAction = ''
148        deferAction = ''
149        if not self.data.get('recipient'):
150            self.data['recipient'] = 'none'
151        if milterconfig.get('debugLevel') >= 3: syslog.syslog('Config: %s' % str(milterconfig))
152        #  run the checkers  {{{3
153        checkerValue = None
154        checkerReason = None
155        checkerValue, checkerReason, self.instance_dict, iserror = \
156                spf_engine._spfcheck(self.data, self.instance_dict, milterconfig,
157                peruser, perusermilterconfig)
158
159        TestOnly = milterconfig.get('TestOnly')
160        if TestOnly == 0 and checkerValue != 'prepend':
161            checkerValue = None
162            checkerReason = None
163
164        if milterconfig.get('SPF_Enhanced_Status_Codes') == 'No':
165            rejectAction = ''
166            deferAction = 'defer_if_permit'
167        else:
168            if iserror:
169                rejectAction = '5.7.24'
170                deferAction = '4.7.24'
171            else:
172                rejectAction = '5.7.23'
173                deferAction = '4.7.24'
174
175        #  handle results  {{{3
176        if milterconfig.get('Header_Type') != 'None' and checkerValue != 'reject':
177            if milterconfig.get('debugLevel') >= 3: syslog.syslog('{0} {1}'.format('Header text', checkerReason))
178            self.arresults.append(checkerReason)
179        if milterconfig.get('debugLevel') >= 3: syslog.syslog('Action: {0}: Text: {1} Reject action: {2}'.format(checkerValue, checkerReason, rejectAction))
180
181        if checkerValue == 'reject':
182            if milterconfig.get('debugLevel') >= 1: syslog.syslog('{0} {1}'.format(rejectAction, checkerReason))
183            self.setreply(str(550), rejectAction, checkerReason)
184            return Milter.REJECT
185
186        elif checkerValue == 'prepend':
187            if milterconfig.get('Prospective'):
188                return Milter.CONTINUE
189            else:
190                if milterconfig.get('Header_Type') != 'None':
191                    if milterconfig.get('debugLevel') >= 1: syslog.syslog('prepend {0}'.format(checkerReason))
192                else:
193                    if milterconfig.get('debugLevel') >= 1: syslog.syslog('Header field not prepended: {0}'.format(checkerReason))
194                return Milter.CONTINUE
195
196        elif checkerValue == 'defer':
197            if milterconfig.get('debugLevel') >= 1: syslog.syslog('{0} {1}'.format(deferAction, checkerReason))
198            self.setreply(str(450), deferAction, checkerReason)
199            return Milter.TEMPFAIL
200
201        elif checkerValue == 'result_only':
202            return Milter.SKIP
203        else:
204            return Milter.CONTINUE
205
206
207def main():
208    # Ugh, but there's no easy way around this.
209    global milterconfig
210    configFile = '/usr/local/etc/python-policyd-spf/policyd-spf.conf'
211    if len(sys.argv) > 1:
212        if sys.argv[1] in ('-?', '--help', '-h'):
213            print('usage: pyspf-milter [<configfilename>]')
214            sys.exit(1)
215        configFile = sys.argv[1]
216    milterconfig = config._processConfigFile(filename=configFile)
217    # Unlike policyd, milter interface only suppports authres
218    if milterconfig.get('Header_Type') != 'None':
219        milterconfig['Header_Type'] = 'AR'
220    # Per user configurations not supported on milter interface
221    milterconfig['Per_User'] = False
222    facility = eval("syslog.LOG_{0}".format('MAIL'))
223    syslog.openlog(os.path.basename(sys.argv[0]), syslog.LOG_PID, facility)
224    _setExceptHook()
225    pid = write_pid(milterconfig)
226    Milter.factory = spfMilter
227    Milter.set_flags(Milter.CHGHDRS + Milter.ADDHDRS)
228    miltername = 'pyspf-filter'
229    socketname = milterconfig.get('Socket')
230    syslog.syslog('pyspf-milter started:{0} user:{1}'
231                  .format(pid, milterconfig.get('UserID')))
232    sys.stdout.flush()
233    drop_privileges(milterconfig)
234    Milter.runmilter(miltername, socketname, 240)
235
236if __name__ == "__main__":
237    main()
238