diff --git a/CHANGES b/CHANGES new file mode 100644 index 0000000..c260f5b --- /dev/null +++ b/CHANGES @@ -0,0 +1,118 @@ +# Legend: +# --- = A new release +# + = Added a feature (in a backwards compatible way) +# ! = Changed something significant, or removed a feature +# * = Fixed a bug, or made a minor improvement + +--- 2.011 2018-07-29 + * Added mention of the requirement for Sys::Syslog to INSTALL + * Add references to the current upstream location on Launchpad + + Add option to skip SPF checks on exempt domains based on /etc/postfix/ + exempt_spf_domains (Thanks to Scott Savarese for the patch) + ! Switch relay_addresses to use /etc/postfix/exempt_spf_addresses instead + of hand editing the code (Thanks to Scott Savarese for pushing this over + the finish line) (Debian #902801) + + Add config.sh (see README for details) to support different postfix + configuration directories + * Change domain back to openspf.org, it has been back for a long time + (Debian #900512) + +--- 2.010 2012-06-17 + * Fixed incorrect use of != instead of ne for string comparison + (LP: #1014243) + ! Changed non-standard X-Comment header fields for localhost and + whitelisted addresses to use RFC 5451 Authentication Results header + fields + ! Added depenency on Sys::Hostname::Long for local hostname determination + +--- 2.009 2012-02-03 + * Chomp erroneus NULLs off the end of local and authority explanations to + work around a Mail::SPF bug (in Mail::SPF versions prior to 2.008) + (LP: #806926) + - Patch thanks to Allison Randal + * Stop logging queue ID since it is virtually never available and clutters + the logs + * Reduced non-verbose logging to a single line per message + +--- 2.008 (2012-01-19 13:46 -0500) + ! Query only TXT and not DNS RR Type SPF records to reduce unnecessary DNS + lookups (LP: #161133) + * Changed default_authority_explanation ('Why' reject text) to point to + openspf.net instead of openspf.org due to extended outage + * Fix incorrect version string + * Ensure all variables are initialized prior to being passed to syslog + +--- 2.007 (2008-07-25 22:24 -0400) + * Update documentation and examples, see Debian bugs 492420 and 492421 for + details. + +--- 2.006 (2008-07-18 00:49 -0400) + * Add default logging to make it easier to determine what SPF identity is + being used + +--- 2.005 (2007-12-14 23:29 -0500) + * Decreased timeout for DNS queries via UDP to 10s from Net::DNS::Resolver's + default of 40s (by doing only 1 retransmission rather than 3 after a query + fails). Until Mail::SPF provides an explicit option for this, we just + create our own resolver object and make Mail::SPF use that. + * Adjust master.cf recommendations in INSTALL for new recommendations from + Wietse Venema (postfix-users mailing list). + * Other minor documentation cleanup + +--- 2.004 (2007-04-18 15:36) + * Fix header text to work with Postfix (access 5 requirements). + +--- 2.003 (2007-04-17 08:50) + * Minor documentation cleanup. + + Add handler for list of relay addresses to bypass. + +--- 2.002 (2007-02-20 05:45) + * Added Julian Mehnle to copyright statement. + * Implemented results cache in order to prevent redundant SPF checks in + multiple invocations per message instance. This also enables us to prepend + a "Received-SPF" header only once per message instance (as opposed to once + per recipient address). + * Minor code and comments clean-up. + +--- 2.001 (2007-02-08 00:36) + * Safer check for local connections. + +--- 2.000 (2007-02-07 17:07) + * Change reject reply to 550 for RFC 2821 compliance. + * Skip SPF checks for local (127.) connections + * Clarified wording for some verbose logging. + * Added more information about HELO checking to README. + +--- 1.990 (2007-02-03 16:00) + + postfix-policyd-spf-perl: + ! Changed from Mail::SPF::Query to Mail::SPF for RFC 4408 compliance + ! Removed Testing handler (usage was undocumented). + ! Removed debian/ dir from release tarball (still provided via SVN). + * Simplified logging. Policy server is less chatty. Logs are clearer. + +--- 1.08.1 (2007-01-10 21:00) + + postfix-policyd-spf-perl: + * Minor and purely cosmetic code clean-up. + + Miscellaneous: + * Updated README file with new website URL and copyright. + * Added LICENSE file as an explicit copy of the GPLv2. + + Debian: + * New maintainer: Scott Kitterman + * Priority: extra (was: optional) + * Removed Build-Depends-Indep: perl, as there really is no need for it. + * Depends: libversion-perl + * Updated debian/copyright. + +--- 1.08 (2006-06-17 20:00) + + * Added Debian package control files. + * Moved documentation from executable into separate README and INSTALL + files. Improved documentation. + * Minor and purely cosmetic code clean-up. + +# $Id$ +# vim:tw=79 sts=2 sw=2 diff --git a/INSTALL b/INSTALL new file mode 100644 index 0000000..01911c7 --- /dev/null +++ b/INSTALL @@ -0,0 +1,45 @@ +System Requirements +------------------- + +The following Perl version and packages are required for running +postfix-policyd-spf-perl: + + Perl 5.6 + version + NetAddr-IP 4 + Mail::SPF (not Mail-SPF-Query) version 2.006 or later + Sys::Hostname::Long + Sys::Syslog + +Installing +---------- + 0. If your postfix config directory is not /etc/postifx, see the README for + additional instructions and adjust the path in step 2 accordingly. + + 1. Copy postfix-policyd-spf-perl to /usr/local/lib/policyd-spf-perl + + 2. Add the following to /etc/postfix/master.cf: + + policy unix - n n - 0 spawn + user=nobody argv=/usr/local/lib/policyd-spf-perl + + 3. Configure the Postfix policy service in /etc/postfix/main.cf: + + smtpd_recipient_restrictions = + ... + reject_unauth_destination + check_policy_service unix:private/policy + ... + + NOTES: + Specify check_policy_service AFTER reject_unauth_destination or + else your system can become an open relay. + + The user 'nobody' is used in this example. This is appropriate if you + do not have any other services running as nobody. If you do, create a + dedicated user for this service and use it instead. + + 4. Add "policy_time_limit = 3600" to main.cf + + 5. Restart Postfix. + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d511905 --- /dev/null +++ b/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + 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, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README b/README new file mode 100644 index 0000000..17c334a --- /dev/null +++ b/README @@ -0,0 +1,121 @@ +postfix-policyd-spf-perl 2.010 +A Postfix SMTPd policy server for SPF checking +(C) 2007-2008,2012 Scott Kitterman +(C) 2012 Allison Randal +(C) 2007 Julian Mehnle +(C) 2003-2004 Meng Weng Wong +Thanks for contributions by various members of the SPF project + + +============================================================================== + +postfix-policyd-spf-perl is a Postfix SMTPd policy server for SPF checking. +It is implemented in pure Perl and uses the Mail::SPF CPAN module. Note that +Mail::SPF is a complete re-implementation of SPF based on the final SPF RFC, +RFC 4408. It shares no code with the older Mail::SPF::Query that was the +original SPF development implementation. If you are upgrading from on older +(<< 2.000) version of this policy server you will need to install Mail::SPF. +At least version 2.006 of Mail::SPF is required. + +This version of the policy server always checks HELO before Mail From (older +versions just checked HELO if Mail From was null). It will reject mail that +fails either Mail From or HELO SPF checks. It will defer mail if there is a +temporary SPF error and the message would othersise be permitted +(DEFER_IF_PERMIT). If the HELO check produces a REJECT/DEFER result, Mail From +will not be checked. + +If the message is not rejected or deferred, the policy server will PREPEND the +appropriate SPF Received header. If Mail From is anything other than completely +empty (i.e. <>) then the Mail From result will be used for SPF Received (e.g. +Mail From None even if HELO is Pass). + +The policy server skips SPF checks for connections from the localhost (127.) and +instead prepends and logs 'SPF skipped - localhost is always allowed.' If you +have relays that you want to skip SPF checks for, create a configuration file, +/etc/postfix/exempt_spf_addresses and add them on one using standard CIDR +notation in a space separated list. For these addresses, 'X-Comment: SPF +skipped for whitelisted relay' is prepended and logged. IPv6 localhost is also +skipped. + +A configuration file, /etc/postfix/exempt_spf_domains, can be used to +ignore domains that have broken SPF configurations that would normally +fail. For those domains, add the domain to the file (one per line), and +restart postfix so that the policy server can reload its configuration. +The policy server will ignore the domain going forward. + +If defined, the configuration files described above need to have permissions +to allow the policy server to read the files. + +The standard build for the policy server assumes that the postfix config file +directory is /etc/postfix. If this is not correct for your operating systemn, +run the provided config.sh file from the package directory and it will update +the config file directory based on the output of postconf -h config_directory. +This needs to be done before package installation. + +Error conditions within the policy server (that don't result in a crash) or from +Mail::SPF will return DUNNO. + +See INSTALL for installation instructions. + +Usage: + policyd-spf-perl [-v] + +This documentation assumes you have read Postfix's README_FILES/ +SMTPD_POLICY_README. + +Logging is sent to syslogd. + +Each time a Postfix SMTP server process is started it connects to the policy +service socket, and Postfix runs one instance of this Perl script. By +default, a Postfix SMTP server process terminates after 100 seconds of idle +time, or after serving 100 clients. Thus, the cost of starting this Perl +script is smoothed out over time. + +The default policy_time_limit is 1000 seconds. This may be too short for some +SMTP transactions to complete. As recommended in SMTPD_POLICY_README, this +should be extended to 3600 seconds. To do so, set "policy_time_limit = 3600" +in /etc/postfix/main.cf. + +Testing the policy daemon +------------------------- + +To test the policy daemon by hand, execute: + + % /usr/local/lib/policyd-spf-perl + +Each query is a bunch of attributes. Order does not matter, and the daemon +uses only a few of all the attributes shown below: + + request=smtpd_access_policy + protocol_state=RCPT + protocol_name=SMTP + helo_name=some.domain.tld + queue_id= + instance=71b0.45e2f5f1.d4da1.0 + sender=foo@bar.tld + recipient=bar@foo.tld + client_address=1.2.3.4 + client_name=another.domain.tld + [empty line] + +The policy daemon will answer in the same style, with an attribute list +followed by a empty line: + + action=550 Please see http://www.openspf.org/Why?id=foo@bar.tld&ip=1.2.3.4& + receiver=bar@foo.tld + [empty line] + +To test HELO checking sender should be empty: + + sender= + ... More attributes... + [empty line] + +If you want more detail in the system logs change $VERBOSE to 1. + +License +------- + +postfix-policyd-spf-perl is free software. You may use, modify, and distribute +it under the GNU GPL (version 2 or later); see the LICENSE file. + diff --git a/config.sh b/config.sh new file mode 100755 index 0000000..9a39b5d --- /dev/null +++ b/config.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +set -e + +CDIR=$(postconf -h config_directory) +echo "Config directory: ${CDIR}" +sed -e 's|@@CDIR@@|'"${CDIR}"'|g' postfix-policyd-spf-perl.in > postfix-policyd-spf-perl diff --git a/postfix-policyd b/postfix-policyd deleted file mode 100755 index d78163f..0000000 --- a/postfix-policyd +++ /dev/null @@ -1,381 +0,0 @@ -#!/usr/bin/perl - -# mengwong@pobox.com -# Wed Dec 10 03:52:04 EST 2003 -# postfix-policyd -# version 1.0 -# see http://spf.pobox.com/ - -use DB_File; -use Fcntl; -use Sys::Syslog qw(:DEFAULT setlogsock); -use strict; -use Mail::SPF::Query; - -# ---------------------------------------------------------- -# configuration -# ---------------------------------------------------------- - -my @HANDLERS = ( - "testing", - "sender_permitted_from", - "greylisting", - ); - -my $VERBOSE = 1; - -my @Greylisting_Whitelisted_Senders = - ( - qr(^postmaster)i, - ); - -# -# Syslogging options for verbose mode and for fatal errors. -# NOTE: comment out the $syslog_socktype line if syslogging does not -# work on your system. -# - -my $syslog_socktype = 'unix'; # inet, unix, stream, console -my $syslog_facility = "mail"; -my $syslog_options = "pid"; -my $syslog_priority = "info"; - -# -# greylist status database and greylist time interval. DO NOT create the -# greylist status database in a world-writable directory such as /tmp -# or /var/tmp. DO NOT create the greylist database in a file system -# that can run out of space. -# -# In case of database corruption, this script saves the database as -# $database_name.time(), so that the mail system does not get stuck. -# -my $database_name="/var/spool/postfix/smtpd-policy.db"; -my $GREYLIST_DELAY=60; - -# ---------------------------------------------------------- -# minimal documentation -# ---------------------------------------------------------- - -# -# Usage: smtpd-policy.pl [-v] -# -# Demo delegated Postfix SMTPD policy server. This server -# implements greylisting and SPF. -# -# State for greylisting is kept in a Berkeley DB database. -# -# The SPF handler uses Mail::SPF::Query to do the heavy lifting. -# -# Logging is sent to syslogd. -# -# How it works: each time a Postfix SMTP server process is started -# it connects to the policy service socket, and Postfix runs one -# instance of this PERL script. By default, a Postfix SMTP server -# process terminates after 100 seconds of idle time, or after serving -# 100 clients. Thus, the cost of starting this PERL script is smoothed -# out over time. -# -# To run this from /etc/postfix/master.cf: -# -# policy unix - n n - - spawn -# user=nobody argv=/usr/bin/perl /usr/libexec/postfix/smtpd-policy.pl -# -# To use this from Postfix SMTPD, use in /etc/postfix/main.cf: -# -# smtpd_recipient_restrictions = -# ... -# reject_unauth_destination -# check_policy_service unix:private/policy -# ... -# -# NOTE: specify check_policy_service AFTER reject_unauth_destination -# or else your system can become an open relay. -# -# To test this script by hand, execute: -# -# % perl smtpd-policy.pl -# -# Each query is a bunch of attributes. Order does not matter, and -# the demo script uses only a few of all the attributes shown below: -# -# request=smtpd_access_policy -# protocol_state=RCPT -# protocol_name=SMTP -# helo_name=some.domain.tld -# queue_id=8045F2AB23 -# sender=foo@bar.tld -# recipient=bar@foo.tld -# client_address=1.2.3.4 -# client_name=another.domain.tld -# [empty line] -# -# The policy server script will answer in the same style, with an -# attribute list followed by a empty line: -# -# action=dunno -# [empty line] -# - -# Jul 23 18:43:29 dumbo/dumbo policyd[21171]: Attribute: client_address=208.210.125.227 -# Jul 23 18:43:29 dumbo/dumbo policyd[21171]: Attribute: client_name=newbabe.mengwong.com -# Jul 23 18:43:29 dumbo/dumbo policyd[21171]: Attribute: helo_name=newbabe.mengwong.com -# Jul 23 18:43:29 dumbo/dumbo policyd[21171]: Attribute: protocol_name=ESMTP -# Jul 23 18:43:29 dumbo/dumbo policyd[21171]: Attribute: protocol_state=RCPT -# Jul 23 18:43:29 dumbo/dumbo policyd[21171]: Attribute: queue_id= -# Jul 23 18:43:29 dumbo/dumbo policyd[21171]: Attribute: recipient=mengwong@dumbo.pobox.com -# Jul 23 18:43:29 dumbo/dumbo policyd[21171]: Attribute: request=smtpd_access_policy -# Jul 23 18:43:29 dumbo/dumbo policyd[21171]: Attribute: sender=mengwong@newbabe.mengwong.com - -# ---------------------------------------------------------- -# initialization -# ---------------------------------------------------------- - -# -# Log an error and abort. -# -sub fatal_exit { - syslog(err => "fatal_exit: @_"); - syslog(warn => "fatal_exit: @_"); - syslog(info => "fatal_exit: @_"); - die "fatal: @_"; -} - -# -# Unbuffer standard output. -# -select((select(STDOUT), $| = 1)[0]); - -# -# Signal 11 means that we have some kind of database corruption (yes -# Berkeley DB should handle this better). Move the corrupted database -# out of the way, and start with a new database. -# -sub sigsegv_handler { - my $backup = $database_name . "." . time(); - - rename $database_name, $backup || fatal_exit ("Can't save $database_name as $backup): $!"); - fatal_exit ("Caught signal 11; the corrupted database is saved as $backup"); -} - -$SIG{'SEGV'} = 'sigsegv_handler'; - -# -# This process runs as a daemon, so it can't log to a terminal. Use -# syslog so that people can actually see our messages. -# -setlogsock $syslog_socktype; -openlog $0, $syslog_options, $syslog_facility; - -# ---------------------------------------------------------- -# main -# ---------------------------------------------------------- - -# -# Receive a bunch of attributes, evaluate the policy, send the result. -# -my %attr; -while () { - chomp; - if (/=/) { my ($k, $v) = split (/=/, $_, 2); $attr{$k} = $v; next } - elsif (length) { syslog(warn=>sprintf("warning: ignoring garbage: %.100s", $_)); next; } - - if ($VERBOSE) { - for (sort keys %attr) { - syslog(debug=> "Attribute: %s=%s", $_, $attr{$_}); - } - } - - fatal_exit ("unrecognized request type: '$attr{request}'") unless $attr{request} eq "smtpd_access_policy"; - - my $action = "ok"; - my %responses; - foreach my $handler (@HANDLERS) { - no strict 'refs'; - my $response = $handler->(attr=>\%attr); - syslog(debug=> "handler %s: %s", $handler, $response); - if ($response !~ /^(ok|dunno)/i) { - - syslog(info=> "handler %s: %s is decisive.", $handler, $response); - $action = $response; last; - } - } - - syslog(info=> "decided action=%s", $action); - - print STDOUT "action=$action\n\n"; - %attr = (); -} - -# ---------------------------------------------------------- -# plugin: SPF -# ---------------------------------------------------------- -sub sender_permitted_from { - local %_ = @_; - my %attr = %{ $_{attr} }; - - my $query = new Mail::SPF::Query (ip =>$attr{client_address}, - sender=>$attr{sender}, - helo =>$attr{helo_name}); - my ($result, $smtp_comment, $header_comment) = $query->result(); - - syslog(info=>"%s: SPF %s: smtp_comment=%s, header_comment=%s", - $attr{queue_id}, $result, $smtp_comment, $header_comment); - - if ($result eq "pass") { return "DUNNO"; } - elsif ($result eq "fail") { return "REJECT " . ($smtp_comment || $header_comment); } - elsif ($result eq "error") { return "DUNNO"; } - else { return "DUNNO"; } - - # TODO XXX: prepend Received-SPF header. -} - -# ---------------------------------------------------------- -# plugin: testing -# ---------------------------------------------------------- -sub testing { - local %_ = @_; - my %attr = %{ $_{attr} }; - - if (lc address_stripped($attr{sender}) eq - lc address_stripped($attr{recipient}) - and - $attr{recipient} =~ /policyblock/) { - - syslog(info=>"%s: testing: will block as requested", - $attr{queue_id}); - return "REJECT smtpd-policy blocking $attr{recipient}"; - } - else { - syslog(info=>"%s: testing: stripped sender=%s, stripped rcpt=%s", - $attr{queue_id}, - address_stripped($attr{sender}), - address_stripped($attr{recipient}), - ); - - } - return "DUNNO"; -} - -sub address_stripped { - # my $foo = localpart_lhs('foo+bar@baz.com'); # returns 'foo@baz.com' - my $string = shift; - for ($string) { - s/[+-].*\@/\@/; - } - return $string; -} - -# ---------------------------------------------------------- -# plugin: greylisting -# ---------------------------------------------------------- - -my $Database_Obj; -my %DB_Hash; - -# -# Demo SMTPD access policy routine. The result is an action just like -# it would be specified on the right-hand side of a Postfix access -# table. Request attributes are available via the %attr hash. -# -sub greylisting { - local %_ = @_; - my %attr = %{ $_{attr} }; - - my($key, $time_stamp, $now); - - return "DUNNO" if grep { $attr{sender} =~ $_ } @Greylisting_Whitelisted_Senders; - - # Open the database on the fly. - open_database() unless $Database_Obj; - - # Lookup the time stamp for this client/sender/recipient. - $key = lc join "/", @attr{qw( client_address sender recipient )}; - $time_stamp = read_database($key); - $now = time(); - - # If this is a new request add this client/sender/recipient to the database. - if ($time_stamp == 0) { - $time_stamp = $now; - update_database($key, $time_stamp); - } - - # In case of success, return DUNNO instead of OK so that the - # check_policy_service restriction can be followed by other restrictions. - # In case of failure, specify DEFER_IF_PERMIT so that mail can - # still be blocked by other access restrictions. - syslog $syslog_priority, "request age %d", $now - $time_stamp if $VERBOSE; - if ($now - $time_stamp > $GREYLIST_DELAY) { - syslog(debug=> "handler %s: %s showed up in the database more than $GREYLIST_DELAY seconds ago.", - "greylisting", $key); - return "dunno"; - } else { - syslog(debug=> "handler %s: %s has not been in the database $GREYLIST_DELAY seconds. denying.", - "greylisting", $key); - return "defer_if_permit Greylisting delay; please try again after $GREYLIST_DELAY seconds."; - } -} - -# -# You should not have to make changes below this point. -# -sub LOCK_SH { 1 }; # Shared lock (used for reading). -sub LOCK_EX { 2 }; # Exclusive lock (used for writing). -sub LOCK_NB { 4 }; # Don't block (for testing). -sub LOCK_UN { 8 }; # Release lock. - -# -# Open hash database. -# -sub open_database { - my($database_fd); - - # Use tied database to make complex manipulations easier to express. - $Database_Obj = tie(%DB_Hash, 'DB_File', $database_name, - O_CREAT|O_RDWR, 0644, $DB_BTREE) || - fatal_exit "Cannot open database %s while running as $>: $!", $database_name; - $database_fd = $Database_Obj->fd; - open DATABASE_HANDLE, "+<&=$database_fd" || - fatal_exit "Cannot fdopen database %s: $!", $database_name; - syslog $syslog_priority, "open %s", $database_name if $VERBOSE; -} - -# -# Read database. Use a shared lock to avoid reading the database -# while it is being changed. XXX There should be a way to synchronize -# our cache from the on-file database before looking up the key. -# -sub read_database { - my($key) = @_; - my($value); - - flock DATABASE_HANDLE, LOCK_SH || - fatal_exit "Can't get shared lock on %s: $!", $database_name; - # XXX Synchronize our cache from the on-disk copy before lookup. - $value = $DB_Hash{$key}; - syslog $syslog_priority, "lookup %s: %s", $key, $value if $VERBOSE; - flock DATABASE_HANDLE, LOCK_UN || - fatal_exit "Can't unlock %s: $!", $database_name; - return $value; -} - -# -# Update database. Use an exclusive lock to avoid collisions with -# other updaters, and to avoid surprises in database readers. XXX -# There should be a way to synchronize our cache from the on-file -# database before updating the database. -# -sub update_database { - my($key, $value) = @_; - - syslog $syslog_priority, "store %s: %s", $key, $value if $VERBOSE; - flock DATABASE_HANDLE, LOCK_EX || - fatal_exit "Can't exclusively lock %s: $!", $database_name; - # XXX Synchronize our cache from the on-disk copy before update. - $DB_Hash{$key} = $value; - $Database_Obj->sync() && - fatal_exit "Can't update %s: $!", $database_name; - flock DATABASE_HANDLE, LOCK_UN || - fatal_exit "Can't unlock %s: $!", $database_name; -} - - diff --git a/postfix-policyd-spf-perl b/postfix-policyd-spf-perl new file mode 100755 index 0000000..322111e --- /dev/null +++ b/postfix-policyd-spf-perl @@ -0,0 +1,473 @@ +#!/usr/bin/perl + +# postfix-policyd-spf-perl +# https://launchpad.net/postfix-policyd-spf-perl +# http://www.openspf.org/Software +# version 2.011 +# +# (C) 2007-2008,2012,2018 Scott Kitterman +# (C) 2018 Scott Savarese +# (C) 2012 Allison Randal +# (C) 2007 Julian Mehnle +# (C) 2003-2004 Meng Weng Wong +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use version; our $VERSION = qv('2.011'); + +use strict; + +use IO::Handle; +use Sys::Syslog qw(:DEFAULT setlogsock); +use NetAddr::IP; +use Mail::SPF; +use Sys::Hostname::Long 'hostname_long'; + +# ---------------------------------------------------------- +# configuration +# ---------------------------------------------------------- + +my $resolver = Net::DNS::Resolver->new( + retrans => 5, # Net::DNS::Resolver default: 5 + retry => 2, # Net::DNS::Resolver default: 4 + # Makes for a total timeout for UDP queries of 5s * 2 = 10s. +); + +# query_rr_type_all will query both type TXT and type SPF. This upstream +# default is changed due to there being essentiall no type SPF deployment. +my $spf_server = Mail::SPF::Server->new( + dns_resolver => $resolver, + query_rr_types => Mail::SPF::Server->query_rr_type_txt, + default_authority_explanation => + 'Please see http://www.openspf.org/Why?s=%{_scope};id=%{S};ip=%{C};r=%{R}' +); + +# Adding more handlers is easy: +my @HANDLERS = ( + { + name => 'exempt_localhost', + code => \&exempt_localhost + }, + { + name => 'exempt_relay', + code => \&exempt_relay + }, + { + name => 'exempt_domains', + code => \&exempt_domains + }, + { + name => 'sender_policy_framework', + code => \&sender_policy_framework + } +); + +my $VERBOSE = 0; + +my $DEFAULT_RESPONSE = 'DUNNO'; + +# Read in exemption lists +my $exempt_domains = get_exempt_domains( "/etc/postfix/exempt_spf_domains" ); +my $relay_addresses = get_exempt_address("/etc/postfix/exempt_spf_addresses"); + +# +# Syslogging options for verbose mode and for fatal errors. +# NOTE: comment out the $syslog_socktype line if syslogging does not +# work on your system. +# + +my $syslog_socktype = 'unix'; # inet, unix, stream, console +my $syslog_facility = 'mail'; +my $syslog_options = 'pid'; +my $syslog_ident = 'postfix/policy-spf'; + +use constant localhost_addresses => map( + NetAddr::IP->new($_), + qw( 127.0.0.0/8 ::ffff:127.0.0.0/104 ::1 ) +); # Does Postfix ever say "client_address=::ffff:"? + +# Fully qualified hostname, if available, for use in authentication results +# headers now provided by the localhost and whitelist checks. +my $host = hostname_long; + +my %results_cache; # by message instance + +# ---------------------------------------------------------- +# initialization +# ---------------------------------------------------------- + +# +# Log an error and abort. +# +sub fatal_exit { + syslog(err => "fatal_exit: @_"); + syslog(warning => "fatal_exit: @_"); + syslog(info => "fatal_exit: @_"); + die("fatal: @_"); +} + +# +# Unbuffer standard output. +# +STDOUT->autoflush(1); + +# +# This process runs as a daemon, so it can't log to a terminal. Use +# syslog so that people can actually see our messages. +# +setlogsock($syslog_socktype); +openlog($syslog_ident, $syslog_options, $syslog_facility); + +# ---------------------------------------------------------- +# main +# ---------------------------------------------------------- + +# +# Receive a bunch of attributes, evaluate the policy, send the result. +# +my %attr; +while () { + chomp; + + if (/=/) { + my ($key, $value) =split (/=/, $_, 2); + $attr{$key} = $value; + next; + } + elsif (length) { + syslog(warning => sprintf("warning: ignoring garbage: %.100s", $_)); + next; + } + + if ($VERBOSE) { + for (sort keys %attr) { + syslog(debug => "Attribute: %s=%s", $_ || '', $attr{$_} || ''); + } + }; + + my $message_instance = $attr{instance}; + my $cache = defined($message_instance) ? $results_cache{$message_instance} ||= {} : {}; + + my $action = $DEFAULT_RESPONSE; + + foreach my $handler (@HANDLERS) { + my $handler_name = $handler->{name}; + my $handler_code = $handler->{code}; + + my $response = $handler_code->(attr => \%attr, cache => $cache); + + if ($VERBOSE) { + syslog(debug => "handler %s: %s", $handler_name || '', $response || ''); + }; + + # Pick whatever response is not 'DUNNO' + if ($response and $response !~ /^DUNNO/i) { + if ($VERBOSE) { + syslog(info => "handler %s: is decisive.", $handler_name || ''); + } + $action = $response; + last; + } + } + + syslog(info => "Policy action=%s", $action || ''); + + STDOUT->print("action=$action\n\n"); + %attr = (); +} + +# ---------------------------------------------------------- +# handler: domain exemption +# ---------------------------------------------------------- + +sub get_exempt_domains { + my ( $file ) = @_; + + my $list = {}; + + # Return nothing if file not found + if ( ! -r $file ) { + return $list; + } + + # Read the file into one variable, split on space or comma (or all) + open ( FILE, $file ) or die "Can't open $file: $!\n"; + my $text = ""; + while ( my $tmp = ) { + $text .= $tmp; + } + close( FILE ); + + foreach my $domain ( split( /[\s,]+/, $text ) ) { + $list->{$domain} = 1; + } + + return $list; +} + +sub exempt_domains { + my %options = @_; + my $attr = $options{attr}; + + my $domain = ( split( /\@/, $attr->{sender} ) )[1]; + return 'DUNNO' if ( ( ! defined( $domain ) ) or ( $domain eq '' ) ); + + # Check the domain against our list of ignored domains + if ( defined( $exempt_domains->{$domain} ) ) { + return "PREPEND Authentication-Results: $host; none " . + "(SPF exempted by policy)"; + } + + return 'DUNNO'; +} + +# ---------------------------------------------------------- +# handler: localhost exemption +# ---------------------------------------------------------- + +sub exempt_localhost { + my %options = @_; + my $attr = $options{attr}; + if ($attr->{client_address} ne '') { + my $client_address = NetAddr::IP->new($attr->{client_address}); + return "PREPEND Authentication-Results: $host; none (SPF not checked for localhost)" + if grep($_->contains($client_address), localhost_addresses); + }; + return 'DUNNO'; +} + +# ---------------------------------------------------------- +# handler: relay exemption +# ---------------------------------------------------------- + +sub get_exempt_address { + my ( $file ) = @_; + + my $list = []; + + # Return nothing if file not found + if ( ! -r $file ) { + return $list; + } + + # Read the file into one variable, split on space or comma (or all) + open ( FILE, $file ) or die "Can't open $file: $!\n"; + my $text = ""; + while ( my $tmp = ) { + $text .= $tmp; + } + close( FILE ); + + foreach my $addr ( split( /[\s,]+/, $text ) ) { + push( @$list, NetAddr::IP->new($addr) ); + } + return $list; +} + +sub exempt_relay { + my %options = @_; + my $attr = $options{attr}; + if ($attr->{client_address} ne '') { + my $client_address = NetAddr::IP->new($attr->{client_address}); + return "PREPEND Authentication-Results: $host; none (SPF not checked for whitelisted relay)" + if grep($_->contains($client_address), @$relay_addresses); + }; + return 'DUNNO'; +} + +# ---------------------------------------------------------- +# handler: SPF +# ---------------------------------------------------------- + +sub sender_policy_framework { + my %options = @_; + my $attr = $options{attr}; + my $cache = $options{cache}; + + # ------------------------------------------------------------------------- + # Always do HELO check first. If no HELO policy, it's only one lookup. + # This avoids the need to do any MAIL FROM processing for null sender. + # ------------------------------------------------------------------------- + + my $helo_result = $cache->{helo_result}; + + if (not defined($helo_result)) { + # No HELO result has been cached from earlier checks on this message. + + my $helo_request = eval { + Mail::SPF::Request->new( + scope => 'helo', + identity => $attr->{helo_name}, + ip_address => $attr->{client_address} + ); + }; + + if ($@) { + # An unexpected error occurred during request creation, + # probably due to invalid input data! + my $errmsg = $@; + $errmsg = $errmsg->text if UNIVERSAL::isa($@, 'Mail::SPF::Exception'); + if ($VERBOSE) { + syslog( + info => "HELO check failed - Mail::SPF->new(%s, %s, %s) failed: %s", + $attr->{client_address} || '', + $attr->{sender} || '', $attr->{helo_name} || '', + $errmsg || '' + ); + }; + return; + } + + $helo_result = $cache->{helo_result} = $spf_server->process($helo_request); + } + + my $helo_result_code = $helo_result->code; # 'pass', 'fail', etc. + my $helo_local_exp = nullchomp($helo_result->local_explanation); + my $helo_authority_exp = nullchomp($helo_result->authority_explanation) + if $helo_result->is_code('fail'); + my $helo_spf_header = $helo_result->received_spf_header; + + if ($VERBOSE) { + syslog( + info => "SPF %s: HELO/EHLO: %s, IP Address: %s, Recipient: %s", + $helo_result || '', + $attr->{helo_name} || '', $attr->{client_address} || '', + $attr->{recipient} || '' + ); + }; + + # Prepend header on HELO fail instead of rejecting. + # Use the HELO result and return for null sender. + if ($helo_result->is_code('fail')) { + if ($VERBOSE) { + syslog( + info => "SPF %s: HELO/EHLO: %s", + $helo_result || '', + $attr->{helo_name} || '' + ); + }; + return "PREPEND $helo_spf_header" + unless $cache->{added_spf_header}++; + } + elsif ($helo_result->is_code('temperror')) { + if ($VERBOSE) { + syslog( + info => "SPF %s: HELO/EHLO: %s", + $helo_result || '', + $attr->{helo_name} || '' + ); + }; + return "PREPEND $helo_spf_header" + unless $cache->{added_spf_header}++; + } + elsif ($attr->{sender} eq '') { + if ($VERBOSE) { + syslog( + info => "SPF %s: HELO/EHLO (Null Sender): %s", + $helo_result || '', + $attr->{helo_name} || '' + ); + }; + return "PREPEND $helo_spf_header" + unless $cache->{added_spf_header}++; + } + + # ------------------------------------------------------------------------- + # Do MAIL FROM check (as HELO did not give a definitive result) + # ------------------------------------------------------------------------- + + my $mfrom_result = $cache->{mfrom_result}; + + if (not defined($mfrom_result)) { + # No MAIL FROM result has been cached from earlier checks on this message. + + my $mfrom_request = eval { + Mail::SPF::Request->new( + scope => 'mfrom', + identity => $attr->{sender}, + ip_address => $attr->{client_address}, + helo_identity => $attr->{helo_name} # for %{h} macro expansion + ); + }; + + if ($@) { + # An unexpected error occurred during request creation, + # probably due to invalid input data! + my $errmsg = $@; + $errmsg = $errmsg->text if UNIVERSAL::isa($@, 'Mail::SPF::Exception'); + if ($VERBOSE) { + syslog( + info => "Mail From (sender) check failed - Mail::SPF->new(%s, %s, %s) failed: %s", + $attr->{client_address} || '', + $attr->{sender} || '', $attr->{helo_name} || '', $errmsg || '' + ); + }; + return; + } + + $mfrom_result = $cache->{mfrom_result} = $spf_server->process($mfrom_request); + } + + my $mfrom_result_code = $mfrom_result->code; # 'pass', 'fail', etc. + my $mfrom_local_exp = nullchomp($mfrom_result->local_explanation); + my $mfrom_authority_exp = nullchomp($mfrom_result->authority_explanation) + if $mfrom_result->is_code('fail'); + my $mfrom_spf_header = $mfrom_result->received_spf_header; + + if ($VERBOSE) { + syslog( + info => "SPF %s: Envelope-from: %s, IP Address: %s, Recipient: %s", + $mfrom_result || '', + $attr->{sender} || '', $attr->{client_address} || '', + $attr->{recipient} || '' + ); + }; + + # Same approach as HELO.... + if ($VERBOSE) { + syslog( + info => "SPF %s: Envelope-from: %s", + $mfrom_result || '', + $attr->{sender} || '' + ); + }; + if ($mfrom_result->is_code('fail')) { + return "PREPEND $mfrom_spf_header" + unless $cache->{added_spf_header}++; + } + elsif ($mfrom_result->is_code('temperror')) { + return "PREPEND $mfrom_spf_header" + unless $cache->{added_spf_header}++; + } + else { + return "PREPEND $mfrom_spf_header" + unless $cache->{added_spf_header}++; + } + + return; +} + +# ---------------------------------------------------------- +# utility, string cleaning +# ---------------------------------------------------------- + +sub nullchomp { + my $value = shift; + + # Remove one or more null characters from the + # end of the input. + $value =~ s/\0+$//; + return $value; +} diff --git a/postfix-policyd-spf-perl.in b/postfix-policyd-spf-perl.in new file mode 100644 index 0000000..a626c89 --- /dev/null +++ b/postfix-policyd-spf-perl.in @@ -0,0 +1,473 @@ +#!/usr/bin/perl + +# postfix-policyd-spf-perl +# https://launchpad.net/postfix-policyd-spf-perl +# http://www.openspf.org/Software +# version 2.011 +# +# (C) 2007-2008,2012,2018 Scott Kitterman +# (C) 2018 Scott Savarese +# (C) 2012 Allison Randal +# (C) 2007 Julian Mehnle +# (C) 2003-2004 Meng Weng Wong +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use version; our $VERSION = qv('2.011'); + +use strict; + +use IO::Handle; +use Sys::Syslog qw(:DEFAULT setlogsock); +use NetAddr::IP; +use Mail::SPF; +use Sys::Hostname::Long 'hostname_long'; + +# ---------------------------------------------------------- +# configuration +# ---------------------------------------------------------- + +my $resolver = Net::DNS::Resolver->new( + retrans => 5, # Net::DNS::Resolver default: 5 + retry => 2, # Net::DNS::Resolver default: 4 + # Makes for a total timeout for UDP queries of 5s * 2 = 10s. +); + +# query_rr_type_all will query both type TXT and type SPF. This upstream +# default is changed due to there being essentiall no type SPF deployment. +my $spf_server = Mail::SPF::Server->new( + dns_resolver => $resolver, + query_rr_types => Mail::SPF::Server->query_rr_type_txt, + default_authority_explanation => + 'Please see http://www.openspf.org/Why?s=%{_scope};id=%{S};ip=%{C};r=%{R}' +); + +# Adding more handlers is easy: +my @HANDLERS = ( + { + name => 'exempt_localhost', + code => \&exempt_localhost + }, + { + name => 'exempt_relay', + code => \&exempt_relay + }, + { + name => 'exempt_domains', + code => \&exempt_domains + }, + { + name => 'sender_policy_framework', + code => \&sender_policy_framework + } +); + +my $VERBOSE = 0; + +my $DEFAULT_RESPONSE = 'DUNNO'; + +# Read in exemption lists +my $exempt_domains = get_exempt_domains( "@@CDIR@@/exempt_spf_domains" ); +my $relay_addresses = get_exempt_address("@@CDIR@@/exempt_spf_addresses"); + +# +# Syslogging options for verbose mode and for fatal errors. +# NOTE: comment out the $syslog_socktype line if syslogging does not +# work on your system. +# + +my $syslog_socktype = 'unix'; # inet, unix, stream, console +my $syslog_facility = 'mail'; +my $syslog_options = 'pid'; +my $syslog_ident = 'postfix/policy-spf'; + +use constant localhost_addresses => map( + NetAddr::IP->new($_), + qw( 127.0.0.0/8 ::ffff:127.0.0.0/104 ::1 ) +); # Does Postfix ever say "client_address=::ffff:"? + +# Fully qualified hostname, if available, for use in authentication results +# headers now provided by the localhost and whitelist checks. +my $host = hostname_long; + +my %results_cache; # by message instance + +# ---------------------------------------------------------- +# initialization +# ---------------------------------------------------------- + +# +# Log an error and abort. +# +sub fatal_exit { + syslog(err => "fatal_exit: @_"); + syslog(warning => "fatal_exit: @_"); + syslog(info => "fatal_exit: @_"); + die("fatal: @_"); +} + +# +# Unbuffer standard output. +# +STDOUT->autoflush(1); + +# +# This process runs as a daemon, so it can't log to a terminal. Use +# syslog so that people can actually see our messages. +# +setlogsock($syslog_socktype); +openlog($syslog_ident, $syslog_options, $syslog_facility); + +# ---------------------------------------------------------- +# main +# ---------------------------------------------------------- + +# +# Receive a bunch of attributes, evaluate the policy, send the result. +# +my %attr; +while () { + chomp; + + if (/=/) { + my ($key, $value) =split (/=/, $_, 2); + $attr{$key} = $value; + next; + } + elsif (length) { + syslog(warning => sprintf("warning: ignoring garbage: %.100s", $_)); + next; + } + + if ($VERBOSE) { + for (sort keys %attr) { + syslog(debug => "Attribute: %s=%s", $_ || '', $attr{$_} || ''); + } + }; + + my $message_instance = $attr{instance}; + my $cache = defined($message_instance) ? $results_cache{$message_instance} ||= {} : {}; + + my $action = $DEFAULT_RESPONSE; + + foreach my $handler (@HANDLERS) { + my $handler_name = $handler->{name}; + my $handler_code = $handler->{code}; + + my $response = $handler_code->(attr => \%attr, cache => $cache); + + if ($VERBOSE) { + syslog(debug => "handler %s: %s", $handler_name || '', $response || ''); + }; + + # Pick whatever response is not 'DUNNO' + if ($response and $response !~ /^DUNNO/i) { + if ($VERBOSE) { + syslog(info => "handler %s: is decisive.", $handler_name || ''); + } + $action = $response; + last; + } + } + + syslog(info => "Policy action=%s", $action || ''); + + STDOUT->print("action=$action\n\n"); + %attr = (); +} + +# ---------------------------------------------------------- +# handler: domain exemption +# ---------------------------------------------------------- + +sub get_exempt_domains { + my ( $file ) = @_; + + my $list = {}; + + # Return nothing if file not found + if ( ! -r $file ) { + return $list; + } + + # Read the file into one variable, split on space or comma (or all) + open ( FILE, $file ) or die "Can't open $file: $!\n"; + my $text = ""; + while ( my $tmp = ) { + $text .= $tmp; + } + close( FILE ); + + foreach my $domain ( split( /[\s,]+/, $text ) ) { + $list->{$domain} = 1; + } + + return $list; +} + +sub exempt_domains { + my %options = @_; + my $attr = $options{attr}; + + my $domain = ( split( /\@/, $attr->{sender} ) )[1]; + return 'DUNNO' if ( ( ! defined( $domain ) ) or ( $domain eq '' ) ); + + # Check the domain against our list of ignored domains + if ( defined( $exempt_domains->{$domain} ) ) { + return "PREPEND Authentication-Results: $host; none " . + "(SPF exempted by policy)"; + } + + return 'DUNNO'; +} + +# ---------------------------------------------------------- +# handler: localhost exemption +# ---------------------------------------------------------- + +sub exempt_localhost { + my %options = @_; + my $attr = $options{attr}; + if ($attr->{client_address} ne '') { + my $client_address = NetAddr::IP->new($attr->{client_address}); + return "PREPEND Authentication-Results: $host; none (SPF not checked for localhost)" + if grep($_->contains($client_address), localhost_addresses); + }; + return 'DUNNO'; +} + +# ---------------------------------------------------------- +# handler: relay exemption +# ---------------------------------------------------------- + +sub get_exempt_address { + my ( $file ) = @_; + + my $list = []; + + # Return nothing if file not found + if ( ! -r $file ) { + return $list; + } + + # Read the file into one variable, split on space or comma (or all) + open ( FILE, $file ) or die "Can't open $file: $!\n"; + my $text = ""; + while ( my $tmp = ) { + $text .= $tmp; + } + close( FILE ); + + foreach my $addr ( split( /[\s,]+/, $text ) ) { + push( @$list, NetAddr::IP->new($addr) ); + } + return $list; +} + +sub exempt_relay { + my %options = @_; + my $attr = $options{attr}; + if ($attr->{client_address} ne '') { + my $client_address = NetAddr::IP->new($attr->{client_address}); + return "PREPEND Authentication-Results: $host; none (SPF not checked for whitelisted relay)" + if grep($_->contains($client_address), @$relay_addresses); + }; + return 'DUNNO'; +} + +# ---------------------------------------------------------- +# handler: SPF +# ---------------------------------------------------------- + +sub sender_policy_framework { + my %options = @_; + my $attr = $options{attr}; + my $cache = $options{cache}; + + # ------------------------------------------------------------------------- + # Always do HELO check first. If no HELO policy, it's only one lookup. + # This avoids the need to do any MAIL FROM processing for null sender. + # ------------------------------------------------------------------------- + + my $helo_result = $cache->{helo_result}; + + if (not defined($helo_result)) { + # No HELO result has been cached from earlier checks on this message. + + my $helo_request = eval { + Mail::SPF::Request->new( + scope => 'helo', + identity => $attr->{helo_name}, + ip_address => $attr->{client_address} + ); + }; + + if ($@) { + # An unexpected error occurred during request creation, + # probably due to invalid input data! + my $errmsg = $@; + $errmsg = $errmsg->text if UNIVERSAL::isa($@, 'Mail::SPF::Exception'); + if ($VERBOSE) { + syslog( + info => "HELO check failed - Mail::SPF->new(%s, %s, %s) failed: %s", + $attr->{client_address} || '', + $attr->{sender} || '', $attr->{helo_name} || '', + $errmsg || '' + ); + }; + return; + } + + $helo_result = $cache->{helo_result} = $spf_server->process($helo_request); + } + + my $helo_result_code = $helo_result->code; # 'pass', 'fail', etc. + my $helo_local_exp = nullchomp($helo_result->local_explanation); + my $helo_authority_exp = nullchomp($helo_result->authority_explanation) + if $helo_result->is_code('fail'); + my $helo_spf_header = $helo_result->received_spf_header; + + if ($VERBOSE) { + syslog( + info => "SPF %s: HELO/EHLO: %s, IP Address: %s, Recipient: %s", + $helo_result || '', + $attr->{helo_name} || '', $attr->{client_address} || '', + $attr->{recipient} || '' + ); + }; + + # Prepend header on HELO fail instead of rejecting. + # Use the HELO result and return for null sender. + if ($helo_result->is_code('fail')) { + if ($VERBOSE) { + syslog( + info => "SPF %s: HELO/EHLO: %s", + $helo_result || '', + $attr->{helo_name} || '' + ); + }; + return "PREPEND $helo_spf_header" + unless $cache->{added_spf_header}++; + } + elsif ($helo_result->is_code('temperror')) { + if ($VERBOSE) { + syslog( + info => "SPF %s: HELO/EHLO: %s", + $helo_result || '', + $attr->{helo_name} || '' + ); + }; + return "PREPEND $helo_spf_header" + unless $cache->{added_spf_header}++; + } + elsif ($attr->{sender} eq '') { + if ($VERBOSE) { + syslog( + info => "SPF %s: HELO/EHLO (Null Sender): %s", + $helo_result || '', + $attr->{helo_name} || '' + ); + }; + return "PREPEND $helo_spf_header" + unless $cache->{added_spf_header}++; + } + + # ------------------------------------------------------------------------- + # Do MAIL FROM check (as HELO did not give a definitive result) + # ------------------------------------------------------------------------- + + my $mfrom_result = $cache->{mfrom_result}; + + if (not defined($mfrom_result)) { + # No MAIL FROM result has been cached from earlier checks on this message. + + my $mfrom_request = eval { + Mail::SPF::Request->new( + scope => 'mfrom', + identity => $attr->{sender}, + ip_address => $attr->{client_address}, + helo_identity => $attr->{helo_name} # for %{h} macro expansion + ); + }; + + if ($@) { + # An unexpected error occurred during request creation, + # probably due to invalid input data! + my $errmsg = $@; + $errmsg = $errmsg->text if UNIVERSAL::isa($@, 'Mail::SPF::Exception'); + if ($VERBOSE) { + syslog( + info => "Mail From (sender) check failed - Mail::SPF->new(%s, %s, %s) failed: %s", + $attr->{client_address} || '', + $attr->{sender} || '', $attr->{helo_name} || '', $errmsg || '' + ); + }; + return; + } + + $mfrom_result = $cache->{mfrom_result} = $spf_server->process($mfrom_request); + } + + my $mfrom_result_code = $mfrom_result->code; # 'pass', 'fail', etc. + my $mfrom_local_exp = nullchomp($mfrom_result->local_explanation); + my $mfrom_authority_exp = nullchomp($mfrom_result->authority_explanation) + if $mfrom_result->is_code('fail'); + my $mfrom_spf_header = $mfrom_result->received_spf_header; + + if ($VERBOSE) { + syslog( + info => "SPF %s: Envelope-from: %s, IP Address: %s, Recipient: %s", + $mfrom_result || '', + $attr->{sender} || '', $attr->{client_address} || '', + $attr->{recipient} || '' + ); + }; + + # Same approach as HELO.... + if ($VERBOSE) { + syslog( + info => "SPF %s: Envelope-from: %s", + $mfrom_result || '', + $attr->{sender} || '' + ); + }; + if ($mfrom_result->is_code('fail')) { + return "PREPEND $mfrom_spf_header" + unless $cache->{added_spf_header}++; + } + elsif ($mfrom_result->is_code('temperror')) { + return "PREPEND $mfrom_spf_header" + unless $cache->{added_spf_header}++; + } + else { + return "PREPEND $mfrom_spf_header" + unless $cache->{added_spf_header}++; + } + + return; +} + +# ---------------------------------------------------------- +# utility, string cleaning +# ---------------------------------------------------------- + +sub nullchomp { + my $value = shift; + + # Remove one or more null characters from the + # end of the input. + $value =~ s/\0+$//; + return $value; +}