From 6e39bad3fbe75b88fca4c2e2aad8eb51af14b1be Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Wed, 31 May 2017 21:42:32 +0200 Subject: Don't let authenticated client use arbitrary sender addresses. The following policy is now implemented: * users can use their SASL login name as sender address; * alias and/or list owners can use the address as envelope sender; * domain postmasters can use arbitrary sender addresses under their domains; * domain owners can use arbitrary sender addresses under their domains, unless it is also an existing account name; * for known domains without owner or postmasters, other sender addresses are not allowed; and * arbitrary sender addresses under unknown domains are allowed. --- group_vars/all.yml | 3 +- .../systemd/system/postfix-sender-login.service | 23 +++ .../etc/systemd/system/postfix-sender-login.socket | 8 + .../files/usr/local/bin/postfix-sender-login.pl | 171 +++++++++++++++++++++ roles/MSA/handlers/main.yml | 3 + roles/MSA/tasks/main.yml | 22 +++ roles/MSA/templates/etc/postfix/main.cf.j2 | 2 + roles/common-LDAP/templates/etc/default/slapd.j2 | 2 +- .../templates/etc/ldap/database.ldif.j2 | 17 +- roles/common/templates/etc/postfix/master.cf.j2 | 7 +- roles/webmail/tasks/roundcube.yml | 7 +- 11 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 roles/MSA/etc/systemd/system/postfix-sender-login.service create mode 100644 roles/MSA/etc/systemd/system/postfix-sender-login.socket create mode 100755 roles/MSA/files/usr/local/bin/postfix-sender-login.pl diff --git a/group_vars/all.yml b/group_vars/all.yml index 97e2024..236527e 100644 --- a/group_vars/all.yml +++ b/group_vars/all.yml @@ -40,7 +40,8 @@ postfix_instance: , addr: "{{ (groups.all | length > 1) | ternary( ipsec[ hostvars[groups.out[0]].inventory_hostname_short ], '127.0.0.1') }}" , port: 2525 } MSA: { name: msa - , port: 587 } + , addr: "{{ (groups.all | length > 1) | ternary( ipsec[ hostvars[groups.MSA[0]].inventory_hostname_short ], '127.0.0.1') }}" + , port: 2587 } lists: { name: lists , addr: "{{ (groups.all | length > 1) | ternary( ipsec[ hostvars[groups.lists[0]].inventory_hostname_short ], '127.0.0.1') }}" , port: 2527 } diff --git a/roles/MSA/etc/systemd/system/postfix-sender-login.service b/roles/MSA/etc/systemd/system/postfix-sender-login.service new file mode 100644 index 0000000..3ceb310 --- /dev/null +++ b/roles/MSA/etc/systemd/system/postfix-sender-login.service @@ -0,0 +1,23 @@ +[Unit] +Description=Postfix sender login socketmap +After=mail-transport-agent.target +Requires=postfix-sender-login.socket + +[Service] +User=postfix +Group=postfix +StandardInput=null +SyslogFacility=mail +ExecStart=/usr/local/bin/postfix-sender-login.pl + +# Hardening +NoNewPrivileges=yes +PrivateDevices=yes +ProtectHome=yes +ProtectSystem=full +ReadOnlyDirectories=/ +RestrictAddressFamilies=AF_UNIX + +[Install] +WantedBy=multi-user.target +Also=postfix-sender-login.socket diff --git a/roles/MSA/etc/systemd/system/postfix-sender-login.socket b/roles/MSA/etc/systemd/system/postfix-sender-login.socket new file mode 100644 index 0000000..c883dc1 --- /dev/null +++ b/roles/MSA/etc/systemd/system/postfix-sender-login.socket @@ -0,0 +1,8 @@ +[Socket] +SocketUser=postfix +SocketGroup=postfix +SocketMode=0666 +ListenStream=/var/spool/postfix-msa/private/sender-login + +[Install] +WantedBy=sockets.target diff --git a/roles/MSA/files/usr/local/bin/postfix-sender-login.pl b/roles/MSA/files/usr/local/bin/postfix-sender-login.pl new file mode 100755 index 0000000..58cbf9d --- /dev/null +++ b/roles/MSA/files/usr/local/bin/postfix-sender-login.pl @@ -0,0 +1,171 @@ +#!/usr/bin/perl -T + +#---------------------------------------------------------------------- +# socketmap lookup table returning the SASL login name(s) owning a given +# sender address +# Copyright © 2017 Guilhem Moulin +# +# 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 . +#---------------------------------------------------------------------- + +use warnings; +use strict; + +use Errno 'EINTR'; +use Socket qw/PF_UNIX SOCK_STREAM SHUT_RDWR/; + +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/}; + +# returned for forbidden envelope sender addresses +my $POSTMASTER = 'postmaster@fripost.org'; + +# 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 $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/'; +sub process_request($); + +while(1) { + accept(my $conn, $S) or do { + # try again if accept(2) was interrupted by a signal + 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 { + warn "Can't write: $!"; + last; + }; + $i += $n; + } + close $conn or warn "Can't close: $!"; +} + +############################################################################# + +sub process_request($) { + my $conn = shift; + my ($buf, $offset) = (undef, 0); + + # keep reading until the request length is determined + do { + my $n = sysread($conn, $buf, $BUFSIZE, $offset) // return "TEMP can't read: $!"; + return "TEMP EOF" if $n == 0; + $offset += $n; + } until ($buf =~ /\A(0|[1-9][0-9]*):/); + + # keep reading until the whole request is buffered + my $strlen = length("$1") + 1; # [len]":" + my $len = $strlen + $1 + 1; # [len]":"[string]"," + while ($offset < $len) { + my $n = sysread($conn, $buf, $BUFSIZE, $offset) // return "TEMP can't read: $!"; + return "TEMP EOF" if $n == 0; + $offset += $n; + } + + # requests are of the form $name $key, cf. socketmap_table(5) + my $i = index($buf, ' ', $strlen); + return "TEMP invalid input: $buf" unless $i > $strlen and substr($buf,-1) eq ','; + my $name = substr($buf, $strlen, $i-$strlen); + my $key = substr($buf, $i, -1); + return "TEMP invalid name: $name" unless $name eq 'sender_login'; + + $key =~ /\A(.+)@([^\@]+)\z/ or return "NOTFOUND "; # invalid sender address + my ($localpart, $domainpart) = ($1, $2); + + my $ldap = Net::LDAPI::->new( $LDAPI ) + // return "TEMP couldn't create Net::LDAPI object"; + $ldap->bind( undef, sasl => Authen::SASL::->new(mechanism => 'EXTERNAL') ) + or return "TEMP LDAP: couldn't bind"; + + my $reply = lookup_sender($ldap, $localpart, $domainpart); + $ldap->unbind(); + return $reply; +} + +sub lookup_sender($$$) { + my ($ldap, $l, $d) = @_; + + my $filter = '(&(objectClass=FripostVirtualDomain)(fvd='.escape_filter_value($d).'))'; + my $mesg = $ldap->search( base => $BASEDN, scope => 'one', deref => 'never' + , filter => $filter + , attrs => [qw/objectClass fripostOwner fripostPostmaster/] + ); + return "TEMP LDAP error: ".$mesg->error() if $mesg->code; + my $entry = $mesg->pop_entry() // return "NOTFOUND "; # not a domain we know + return "TEMP LDAP error: multiple entry founds" if defined $mesg->pop_entry(); # sanity check + + # domain postmasters are allowed to use any sender address + my @logins = $entry->get_value('fripostPostmaster', asref => 0); + my @owners = $entry->get_value('fripostOwner', asref => 0); + + if (grep { $_ eq 'FripostVirtualAliasDomain' } $entry->get_value('objectClass', asref => 0)) { + # so are alias domain owners + push @logins, @owners; + } else { + my $dn = 'fvd='.escape_dn_value($d).','.$BASEDN; + my $filter = '(&(|(objectClass=FripostVirtualAlias)(objectClass=FripostVirtualList)(objectClass=FripostVirtualUser))(fvl='.escape_filter_value($l).'))'; + my $mesg = $ldap->search( base => $dn, scope => 'one', deref => 'never' + , filter => $filter + , attrs => [qw/objectClass fripostOwner/] + ); + unless ($mesg->code == 0 and defined ($entry = $mesg->pop_entry())) { + # domains owners are allowed to use any unkwown localpart as sender address + push @logins, @owners; + } else { + return "TEMP LDAP error: multiple entry founds" if defined $mesg->pop_entry(); # sanity check + if (grep { $_ eq 'FripostVirtualUser' } $entry->get_value('objectClass', asref => 0)) { + push @logins, $entry->dn(); + } else { + # alias/list owners can use the address as sender, and so are the domains owners + push @logins, @owners, $entry->get_value('fripostOwner', asref => 0); + } + } + } + + # convert DNs to SASL login names + my %logins; + foreach my $dn (@logins) { + next unless defined $dn; + $dn = ldap_explode_dn($dn, casefold => 'lower'); + next unless defined $dn and $#$dn == 4; + my $l = $dn->[0]->{fvl} // next; + my $d = $dn->[1]->{fvd} // next; + $logins{$l.'@'.$d} = 1; + } + + # if the entry is found in LDAP but doesn't have an owner, only + # $POSTMASTER is allowed to use it as sender address + my $reply = %logins ? join(',', keys %logins) : $POSTMASTER; + return "OK $reply"; +} diff --git a/roles/MSA/handlers/main.yml b/roles/MSA/handlers/main.yml index 9edf610..a3db0f8 100644 --- a/roles/MSA/handlers/main.yml +++ b/roles/MSA/handlers/main.yml @@ -1,4 +1,7 @@ --- +- name: systemctl daemon-reload + command: /bin/systemctl daemon-reload + - name: Reload Postfix service: name=postfix state=reloaded diff --git a/roles/MSA/tasks/main.yml b/roles/MSA/tasks/main.yml index 6eff2cf..00c205d 100644 --- a/roles/MSA/tasks/main.yml +++ b/roles/MSA/tasks/main.yml @@ -4,6 +4,28 @@ - postfix - postfix-pcre +- 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: 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 }} diff --git a/roles/MSA/templates/etc/postfix/main.cf.j2 b/roles/MSA/templates/etc/postfix/main.cf.j2 index f5f0834..ec6b242 100644 --- a/roles/MSA/templates/etc/postfix/main.cf.j2 +++ b/roles/MSA/templates/etc/postfix/main.cf.j2 @@ -93,10 +93,12 @@ smtpd_helo_required = yes smtpd_helo_restrictions = reject_invalid_helo_hostname +smtpd_sender_login_maps = socketmap:unix:private/sender-login:sender_login smtpd_sender_restrictions = reject_non_fqdn_sender reject_unknown_sender_domain check_sender_access cdb:$config_directory/check_sender_access + reject_known_sender_login_mismatch smtpd_relay_restrictions = reject_non_fqdn_recipient diff --git a/roles/common-LDAP/templates/etc/default/slapd.j2 b/roles/common-LDAP/templates/etc/default/slapd.j2 index 80c1be1..fdd7481 100644 --- a/roles/common-LDAP/templates/etc/default/slapd.j2 +++ b/roles/common-LDAP/templates/etc/default/slapd.j2 @@ -20,7 +20,7 @@ SLAPD_PIDFILE= # service requests on TCP-port 636 (ldaps) and requests via unix # sockets. SLAPD_SERVICES="ldapi:///" -{% for i in group_names | intersect(['MX','lists']) | sort %} +{% for i in group_names | intersect(['MX','lists','MSA']) | sort %} SLAPD_SERVICES="$SLAPD_SERVICES ldapi://%2Fvar%2Fspool%2Fpostfix-{{ postfix_instance[i].name }}%2Fprivate%2Fldapi/" {% endfor %} {% if 'LDAP-provider' in group_names %} diff --git a/roles/common-LDAP/templates/etc/ldap/database.ldif.j2 b/roles/common-LDAP/templates/etc/ldap/database.ldif.j2 index 8310818..494888e 100644 --- a/roles/common-LDAP/templates/etc/ldap/database.ldif.j2 +++ b/roles/common-LDAP/templates/etc/ldap/database.ldif.j2 @@ -258,7 +258,7 @@ olcAccess: to dn.exact="ou=virtual,dc=fripost,dc=org" {% if 'MDA' in group_names -%} by dn.exact="username=dovecot,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://" =sd {% endif -%} - {% if 'MX' in group_names -%} + {% if 'MX' in group_names or 'MSA' 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 -%} by users =0 break @@ -476,6 +476,21 @@ olcAccess: to dn.regex="^fvl=[^,]+,fvd=[^,]+,ou=virtual,dc=fripost,dc=org$" {% 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 +{% 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 +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 +{% endif %} {% if 'LDAP-provider' in group_names %} # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # diff --git a/roles/common/templates/etc/postfix/master.cf.j2 b/roles/common/templates/etc/postfix/master.cf.j2 index c2ee395..52b2ec4 100644 --- a/roles/common/templates/etc/postfix/master.cf.j2 +++ b/roles/common/templates/etc/postfix/master.cf.j2 @@ -20,8 +20,13 @@ tlsproxy unix - - n - 0 tlsproxy dnsblog unix - - n - 0 dnsblog cleanup_nochroot unix n - n - 0 cleanup {% elif inst == 'MSA' %} -{{ postfix_instance.MSA.port }} inet n - - - - smtpd +submission inet n - - - - smtpd -o tls_high_cipherlist=EECDH+AESGCM:!MEDIUM:!LOW:!EXP:!aNULL:!eNULL +{% if groups.webmail | difference([inventory_hostname]) | length > 0 %} +[{{ postfix_instance.MSA.addr }}]:{{ postfix_instance.MSA.port }} inet n - - - - smtpd + -o smtpd_tls_security_level=none + -o smtpd_sasl_security_options=noanonymous +{% endif %} {% elif inst in ['IMAP', 'out', 'lists'] %} [{{ postfix_instance[inst].addr }}]:{{ postfix_instance[inst].port }} inet n - - - - smtpd {% endif %} diff --git a/roles/webmail/tasks/roundcube.yml b/roles/webmail/tasks/roundcube.yml index 4c7ac8d..5f41ba0 100644 --- a/roles/webmail/tasks/roundcube.yml +++ b/roles/webmail/tasks/roundcube.yml @@ -83,8 +83,11 @@ - { var: imap_force_ns, value: "true" } - { var: messages_cache, value: "false" } # SMTP - - { var: smtp_server, value: "'{{ postfix_instance.out.addr | ipaddr }}'" } - - { var: smtp_port, value: "{{ postfix_instance.out.port }}" } + - { var: smtp_server, value: "'{{ postfix_instance.MSA.addr | ipaddr }}'" } + - { var: smtp_port, value: "{{ postfix_instance.MSA.port }}" } + - { var: smtp_auth_type, value: "'PLAIN'" } + - { var: smtp_user, value: "'%u'" } + - { var: smtp_pass, value: "'%p'" } # System - { var: force_https, value: "true" } - { var: login_autocomplete, value: "2" } -- cgit v1.2.3