How we implement per-user CGIs on our web server that also work for group shares (privilege separation)

We host a great number of home directories and group shares for our scientists and we would like to give them the freedom of creating personal or group web sites in the public_html folder of their shares. This is rather straightforward using Apache's userdir module, but as soon as you think about also allowing CGIs or PHP scripts, things get hairy very fast. How do you prevent one user's Perl script from reading another user's database config? Well, that's what suEXEC is for, right? suExec works for dynamic content in individual users' public_html, but it does not work for a group share where files typically have user:group ownership and all sorts of different permissions. Since we didn't find a proper solution for this problem when researching it on the web, we document the implementation we came up with in case somebody else is interested.

The basic idea is the following:

  • since the home directories and group shares come from several different file servers, we first statically mount their NFS exports to /mnt/export/<SERVER>/ on our web server
  • we then use the automounter to mount /mnt/export/<SERVER>/<PATH>/<UID>/public_html to /home/<UID>/public_html via mod_userdir. We get UID from the apache request and SERVER and PATH from an LDAP lookup.
  • in order to get both the permissions and ownership of those mounts right, we do not use a simple bind mount, but bindfs. This will satisfy suEXEC's checks and allows us to also serve dynamic content.
  • mod_userdir then serves /home/<UID>/public_html/ for static content by default and uses suEXEC via per-user userdir.conf files for CGIs or PHP.

Going into more detail, we will show the relevant config, but don't expect them to work without modifications, they're heavily dependent on our infrastructure.

  • install apache and enable the following modules: userdir, headers, cgid, suexec, php-cgi.
  • create your /mnt/export/<SERVER> dirs, chmod o-rx /mnt/export/ and mount your NFS shares statically. Note that you can't use the automounter here, we'll explain why in a minute.
  • next we need the autofs trigger for bindfs: create /etc/auto.master.d/bindfs.autofs to read
/home program:/etc/auto.bindfs

This tells autofs to invoke the mapping script auto.bindfs for each access to /home/<UID>. Since autofs does not know how to deal with bindfs, we will create the bindfs mount in this mapper script manually. This is also the reason we can't use autofs for the underlying /mnt/export/ mounts: while autofs is executing its mapper script, you can't trigger another automount, so the bindfs sources need to be static. * /etc/auto.bindfs reads

/bin/bash

LUID=$1

if expr match "$LUID" "^[-a-z0-9_]\+$" && <span class="createlink"><a href="/ikiwiki.cgi?do=create&amp;from=web%2Fper_user_CGI_for_groupshares&amp;page=_%2Fusr%2Fbin%2Fgrep_uid__61____41___" rel="nofollow">?</a> $(/usr/bin/id $LUID 2>/dev/null </span>; then
    HOME="$(ldapsearchphys -LLLb nisMapName=auto.home,ou=automount,dc=phys,dc=ethz,dc=ch cn=${LUID} nisMapEntry | sed -nre 's/^nisMapEntry: (.+)$/\1/p')"
    SERVER="$(echo $HOME | sed -nre 's/^([^:]+):.+/\1/p')"
    PATH="$(echo $HOME | sed -nre 's#^.+:/(export/)?(.+)#\2/public_html#p')"
    echo >> /var/log/bindfs.log
    if <span class="createlink"><a href="/ikiwiki.cgi?do=create&amp;from=web%2Fper_user_CGI_for_groupshares&amp;page=_-n___34____36__SERVER__34_____38____38___-n___34____36__PATH__34___" rel="nofollow">?</a> -n &#34;&#36;SERVER&#34; &#38;&#38; -n &#34;&#36;PATH&#34; </span>; then
        echo "mounting $LUID from $SERVER/$PATH.." >> /var/log/bindfs.log

        /usr/bin/mkdir -p /home/$LUID/public_html
        /bin/chown $LUID:www-data /home/$LUID && \
        /bin/chmod o-rwx /home/$LUID && \
        if ! <span class="createlink"><a href="/ikiwiki.cgi?do=create&amp;from=web%2Fper_user_CGI_for_groupshares&amp;page=_%2Fusr%2Fbin%2Fgrep__%2Fhome%2F__36__LUID__41___" rel="nofollow">?</a> $(/usr/bin/mount </span>; then
            /usr/bin/bindfs -r -u $LUID -g $LUID -p 'g-w,o+rx' /mnt/export/$SERVER/$PATH /home/$LUID/public_html >> /var/log/bindfs.log 2>&1
        fi
    else
        echo "$LUID: no LDAP info, bailing out!" >> /var/log/bindfs.log
    fi
fi

echo ""

This first if sanitizes UID, makes sure the user exists and then gets the user's home directory from LDAP. It then creates /home/<UID>/public_html, sets the correct permissions and ownership and finally invokes bindfs to bind mount <UID>/public_html. In order to make suEXEC happy, we force ownership of all files to UID:UID, revoke group write and grant other read,execute permissions.

  • mod_userdir config in conf-available/userdir.conf:
<IfModule mod_userdir.c>
    <Directory /home/*/public_html>
        DirectoryIndex index.html
    </Directory>

    # Per user settings are included from:      /etc/apache2/sites-common/userdir/
    # For a normal user account add a file:     user-<username>.conf
    # For a group account add a file:           group-<username>.conf
    IncludeOptional /etc/apache2/sites-common/userdir/group-*.conf
    IncludeOptional /etc/apache2/sites-common/userdir/user-*.conf
</IfModule>

By default we disable all dynamic content and specifically enable it per user or share.

  • two typical sites-common/userdir/user-*.conf config examples look like this:
#register .php files as a cgi handler - note that the 'REDIRECT_STATUS' is always necessary for PHP and all .php files need to be executable!
<Directory "/home/<UID>/public_html">
    Options +ExecCGI
    AddHandler cgi-script .php
    # Enable PHP for <USER NAME>
    DirectoryIndex index.html index.php
    <FilesMatch "\.(?i:php)$">
        Require all granted
        SetEnv REDIRECT_STATUS 1
    </FilesMatch>
</Directory>
#allow Perl scripts to be used as CGIs in cgi-bin. The config is a bit simpler since Perl scripts have a proper shebang line, in contrast to PHP
<Directory "/home/<UID>/public_html/cgi-bin">
    Options +ExecCGI
    AddHandler cgi-script .pl
    DirectoryIndex index.html index.pl
    Require all granted
</Directory>
  • PHP requires one additional step: as there's no shebang, we need to register the appropriate interpreter like so:
echo ':PHP:E::php::/usr/bin/php-cgi:' > /proc/sys/fs/binfmt_misc/register
  • last task: since we're somewhat misusing the auto mounter, our bindfs mounts will not be automagically umounted. We work around this issue by a nightly call of service autofs restart. No big deal.

Now you should be able to serve both static and dynamic content from user home directories and group shares with complete privilege separation, which is achieved by the combination of the mechanisms detailed above (keep in mind that we have two conflicting sets of permission requirements: static content is served by www-data and therefore needs to be readable by apache while CGIs run under the owner's uid and suEXEC has a rather long list of strict requirements):

  • bindfs -u $LUID -g $LUID maps ownership to the user id of the apache CGI process, regardless of the original owner. This is a prerequisite for suExec. It also takes care of the group share problem, meaning that for a group share, the original owner of a file doesn't matter and is mapped to the group's uid.
  • bindfs -p 'g-w,o+rx' removes group (which is $LUID) write permissions, required by suEXEC, and allows others (in our case www-data) to enter directories and read files, so that apache can serve static content.
  • /bin/chown $LUID:www-data /home/$LUID and /bin/chmod o-rwx /home/$LUID accomplish two things: allow apache to enter each home, but prevent everybody else (every other uid) to do so.

In combination with chmod o-rx /mnt/export/, this means that even a malicious CGI can not read its own home directory (outside of public_html), as only public_html is bindfs mounted and a regular user has no access to the underlying /mnt/export tree. It also can't access other users' homes or public_htmls due to the permission settings explained above.