Pseudo suExec with PHP Behind Nginx

For those who don't want to run more than one php-cgi... for some reason.

I recently started transitioning all of the websites under my management from Apache to nginx (mainly to ease running my Python webapps via gunicorn, but that is another story).

Since nginx will not directly execute PHP (via either CGI or nginx-managed FastCGI), the first step was to get PHP running at all. I opted to run php-cgi via daemontools; my initial run script was fairly straight forward:

1
2
#!/usr/bin/env bash
exec php-cgi -b 127.0.0.1:9000

Couple this with a (relatively) straight forward nginx configuration and the sites will already start responding:

server {

    listen          80;
    server_name     example.com
    root            /var/www/example.com/httpdocs;

    index           index.php index.html;
    fastcgi_index   index.php;

    location ~ \.php {
        keepalive_timeout 0;
        include /etc/nginx/fastcgi_params;
        fastcgi_param   SCRIPT_FILENAME  $document_root$uri;
        fastcgi_pass    127.0.0.1:9000;
    }

}

The tricky part came when I wanted to run PHP under the user who owned the various sites. I could have (and perhaps should have) opted to spin up a copy of php-cgi for each user, but I decided to try something a little sneakier; PHP will set its own UID on each request.


The first step is to modify the run script to have a pool of processes sitting around, but only use each once (since after a process has set its UID for one site it cannot change it for another), and to load a custom php.ini. It now looks like:

1
2
3
4
5
6
#!/usr/bin/env bash

export PHP_FCGI_CHILDREN=4
export PHP_FCGI_MAX_REQUESTS=1

exec php-cgi -b 127.0.0.1:8020 -c /service/php-cgi/php.ini

Then, modify a copy of /etc/php5/cgi/php.ini to include a custom PHP script in front of every request by adding something like the following:

auto_prepend_file = /service/php-cgi/prepend.php

And finally, the prepend.php script which sets up the environment for every request

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php

// This script is run before EVERY request. We will use it to assert some
// security parameters. We don't bother with setting the CWD because php-cgi
// does that for us.

// This is the file that is going to run.
$filename = $_SERVER["SCRIPT_FILENAME"];

// Retrieve the UID/GID of the PHP script about to run.
$uid = fileowner($filename);
$gid = filegroup($filename);

// Get the user's name
$userinfo = posix_getpwuid($uid);
$username = $userinfo['name'];

// Set permissions on all files that were uploaded; we will not be able to move
// them afterwards if we leave them owned by root.
foreach($_FILES as $file) {
    chown($file['tmp_name'], $uid);
    chgrp($file['tmp_name'], $gid);
}

// Change the session directory to something user specific.
$session_dir = '/var/lib/php5/' . $username;
if (!file_exists($session_dir)) {
    mkdir($session_dir, 0700);
    chown($session_dir, $uid);
}
session_save_path($session_dir);

// Run as the user/group of the target file; must do group then user.
posix_setgid($gid);
posix_setuid($uid);

// Cleanup.
unset($uid);
unset($gid);
unset($userinfo);
unset($username);
unset($filename);
unset($session_dir);

All my scripts are happily running away as the user they are owned by, and I haven't run into any new permission issues (yet).

Posted . Categories: .