diff options
author | Guilhem Moulin <guilhem@fripost.org> | 2020-05-20 15:46:27 +0200 |
---|---|---|
committer | Guilhem Moulin <guilhem@fripost.org> | 2020-05-21 03:40:53 +0200 |
commit | 6d1daa0424c168eae4bfa9f6772add3f77ec506f (patch) | |
tree | a45e83f4fefa0a3976c534078d26d3ff003e9935 /roles | |
parent | 5118f8d3394579a245b355c863c69410fe92e26e (diff) |
postfix-sender-login: Better hardening.
Run as a dedicated user, not ‘postfix’.
Diffstat (limited to 'roles')
-rw-r--r-- | roles/MSA/files/etc/systemd/system/postfix-sender-login.service | 6 | ||||
-rwxr-xr-x | roles/MSA/files/usr/local/bin/postfix-sender-login.pl | 13 | ||||
-rw-r--r-- | roles/MSA/tasks/main.yml | 16 | ||||
-rw-r--r-- | roles/common-LDAP/templates/etc/ldap/database.ldif.j2 | 53 |
4 files changed, 59 insertions, 29 deletions
diff --git a/roles/MSA/files/etc/systemd/system/postfix-sender-login.service b/roles/MSA/files/etc/systemd/system/postfix-sender-login.service index f5e6d89..d652f75 100644 --- a/roles/MSA/files/etc/systemd/system/postfix-sender-login.service +++ b/roles/MSA/files/etc/systemd/system/postfix-sender-login.service @@ -1,27 +1,25 @@ [Unit] Description=Postfix sender login socketmap After=mail-transport-agent.target Requires=postfix-sender-login.socket [Service] -User=postfix -Group=postfix +User=_postfix-sender-login StandardInput=null SyslogFacility=mail ExecStart=/usr/local/bin/postfix-sender-login.pl # Hardening NoNewPrivileges=yes PrivateDevices=yes +PrivateNetwork=yes ProtectHome=yes ProtectSystem=strict -PrivateDevices=yes -PrivateNetwork=yes ProtectControlGroups=yes ProtectKernelModules=yes ProtectKernelTunables=yes RestrictAddressFamilies=AF_UNIX [Install] WantedBy=multi-user.target Also=postfix-sender-login.socket diff --git a/roles/MSA/files/usr/local/bin/postfix-sender-login.pl b/roles/MSA/files/usr/local/bin/postfix-sender-login.pl index 374cc70..a37f872 100755 --- a/roles/MSA/files/usr/local/bin/postfix-sender-login.pl +++ b/roles/MSA/files/usr/local/bin/postfix-sender-login.pl @@ -1,89 +1,90 @@ #!/usr/bin/perl -T #---------------------------------------------------------------------- # socketmap lookup table returning the SASL login name(s) owning a given # sender address -# Copyright © 2017 Guilhem Moulin <guilhem@fripost.org> +# Copyright © 2017,2020 Guilhem Moulin <guilhem@fripost.org> # # 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 3 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, see <http://www.gnu.org/licenses/>. #---------------------------------------------------------------------- use warnings; use strict; use Errno 'EINTR'; use Net::LDAPI (); use Net::LDAP::Util qw/ldap_explode_dn escape_dn_value escape_filter_value/; use Net::LDAP::Constant qw/LDAP_NO_SUCH_OBJECT/; use Authen::SASL (); # clean up PATH $ENV{PATH} = join ':', qw{/usr/bin /bin}; delete @ENV{qw/IFS CDPATH ENV BASH_ENV/}; -my $nProc = 2; # number of pre-forked servers -my $POSTMASTER = 'postmaster@fripost.org'; # returned for forbidden envelope sender addresses +my $nProc = 2; # number of pre-forked servers +my $maxRequests = 32; # maximum number of requests per worker +my $POSTMASTER = 'postmaster@fripost.org'; # returned for forbidden envelope sender addresses -my $BASEDN = 'ou=virtual,dc=fripost,dc=org'; +my $BASEDN = "ou=virtual,dc=fripost,dc=org"; my $BUFSIZE = 65536; # try to read that many bytes at the time -my $LDAPI = 'ldapi://%2Fvar%2Fspool%2Fpostfix-msa%2Fprivate%2Fldapi/'; +my $LDAPI = "ldapi://"; sub server(); # fdopen(3) the file descriptor FD die "This service must be socket-activated.\n" unless defined $ENV{LISTEN_PID} and $ENV{LISTEN_PID} == $$ and defined $ENV{LISTEN_FDS} and $ENV{LISTEN_FDS} == 1; open my $S, '+<&=', 3 or die "fdopen: $!"; my @CHILDREN; for (my $i = 0; $i < $nProc-1; $i++) { my $pid = fork() // die "fork: $!"; if ($pid) { push @CHILDREN, $pid; } else { server(); # child, never return exit; } } server(); waitpid $_ => 0 foreach @CHILDREN; exit $?; ############################################################################# sub server() { - for (my $n = 0; $n < 32; $n++) { + for (my $n = 0; $n < $maxRequests; $n++) { accept(my $conn, $S) or do { next if $! == EINTR; die "accept: $!"; }; my $reply = process_request($conn); # encode the reply as a netstring and send it back # https://cr.yp.to/proto/netstrings.txt $reply = length($reply).':'.$reply.','; my $len = length($reply); for (my $i = 0; $i < $len;) { my $n = syswrite($conn, $reply, $len-$i, $i) // do { next if $! == EINTR; warn "Can't write: $!"; last; }; $i += $n; } close $conn or warn "Can't close: $!"; diff --git a/roles/MSA/tasks/main.yml b/roles/MSA/tasks/main.yml index c78139a..2eee925 100644 --- a/roles/MSA/tasks/main.yml +++ b/roles/MSA/tasks/main.yml @@ -1,34 +1,50 @@ - name: Install Postfix apt: pkg={{ packages }} vars: packages: - postfix - postfix-pcre - postfix-policyd-spf-python +- name: Install Net::LDAP and Authen::SASL + apt: pkg={{ packages }} + vars: + packages: + - libnet-ldap-perl + - libauthen-sasl-perl + - name: Copy Postfix sender login socketmap copy: src=usr/local/bin/postfix-sender-login.pl dest=/usr/local/bin/postfix-sender-login.pl owner=root group=staff mode=0755 +- name: Create '_postfix-sender-login' user + user: name=_postfix-sender-login system=yes + group=nogroup + createhome=no + home=/nonexistent + shell=/usr/sbin/nologin + password=! + state=present + - name: Copy Postfix sender login socketmap systemd unit files copy: src=etc/systemd/system/{{ item }} dest=/etc/systemd/system/{{ item }} owner=root group=root mode=0644 with_items: - postfix-sender-login.service - postfix-sender-login.socket notify: - systemctl daemon-reload - meta: flush_handlers - name: Enable Postfix sender login socketmap service: name=postfix-sender-login.socket state=started enabled=yes - name: Configure Postfix template: src=etc/postfix/{{ item }}.j2 dest=/etc/postfix-{{ postfix_instance[inst].name }}/{{ item }} owner=root group=root diff --git a/roles/common-LDAP/templates/etc/ldap/database.ldif.j2 b/roles/common-LDAP/templates/etc/ldap/database.ldif.j2 index b640cbf..9b4633b 100644 --- a/roles/common-LDAP/templates/etc/ldap/database.ldif.j2 +++ b/roles/common-LDAP/templates/etc/ldap/database.ldif.j2 @@ -250,79 +250,87 @@ olcAccess: to dn.subtree="ou=virtual,dc=fripost,dc=org" by group.exact="cn=admin,ou=groups,dc=fripost,dc=org" =wrsd by users =0 break olcAccess: to dn.children="ou=virtual,dc=fripost,dc=org" by group.exact="cn=admin,ou=groups,dc=fripost,dc=org" =wrsd by users =0 break {% endif -%} # # * Postfix may use the base as a searchBase on the MX:es, when # connecting a local ldapi:// socket from the 'private' directory in # one of the non-default instance's chroot. # * So may _dovecot-auth-proxy on the MDA (needed for the iterate # logic), when SASL-binding using the EXTERNAL mechanism and # connecting to a local ldapi:// socket. # * So may Nextcloud on the LDAP provider olcAccess: to dn.exact="ou=virtual,dc=fripost,dc=org" attrs=entry,objectClass filter=(objectClass=FripostVirtual) {% if 'MDA' in group_names -%} by dn.exact="username=_dovecot-auth-proxy,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://" =sd {% endif -%} - {% if 'MX' in group_names or 'MSA' in group_names -%} + {% if 'MX' in group_names -%} by dn.exact="username=postfix,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://%2Fvar%2Fspool%2Fpostfix-[-[:alnum:]]+%2Fprivate%2F" =sd {% endif -%} + {% if 'MSA' in group_names -%} + by dn.exact="username=_postfix-sender-login,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://" =sd + {% endif -%} {% if 'LDAP_provider' in group_names -%} by dn.exact="cn=nextcloud,ou=services,dc=fripost,dc=org" tls_ssf=128 =sd {% endif -%} by users =0 break # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # Domain entries # # * The SyncRepl replicates have read access to the entry itself, when # using a TLS-protected connection. # * So has Postfix, when connecting a local ldapi:// socket from the # 'private' directory in one of the non-default instance's chroot. -# * So has _dovecot-auth-proxy on the MDA (for the iterate logic), when -# SASL-binding using the EXTERNAL mechanism and connecting to a local -# ldapi:// socket. -# * Amavis may use the entry as searchBase (required to look for the -# per-user preferences) but doesn't have read access to the entry. # * The 'nobody' UNIX user has read access on the MX:es, when using # SASL-binding using the EXTERNAL mechanism and connecting to a local # ldapi:// socket. This is required for the 'reserved-alias.pl' # script. +# * Amavis may use the entry as searchBase (required to look for the +# per-user preferences) but doesn't have read access to the entry. +# * So has _dovecot-auth-proxy on the MDA (for the iterate logic), when +# SASL-binding using the EXTERNAL mechanism and connecting to a local +# ldapi:// socket. +# * So has _postfix-sender-login on the submission service to verify +# envelope sender ownership olcAccess: to dn.regex="^fvd=[^,]+,ou=virtual,dc=fripost,dc=org$" attrs=entry,objectClass,fvd filter=(&(objectClass=FripostVirtualDomain)(!(objectClass=FripostPendingEntry))) - {% if 'LDAP_provider' in group_names -%} - {% if groups.MX | difference([inventory_hostname]) -%} + {% if 'LDAP_provider' in group_names and groups.MX | difference([inventory_hostname]) -%} by dn.exact="cn=mX,ou=syncRepl,dc=fripost,dc=org" tls_ssf=128 =rsd {% endif -%} - {% endif -%} + {% if 'MX' in group_names -%} by dn.exact="username=postfix,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://%2Fvar%2Fspool%2Fpostfix-[-[:alnum:]]+%2Fprivate%2F" =rsd + by dn.exact="username=nobody,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://" =rsd + {% endif -%} {% if 'MDA' in group_names -%} - by dn.exact="username=_dovecot-auth-proxy,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://" =rsd by dn.exact="username=amavis,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://" =sd {% endif -%} - {% if 'MX' in group_names -%} - by dn.exact="username=nobody,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://" =rsd + {% if 'IMAP' in group_names -%} + by dn.exact="username=_dovecot-auth-proxy,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://" =rsd + {% endif -%} + {% if 'MSA' in group_names -%} + by dn.exact="username=_postfix-sender-login,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://" =rsd {% endif -%} by users =0 break # # * The SyncRepl MX replicates can check whether a virtual domain is # active, and read the destination address for catch-alls, when using # a TLS-protected connection. # * So can Postfix on the MX:es, when connecting a local ldapi:// socket # from the 'private' directory in one of the non-default instance's # chroot. {% if 'MX' in group_names or ('LDAP_provider' in group_names and groups.MX | difference([inventory_hostname])) %} olcAccess: to dn.regex="^fvd=[^,]+,ou=virtual,dc=fripost,dc=org$" attrs=fripostIsStatusActive,fripostOptionalMaildrop filter=(&(objectClass=FripostVirtualDomain)(!(objectClass=FripostPendingEntry))) {% if 'LDAP_provider' in group_names and groups.MX | difference([inventory_hostname]) -%} by dn.exact="cn=mX,ou=syncRepl,dc=fripost,dc=org" tls_ssf=128 =rsd {% endif -%} {% if 'MX' in group_names -%} by dn.exact="username=postfix,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://%2Fvar%2Fspool%2Fpostfix-[-[:alnum:]]+%2Fprivate%2F" =rsd {% endif -%} by users =0 break @@ -366,45 +374,52 @@ olcAccess: to dn.regex="^fvd=[^,]+,ou=virtual,dc=fripost,dc=org$" {% endif %} # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # User entries # # * The SyncRepl replicates have read access to the entry itself, when # using a TLS-protected connection. # * So has Postfix, when connecting a local ldapi:// socket from the # 'private' directory in one of the non-default instance's chroot. # * So has _dovecot-auth-proxy on the MDA (for the iterate logic), when # SASL-binding using the EXTERNAL mechanism and connecting to a local # ldapi:// socket. # * So has Amavis on the MDA, when SASL-binding using the EXTERNAL # mechanism and connecting to a local ldapi:// socket. olcAccess: to dn.regex="^fvl=[^,]+,fvd=[^,]+,ou=virtual,dc=fripost,dc=org$" attrs=entry,objectClass,fvl filter=(objectClass=FripostVirtualUser) {% if 'LDAP_provider' in group_names and groups.MX | difference([inventory_hostname]) -%} by dn.exact="cn=mX,ou=syncRepl,dc=fripost,dc=org" tls_ssf=128 =rsd {% endif -%} + {% if 'MX' in group_names -%} by dn.exact="username=postfix,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://%2Fvar%2Fspool%2Fpostfix-[-[:alnum:]]+%2Fprivate%2F" =rsd + {% endif -%} {% if 'MDA' in group_names -%} - by dn.exact="username=_dovecot-auth-proxy,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://" =rsd by dn.exact="username=amavis,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://" =rsd {% endif -%} + {% if 'IMAP' in group_names -%} + by dn.exact="username=_dovecot-auth-proxy,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://" =rsd + {% endif -%} + {% if 'MSA' in group_names -%} + by dn.exact="username=_postfix-sender-login,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://" =rsd + {% endif -%} by users =0 break # # * The SyncRepl MX replicates can check whether a virtual user is # active, when using a TLS-protected connection. # * So can Postfix on the MX:es, when connecting a local ldapi:// socket # from the 'private' directory in one of the non-default instance's # chroot. {% if 'MX' in group_names or ('LDAP_provider' in group_names and groups.MX | difference([inventory_hostname])) %} olcAccess: to dn.regex="^fvl=[^,]+,fvd=[^,]+,ou=virtual,dc=fripost,dc=org$" attrs=fripostIsStatusActive,fripostUseContentFilter filter=(objectClass=FripostVirtualUser) {% if 'LDAP_provider' in group_names and groups.MX | difference([inventory_hostname]) -%} by dn.exact="cn=mX,ou=syncRepl,dc=fripost,dc=org" tls_ssf=128 =rsd {% endif -%} {% if 'MX' in group_names -%} by dn.exact="username=postfix,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://%2Fvar%2Fspool%2Fpostfix-[-[:alnum:]]+%2Fprivate%2F" =rsd {% endif -%} by users =0 break {% endif %} {% if 'MDA' in group_names %} @@ -472,53 +487,53 @@ olcAccess: to dn.regex="^fvl=[^,]+,fvd=[^,]+,ou=virtual,dc=fripost,dc=org$" {% endif %} # # * The SyncRepl MX replicates can check whether a virtual list is # active when using a TLS-protected connection. # * So can Postfix on the MX:es, when connecting a local ldapi:// socket # from the 'private' directory in one of the non-default instance's # chroot. {% if 'MX' in group_names or ('LDAP_provider' in group_names and groups.MX | difference([inventory_hostname])) %} olcAccess: to dn.regex="^fvl=[^,]+,fvd=[^,]+,ou=virtual,dc=fripost,dc=org$" attrs=fripostIsStatusActive filter=(&(objectClass=FripostVirtualList)(!(objectClass=FripostPendingEntry))) {% if 'LDAP_provider' in group_names and groups.MX | difference([inventory_hostname]) -%} by dn.exact="cn=mX,ou=syncRepl,dc=fripost,dc=org" tls_ssf=128 =rsd {% endif -%} {% if 'MX' in group_names -%} by dn.exact="username=postfix,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://%2Fvar%2Fspool%2Fpostfix-[-[:alnum:]]+%2Fprivate%2F" =rsd {% endif -%} by users =0 break {% endif %} # -# * The MSA's postfix user can read entry ownership to dermine the SASL -# login name(s) owning a given sender address +# * The MSA's _postfix-sender-login user can read entry ownership to +# dermine the SASL login name(s) owning a given sender address {% if 'MSA' in group_names %} olcAccess: to dn.regex="^fvd=[^,]+,ou=virtual,dc=fripost,dc=org$" attrs=fripostOwner,fripostPostmaster filter=(|(objectClass=FripostVirtualAliasDomain)(objectClass=FripostVirtualDomain)) - by dn.exact="username=postfix,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://%2Fvar%2Fspool%2Fpostfix-[-[:alnum:]]+%2Fprivate%2F" =rsd - by users =0 break + by dn.exact="username=_postfix-sender-login,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://" =rsd + by users =0 break olcAccess: to dn.regex="^fvl=[^,]+,fvd=[^,]+,ou=virtual,dc=fripost,dc=org$" attrs=entry,objectClass,fvl,fripostOwner filter=(|(objectClass=FripostVirtualAlias)(objectClass=FripostVirtualList)(objectClass=FripostVirtualUser)) - by dn.exact="username=postfix,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://%2Fvar%2Fspool%2Fpostfix-[-[:alnum:]]+%2Fprivate%2F" =rsd - by users =0 break + by dn.exact="username=_postfix-sender-login,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://" =rsd + by users =0 break {% endif %} {% if 'LDAP_provider' in group_names %} # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # Export Fripost members to Nextcloud olcAccess: to dn.exact="fvd=fripost.org,ou=virtual,dc=fripost,dc=org" attrs=entry,objectClass,fvd filter=(&(objectClass=FripostVirtualDomain)(!(objectClass=FripostPendingEntry))) by dn.exact="cn=nextcloud,ou=services,dc=fripost,dc=org" tls_ssf=128 =rsd by users =0 break olcAccess: to dn.regex="^fvl=[^,]+,fvd=fripost.org,ou=virtual,dc=fripost,dc=org$" attrs=entry,entryDN,entryUUID,objectClass,fvl,fripostIsStatusActive filter=(&(objectClass=FripostVirtualUser)(!(objectClass=FripostPendingEntry))(fripostIsStatusActive=TRUE)) by dn.exact="cn=nextcloud,ou=services,dc=fripost,dc=org" tls_ssf=128 =rsd by users =0 break olcAccess: to dn.exact="ou=groups,dc=fripost,dc=org" attrs=entry,objectClass by dn.exact="cn=nextcloud,ou=services,dc=fripost,dc=org" tls_ssf=128 =rsd by users =0 break |