Обеспечение строгого режима сеанса в пользовательской реализации 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
. Во всяком случае, это обсуждалось здесь.