From 5118f8d3394579a245b355c863c69410fe92e26e Mon Sep 17 00:00:00 2001 From: Guilhem Moulin Date: Thu, 21 May 2020 01:35:28 +0200 Subject: dovecot-auth-proxy: replace directory traversal with LDAP lookups. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This provides better isolation opportunity as the service doesn't need to run as ‘vmail’ user. We use a dedicated system user instead, and LDAP ACLs to limit its access to the strict minimum. The new solution is also more robust to quoting/escaping, and doesn't depend on ‘home=/home/mail/virtual/%d/%n’ (we might use $entryUUID instead of %d/%n at some point to make user renaming simpler). OTOH we no longer lists users that have been removed from LDAP but still have a mailstore lingering around. This is fair. --- lib/modules/openldap | 2 +- .../etc/systemd/system/dovecot-auth-proxy.service | 10 +- .../IMAP/files/usr/local/bin/dovecot-auth-proxy.pl | 102 ++++++++++++++------- roles/IMAP/files/usr/local/bin/list-users.pl | 45 --------- roles/IMAP/tasks/imap.yml | 16 ++++ .../templates/etc/ldap/database.ldif.j2 | 16 ++-- 6 files changed, 100 insertions(+), 91 deletions(-) delete mode 100755 roles/IMAP/files/usr/local/bin/list-users.pl diff --git a/lib/modules/openldap b/lib/modules/openldap index 9afe1f1..219c9a6 100644 --- a/lib/modules/openldap +++ b/lib/modules/openldap @@ -61,7 +61,7 @@ indexedDN = { # 102 is postfix's UID and 106 its primary GID. # (Regular expressions are not allowed.) sasl_ext_re = re.compile( r"""(?P\sby\s+dn(?:\.exact)?)= - (?P['\"]?)username=(?P[a-z][-a-z0-9_]*), + (?P['\"]?)username=(?P_?[a-z][-a-z0-9_]*), (?Pcn=peercred,cn=external,cn=auth) (?P=quote)\s""" , re.VERBOSE ) diff --git a/roles/IMAP/files/etc/systemd/system/dovecot-auth-proxy.service b/roles/IMAP/files/etc/systemd/system/dovecot-auth-proxy.service index d20f9c2..3ac0b31 100644 --- a/roles/IMAP/files/etc/systemd/system/dovecot-auth-proxy.service +++ b/roles/IMAP/files/etc/systemd/system/dovecot-auth-proxy.service @@ -4,8 +4,7 @@ After=dovecot.target Requires=dovecot-auth-proxy.socket [Service] -User=vmail -Group=vmail +User=_dovecot-auth-proxy StandardInput=null SyslogFacility=mail ExecStart=/usr/local/bin/dovecot-auth-proxy.pl @@ -13,14 +12,13 @@ ExecStart=/usr/local/bin/dovecot-auth-proxy.pl # Hardening NoNewPrivileges=yes PrivateDevices=yes -ProtectSystem=strict -ProtectHome=read-only -PrivateDevices=yes PrivateNetwork=yes +ProtectHome=yes +ProtectSystem=strict ProtectControlGroups=yes ProtectKernelModules=yes ProtectKernelTunables=yes -RestrictAddressFamilies= +RestrictAddressFamilies=AF_UNIX [Install] WantedBy=multi-user.target diff --git a/roles/IMAP/files/usr/local/bin/dovecot-auth-proxy.pl b/roles/IMAP/files/usr/local/bin/dovecot-auth-proxy.pl index 350bb2c..39d3762 100755 --- a/roles/IMAP/files/usr/local/bin/dovecot-auth-proxy.pl +++ b/roles/IMAP/files/usr/local/bin/dovecot-auth-proxy.pl @@ -1,8 +1,8 @@ -#!/usr/bin/perl +#!/usr/bin/perl -T #---------------------------------------------------------------------- # Dovecot userdb lookup proxy table for user iteration -# Copyright © 2017 Guilhem Moulin +# Copyright © 2017,2020 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 @@ -21,14 +21,22 @@ use warnings; use strict; -use Errno 'EINTR'; +use Errno qw/EINTR/; +use Net::LDAPI; +use Net::LDAP::Constant qw/LDAP_CONTROL_PAGED LDAP_SUCCESS/; +use Net::LDAP::Control::Paged (); +use Net::LDAP::Util qw/ldap_explode_dn/; +use Authen::SASL; + +my $BASE = "ou=virtual,dc=fripost,dc=org"; # clean up PATH $ENV{PATH} = join ':', qw{/usr/bin /bin}; delete @ENV{qw/IFS CDPATH ENV BASH_ENV/}; -# number of pre-forked servers +# number of pre-forked servers and maximum requests per worker my $nProc = 1; +my $maxRequests = 1; sub server(); # fdopen(3) the file descriptor FD @@ -37,12 +45,6 @@ die "This service must be socket-activated.\n" and defined $ENV{LISTEN_FDS} and $ENV{LISTEN_FDS} == 1; open my $S, '+<&=', 3 or die "fdopen: $!"; -do { - my $dir = (getpwnam('vmail'))[7] // die "No such user: vmail"; - $dir .= '/virtual'; - chdir($dir) or die "chdir($dir): $!"; -}; - my @CHILDREN; for (my $i = 0; $i < $nProc-1; $i++) { my $pid = fork() // die "fork: $!"; @@ -61,7 +63,7 @@ exit $?; ############################################################################# sub server() { - for (my $n = 0; $n < 1; $n++) { + for (my $n = 0; $n < $maxRequests; $n++) { accept(my $conn, $S) or do { next if $! == EINTR; die "accept: $!"; @@ -94,10 +96,20 @@ sub server() { sub fail($;$) { my ($fh, $msg) = @_; $fh->printflush("F\n"); - warn "$msg\n" if defined $msg; + print STDERR $msg, "\n" if defined $msg; } -# list all users, even the inactive ones +sub dn2user($) { + my $dn = shift; + $dn = ldap_explode_dn($dn, casefold => "lower"); + if (defined $dn and $#$dn == 4 + and defined (my $l = $dn->[0]->{fvl}) + and defined (my $d = $dn->[1]->{fvd})) { + return $l ."@". $d; + } +} + +# list all users (even the inactive ones) sub iterate($$$$) { my ($fh, $flags, $max_rows, $prefix) = @_; unless ($flags == 0) { @@ -105,27 +117,55 @@ sub iterate($$$$) { return; } - opendir my $dh, '.' or do { - fail($fh => "opendir: $!"); - return; - }; - my $count = 0; - while (defined (my $d = readdir $dh)) { - next if $d eq '.' or $d eq '..'; - opendir my $dh, $d or do { - fail($fh => "opendir: $!"); - return; - }; - while (defined (my $l = readdir $dh) and ($max_rows <= 0 or $count < $max_rows)) { - next if $l eq '.' or $l eq '..'; - my $user = $l.'@'.$d; - next unless $user =~ /\A[a-zA-Z0-9\.\-_@]+\z/; # skip invalid user names + my $ldap = Net::LDAPI::->new(); + $ldap->bind( undef, sasl => Authen::SASL::->new(mechanism => "EXTERNAL") ) + or do { fail($fh => "Error: Couldn't bind"); return; }; + my $page = Net::LDAP::Control::Paged::->new(size => 100); + + my $callback = sub($$) { + my ($mesg, $entry) = @_; + return unless defined $entry; + + my $dn = $entry->dn(); + if (defined (my $user = dn2user($dn))) { $fh->printf("O%s%s\t\n", $prefix, $user); - $count++; + } else { + print STDERR "Couldn't extract username from dn: ", $dn, "\n"; } - closedir $dh or warn "closedir: $!"; + $mesg->pop_entry; + }; + + my @search_args = ( + base => $BASE, + , scope => "children" + , deref => "never" + , filter => "(objectClass=FripostVirtualUser)" + , sizelimit => $max_rows + , control => [$page] + , callback => $callback + , attrs => ["1.1"] + ); + + my $cookie; + while (1) { + my $mesg = $ldap->search(@search_args); + last unless $mesg->code == LDAP_SUCCESS; + + my ($resp) = $mesg->control(LDAP_CONTROL_PAGED) or last; + $cookie = $resp->cookie(); + goto SEARCH_DONE unless defined $cookie and length($cookie) > 0; + + $page->cookie($cookie); + } + + if (defined $cookie and length($cookie) > 0) { + fail($fh => "Abnormal exit from LDAP search, aborting"); + $page->cookie($cookie); + $page->size(0); + $ldap->search(@search_args); } - closedir $dh or warn "closedir: $!"; + SEARCH_DONE: + $ldap->unbind(); $fh->printflush("\n"); } diff --git a/roles/IMAP/files/usr/local/bin/list-users.pl b/roles/IMAP/files/usr/local/bin/list-users.pl deleted file mode 100755 index 1bcab35..0000000 --- a/roles/IMAP/files/usr/local/bin/list-users.pl +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/perl - -# 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 Net::LDAPI; -use Net::LDAP::Util qw/ldap_explode_dn escape_dn_value/; -use Authen::SASL; - -my $BASE = 'ou=virtual,dc=fripost,dc=org'; - -my $LDAP = Net::LDAPI::->new(); -$LDAP->bind( undef, sasl => Authen::SASL::->new(mechanism => 'EXTERNAL') ) - or die "Error: Couldn't bind"; - -my $mesg = $LDAP->search( base => $BASE, scope => 'children', deref => 'never' - , filter => '(objectClass=FripostVirtualUser)' - , attrs => ['1.1'] - ); -die $mesg->error if $mesg->code; - -while (defined (my $entry = $mesg->pop_entry())) { - my $dn = $entry->dn() // next; - $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; - printf "%s@%s\n", $l, $d; -} - -$LDAP->unbind; diff --git a/roles/IMAP/tasks/imap.yml b/roles/IMAP/tasks/imap.yml index 429854e..4a157af 100644 --- a/roles/IMAP/tasks/imap.yml +++ b/roles/IMAP/tasks/imap.yml @@ -18,6 +18,13 @@ password=! state=present +- name: Install Net::LDAP and Authen::SASL + apt: pkg={{ packages }} + vars: + packages: + - libnet-ldap-perl + - libauthen-sasl-perl + - name: Copy dovecot auth proxy copy: src=usr/local/bin/dovecot-auth-proxy.pl dest=/usr/local/bin/dovecot-auth-proxy.pl @@ -30,6 +37,15 @@ tags: - sysctl +- name: Create '_dovecot-auth-proxy' user + user: name=_dovecot-auth-proxy system=yes + group=nogroup + createhome=no + home=/nonexistent + shell=/usr/sbin/nologin + password=! + state=present + - name: Copy dovecot auth proxy systemd unit files copy: src=etc/systemd/system/{{ item }} dest=/etc/systemd/system/{{ item }} diff --git a/roles/common-LDAP/templates/etc/ldap/database.ldif.j2 b/roles/common-LDAP/templates/etc/ldap/database.ldif.j2 index 1be00cb..b640cbf 100644 --- a/roles/common-LDAP/templates/etc/ldap/database.ldif.j2 +++ b/roles/common-LDAP/templates/etc/ldap/database.ldif.j2 @@ -257,15 +257,15 @@ olcAccess: to dn.children="ou=virtual,dc=fripost,dc=org" # * 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 on the MDA (needed for the iterate filter), when -# SASL-binding using the EXTERNAL mechanism and connecting to a local -# ldapi:// socket. +# * 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,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://" =sd + 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 -%} by dn.exact="username=postfix,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://%2Fvar%2Fspool%2Fpostfix-[-[:alnum:]]+%2Fprivate%2F" =sd @@ -282,7 +282,7 @@ olcAccess: to dn.exact="ou=virtual,dc=fripost,dc=org" # 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 on the MDA (for the iterate filter), when +# * 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 @@ -301,7 +301,7 @@ olcAccess: to dn.regex="^fvd=[^,]+,ou=virtual,dc=fripost,dc=org$" {% endif -%} by dn.exact="username=postfix,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://%2Fvar%2Fspool%2Fpostfix-[-[:alnum:]]+%2Fprivate%2F" =rsd {% if 'MDA' in group_names -%} - by dn.exact="username=dovecot,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://" =rsd + 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 -%} @@ -372,7 +372,7 @@ olcAccess: to dn.regex="^fvd=[^,]+,ou=virtual,dc=fripost,dc=org$" # 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 on the MDA (for the iterate filter), when +# * 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 @@ -385,7 +385,7 @@ olcAccess: to dn.regex="^fvl=[^,]+,fvd=[^,]+,ou=virtual,dc=fripost,dc=org$" {% endif -%} by dn.exact="username=postfix,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://%2Fvar%2Fspool%2Fpostfix-[-[:alnum:]]+%2Fprivate%2F" =rsd {% if 'MDA' in group_names -%} - by dn.exact="username=dovecot,cn=peercred,cn=external,cn=auth" sockurl.regex="^ldapi://" =rsd + 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 -%} by users =0 break -- cgit v1.2.3