Обеспечение строгого режима сеанса в пользовательской реализации SessionHandlerInterface

Введение

Так как в PHP 5.5.2 есть параметр конфигурации времени выполнения (session.use_strict_mode), который предназначен для предотвращения фиксации сеанса вредоносными клиентами. Когда эта опция включена и используется собственный обработчик сеанса (файлы), PHP не будет принимать идентификатор входящего сеанса, который ранее не существовал в область хранения сеанса, например:

$ curl -I -H "Cookie:PHPSESSID=madeupkey;" localhost
HTTP/1.1 200 OK
Cache-Control: no-store, no-cache, must-revalidate
Connection: close
Content-type: text/html; charset=UTF-8
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Host: localhost
Pragma: no-cache
Set-Cookie: PHPSESSID=4v3lkha0emji0kk6lgl1lefsi1; path=/  <--- looky

(при отключенном session.use_strict_mode ответ будет содержать не заголовок Set-Cookie, а файл sess_madeupkey был бы создан в каталоге сеансов)

Проблема

Я в процессе реализации настраиваемого обработчика сеансов, и я бы очень хотел придерживаться строгого режима, однако интерфейс затрудняет работу.

Когда вызывается session_start(), строка MyHandler::read($session_id) вызывается по строке, но $session_id может быть или значением, полученным из файла cookie или. идентификатор сессии. Обработчик должен знать разницу, потому что в первом случае ошибка должна быть повышена, если идентификатор сеанса не найден. Более того, согласно spec read($session_id), необходимо возвращать содержимое сеанса или пустую строку (для новых сеансов), но, похоже, нет способа поднять ошибку в цепочке.

Итак, чтобы подвести итог, вопросы, которые мне нужно ответить, чтобы соответствовать собственному поведению:

  • Из контекста read($session_id), как я могу определить разницу между новым чеком идентификатора сеанса или идентификатором сеанса, который пришел из HTTP-запроса?

  • Учитывая идентификатор сеанса, который пришел из HTTP-запроса и предположил, что он не был обнаружен в области хранения, как я могу сообщить об ошибке движку PHP, чтобы он снова вызывал read($session_id) с помощью нового идентификатор сеанса?

Ответ 1

Обновление (2017-03-19)

Моя первоначальная реализация делегирована на session_regenerate_id() для генерации новых идентификаторов сеанса и установки заголовка файла cookie, когда это необходимо. Начиная с PHP 7.1.2 этот метод больше не может вызываться изнутри обработчика сеанса [1]. Достойный Dabbler также сообщил, что этот подход не будет работать в PHP 5.5.9 [2].

Следующая вариация метода read() позволяет избежать этой ошибки, но несколько более беспорядочна, так как она должна сама устанавливать заголовок файла cookie.

/**
 * {@inheritdoc}
 */
public function open($save_path, $name)
{
    // $name is the desired name for the session cookie, as specified
    // in the php.ini file. Default value is 'PHPSESSID'.
    // (calling session_regenerate_id() used to take care of this)
    $this->cookieName = $name;

    // the handling of $save_path is implementation-dependent
}

/**
 * {@inheritdoc}
 */
public function read($session_id)
{
    if ($this->mustRegenerate($session_id)) {
        // Manually set a new ID for the current session
        session_id($session_id = $this->create_sid());

        // Manually set the 'Cookie: PHPSESSID=xxxxx;' header
        setcookie($this->cookieName, $session_id);
    }

    return $this->getSessionData($session_id) ?: '';
}

FWIW, как известно, оригинальная реализация работает под управлением PHP 7.0.x

Оригинальный ответ

Объединяя понимание, полученное от ответа Дэйва (т.е. расширяя класс \SessionHandler вместо реализации \SessionHandlerInterface, чтобы заглянуть в create_sid и решить первое препятствие), и это тонкое полевое исследование жизненного цикла сеанса от Rasmus Schultz. Я придумал довольно удовлетворительное решение: он не обременяет себя генерированием SID и не устанавливает какой-либо cookie вручную, а также не запускает ведро цепочка к клиентскому коду. Для ясности показаны только соответствующие методы:

<?php

class MySessionHandler extends \SessionHandler
{
    /**
     * A collection of every SID generated by the PHP internals
     * during the current thread of execution.
     *
     * @var string[]
     */
    private $new_sessions;

    public function __construct()
    {
        $this->new_sessions = [];
    }

    /**
     * {@inheritdoc}
     */
    public function create_sid()
    {
        $id = parent::create_sid();

        // Delegates SID creation to the default
        // implementation but keeps track of new ones
        $this->new_sessions[] = $id;

        return $id;
    }

    /**
     * {@inheritdoc}
     */
    public function read($session_id)
    {
        // If the request had the session cookie set and the store doesn't have a reference
        // to this ID then the session might have expired or it might be a malicious request.
        // In either case a new ID must be generated:
        if ($this->cameFromRequest($session_id) && null === $this->getSessionData($session_id)) {
            // Regenerating the ID will call destroy(), close(), open(), create_sid() and read() in this order.
            // It will also signal the PHP internals to include the 'Set-Cookie' with the new ID in the response.
            session_regenerate_id(true);

            // Overwrite old ID with the one just created and proceed as usual
            $session_id = session_id();
        }

        return $this->getSessionData($session_id) ?: '';
    }

    /**
     * @param string $session_id
     *
     * @return bool Whether $session_id came from the HTTP request or was generated by the PHP internals
     */
    private function cameFromRequest($session_id)
    {
        // If the request had the session cookie set $session_id won't be in the $new_sessions array
        return !in_array($session_id, $this->new_sessions);
    }

    /**
     * @param string $session_id
     *
     * @return string|null The serialized session data, or null if not found
     */
    private function getSessionData($session_id)
    {
        // implementation-dependent
    }
}

Примечание: класс игнорирует параметр session.use_strict_mode, но всегда следует строгому поведению (на самом деле это то, что я хочу). Это результаты тестирования в моей более полной реализации:

[email protected]:~$ curl -i -H "Cookie:PHPSESSID=madeupkey" localhost/tests/visit-counter.php
HTTP/1.1 200 OK
Server: nginx/1.11.6
Date: Mon, 09 Jan 2017 21:53:05 GMT
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
Set-Cookie: PHPSESSID=c34ovajv5fpjkmnvr7q5cl9ik5; path=/      <--- Success!
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache

1

[email protected]:~$ curl -i -H "Cookie:PHPSESSID=c34ovajv5fpjkmnvr7q5cl9ik5" localhost/tests/visit-counter.php
HTTP/1.1 200 OK
Server: nginx/1.11.6
Date: Mon, 09 Jan 2017 21:53:14 GMT
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache

2

И тестирование script:

<?php

session_set_save_handler(new MySessionHandler(), true);

session_start();

if (!isset($_SESSION['visits'])) {
    $_SESSION['visits'] = 1;
} else {
    $_SESSION['visits']++;
}

echo $_SESSION['visits'];

Ответ 2

Я не тестировал это, чтобы он мог работать или не работать.

Класс SessionHandler может быть расширен. Этот класс содержит соответствующий дополнительный метод, который не имеет интерфейса, а именно create_sid(). Это вызывается, когда PHP генерирует новый идентификатор сеанса. Таким образом, должно быть возможно использовать это, чтобы различать новый сеанс и атаку; что-то вроде:

class MySessionHandler extends \SessionHandler
{
    private $isNewSession = false;

    public function create_sid()
    {
        $this->isNewSession = true;

        return parent::create_sid();
    }

    public function read($id)
    {
        if ($this->dataStore->haveExistingSession($id)) {
            return $this->getSessionData($id);
        }

        if ($this->isNewSession) {
            $this->dataStore->createNewSession($id);
        }

        return '';
    }

    // ...rest of implementation
}

Этот подход может потребовать отладки с другим флагом или двумя для обработки законной регенерации идентификатора сеанса, если вы когда-нибудь это сделаете.

Что касается проблемы обработки ошибки изящно, я бы экспериментировал с бросанием исключения. Если это не принесет ничего полезного, я бы сделал это на уровне приложения, вернув фиксированное значение для самих данных сеанса, которое вы могли бы проверить, а затем обработайте его в приложении с помощью генерирует новый идентификатор или уничтожает сеанс и представляет пользователю ошибку.

Ответ 3

Увидев, что есть уже принятый ответ, я предоставляю это как еще не упомянутую альтернативу.


Начиная с PHP 7, если ваш обработчик сеанса реализует метод validateId(), PHP будет использовать это, чтобы определить, должен ли генерироваться новый идентификатор.

К сожалению, это не работает на PHP 5, где обработчики пользовательского пространства должны реализовать функциональность use_strict_mode=1 самостоятельно.
Есть ярлык, но позвольте мне сначала ответить на ваши прямые вопросы...

Из контекста read($session_id), как я могу определить разницу между новым чеком идентификатора сеанса или идентификатором сеанса, который пришел из HTTP-запроса?

На первый взгляд похоже, что это поможет, но проблема, которую вы будете иметь здесь, заключается в том, что read() для этого не является полезным. В первую очередь по двум причинам:

  • В этот момент сеанс уже инициализируется. Вы хотите отклонить несуществующие идентификаторы сеанса, а не инициализировать, а затем отбросить их.
  • Нет никакой разницы между чтением пустых данных для несуществующего идентификатора сеанса и/или просто возвратом пустых данных для вновь созданного идентификатора. Поэтому, даже если вы знаете, что обрабатываете вызов для несуществующего идентификатора сеанса, это не очень помогает вам.

Вы можете вызвать session_regenerate_id() изнутри read(), но это может иметь неожиданные побочные эффекты или значительно усложнить вашу логику, если вы ожидаете этих побочных эффектов...
Например, хранилище на основе файлов будет построено вокруг файловых дескрипторов, и они должны быть открыты изнутри read(), но затем session_regenerate_id() будет напрямую вызывать write(), и у вас не будет (корректного) дескриптора файла для записи в этот момент.

Учитывая идентификатор сеанса, который пришел из HTTP-запроса и предположил, что он не был обнаружен в области хранения, как я могу сообщить об ошибке движку PHP, чтобы он снова вызывал read($session_id) с новым идентификатором сеанса?

В течение самого долгого времени я ненавидел, что обработчики пользовательского пространства не могли сигнализировать об ошибках, пока я не узнал, что вы можете это сделать.
Как оказалось, на самом деле он был предназначен для обработки логических true, false успеха, неудачи. Это просто, что была очень тонкая ошибка в том, как PHP справился с этим...

Внутренне PHP использует значения 0 и -1 для обозначения успеха и отказа соответственно, но логика, которая обрабатывала преобразование в true, false для пользовательского пространства, была ошибочной и фактически отображала это внутреннее поведение, это недокументировано.
Это было исправлено в PHP 7, но осталось так же, как и для PHP 5, поскольку ошибка очень, очень старая и приведет к огромным перерывам BC при фиксированном. Дополнительная информация в этот PHP RFC, который предложил исправление для PHP 7.

Итак, для PHP 5 вы действительно можете вернуть int(-1) из методов обработчика сеанса, чтобы сигнализировать об ошибке, но это не очень полезно для принудительного применения "строгого режима", поскольку это приводит к совершенно другому поведению - оно испускает a E_WARNING и останавливает инициализацию сеанса.


Теперь для этого ярлыка я упомянул...

Это не совсем очевидно, и на самом деле очень странно, но ext/session не просто читает куки и обрабатывает их сам по себе - на самом деле он использует суперклассов $_COOKIE, а это означает, что вы можете манипулировать $_COOKIE для изменения поведения обработчика сеанса!

Итак, вот решение, которое даже переносит совместимость с PHP 7:

abstract class StrictSessionHandler
{
    private $savePath;
    private $cookieName;

    public function __construct()
    {
        $this->savePath = rtrim(ini_get('session.save_path'), '\\/').DIRECTORY_SEPARATOR;

        // Same thing that gets passed to open(), it actually the cookie name
        $this->cookieName = ini_get('session.name');

        if (PHP_VERSION_ID < 70000 && isset($_COOKIE[$this->cookieName]) && ! $this->validateId($_COOKIE[$this->cookieName])) {
            unset($_COOKIE[$this->cookieName]);
        }
    }

    public function validateId($sessionId)
    {
        return is_file($this->savePath.'sess_'.$sessionId);
    }
}

Вы заметите, что я сделал его абстрактным классом - только потому, что я слишком ленив, чтобы написать весь обработчик здесь, и если вы действительно не реализуете методы SessionHandlerInterface, PHP игнорирует ваш обработчик - просто расширяя SessionHandler без переопределения какого-либо метода обрабатывается так же, как и без использования специального обработчика (будет выполняться код конструктора, но логика строгого режима останется из реализации PHP по умолчанию).

TL; DR: проверьте, есть ли у вас данные, связанные с $_COOKIE[ini_get('session.name')] перед вызовом session_start(), и отключите файл cookie, если вы этого не сделаете - это говорит PHP, что он ведет себя так, как будто вы вообще не получили никакого cookie сеанса, тем самым вызывая новое генерирование идентификатора сеанса.:)

Ответ 4

Я думаю, вы могли бы в качестве простейшего подхода немного расширить примерную реализацию следующим образом:

private $validSessId = false;

public function read($id)
{
    if (file_exists("$this->savePath/sess_$id")) {
        $this->validSessId = true;
        return (string)@file_get_contents("$this->savePath/sess_$id");
    }
    else {    
        return '';
    }
}

public function write($id, $data)
{
    if (! $this->validSessId) {
        $id = $this->generateNewSessId();
        header("Set-Cookie:PHPSESSID=$id;");
    }

    return file_put_contents("$this->savePath/sess_$id", $data) === false ? false : true;
}

Внутри метода write вы можете сгенерировать новый идентификатор сеанса и принудительно вернуть его клиенту.

Это не самая чистая вещь, на самом деле. Это относится к настройке обработчика сохранения сеанса, поэтому мы "userland" должны предоставлять только реализацию хранилища, или интерфейс обработчика должен определять метод проверки, который должен вызываться автоматически, вероятно, до read. Во всяком случае, это обсуждалось здесь.