HTTP セッション管理とトランザクション

こんにちは。サイオステクノロジー OSS サポート担当 T です。

PostgreSQL などの RDBMS を利用して HTTP セッション管理を行う場合、一貫性を保つためにトランザクションが必要になります。今回は HTTP セッション管理とトランザクションの利用について解説します。

HTTP セッション管理に必要な要素

HTTP プロトコルはステートレス (状態を維持しない) プロトコルであるため、HTTP アプリケーションでログインセッションなどを管理するために HTTP セッション管理が必要となります。一般に HTTP セッション管理は接続を識別するためにランダムに生成されたセッション ID をクッキーに保存し、同じセッション ID クッキーを送信したクライアントからのリクエストは同じクライアントからの接続として処理します。

接続を識別するためにユーザ ID (番号やユーザ名または固定のハッシュ値など) を用いると攻撃に対して脆弱になります。一度ユーザ識別に利用される ID がなんらかの形で漏洩した場合、そのユーザに半永久的に成り済ますことが可能になります。

セッション ID はランダムに生成されるだけでなく、セキュリティ要件に合わせて定期的に更新し、ユーザの成り済ましリスクを軽減させる必要があります。

つまりセッション ID は、

  • 総当たり攻撃を含め、予測不可能なランダムな ID でなければならない
  • 定期的にセッション ID は更新されなければならない

といった要件が必要になります。

これに加えセッション ID を処理する場合には、

  • サーバ側で未初期化のセッション ID を受け入れてはならない

という要件も必要です。現在のクッキーの仕様には問題があり攻撃者が変更不可能なクッキーを設定することが可能です。攻撃者はクッキーのパス、ドメイン、HttpOnly、Secure 属性などの組み合わせで常に同じクッキーが送信されるよう細工できます。

セッション ID 更新の問題

セッション ID を更新するには、単純に新しい ID となるクッキーを設定すれば良い、と考えるかも知れません。しかし、これでは不十分です。

古いセッション ID は悪用されないよう削除されなければなりません。しかし、古いセッション ID は新しいセッション ID を付与した際に即座に削除できません。これにはブラウザの仕様と HTTP セッション管理の要件が関係しています。

古いセッション ID を即座に削除した場合、ブラウザから古いセッション ID を利用した接続が送信される可能性があります。これはブラウザは Web サーバに対して複数の接続 (現在のブラウザは概ね 6接続を同時に利用する) を用いて Web サーバに接続します。フレームなどを用いている場合やユーザが複数のタブを用いている場合は更に増えます。古いセッション ID をサーバ側で削除した直後に、古いセッション ID を利用した接続が行われる場合があります。

削除済みのセッション ID でリクエストが送信された場合、HTTP セッション管理の 3つ目の要件である

  • サーバ側で未初期化のセッション ID を受け入れてはならない

により、Web サーバは新しいセッション ID を生成します。これにより、サーバ側で古いセッション ID を即座に削除すると、複数のセッション ID 生成が短時間に行われることがあります。

ブラウザ側ではクッキーの保存/参照にロックをかけていないようです。このため、複数のセッション ID が短時間に生成されると同時実行問題の典型例であるリーダーライター問題が発生してしまうことが、実際のシステムで判っています。セッション ID クッキーは空になることはありえませんが、空になってしまうことが稀に発生します。

ブラウザのクッキー管理と HTTP セッション管理の要件以外にも、不安定なネットワークも問題の原因になります。現在では接続状態が不安定なモバイルネットワークやワイヤレスネットワーク環境からのアクセスは少なくありません。このような環境では以下のようなケースが考えられます。

  1. ブラウザがリクエストを送信
  2. Web サーバが期限切れのセッション ID を検出し、新しいセッション ID を送信
  3. 新しいセッション ID の送信直後にネットワークが切断される
  4. ブラウザが新しいセッション ID を受信できず、古いセッション ID は既に無効となる

このような仕組みで稀にセッションが途切れてしまう (ログオフした状態になる) などの問題が発生します。

これらの問題は常に発生するのではなく “稀” に発生します。これらの HTTP セッション管理の問題はユーザレベルのコードで対応可能です。しかし、多くのシステムで適切/必要な HTTP セッション管理が行われていません。

PHP プロジェクトではこのような問題に対応する為の提案が数年前から提案されています。
( https://wiki.php.net/rfc/precise_session_management )

RDBMS を利用したセッション管理データベース

ここでは HTTP セッション管理の問題ではなく、HTTP セッション管理に利用するセッションデータベースを正確に管理する為に必要なコードを紹介します。例としてセッション管理用のデータ保存先を簡単に定義可能な PHP と PostgreSQL を利用します。

RDBMS を利用した場合、ファイルや Memcached を利用した場合とは異る点に注意が必要です。一貫性を保つ為にトランザクションを利用しなければならない事は直ぐ理解ると思います。しかし、セッション管理用のデータベースは “既にあるレコード” の一貫性を保つのみでなく、”削除したレコード”、”新しく作ったレコード” との一貫性も保たなければなりません。

問題となるアクセスシナリオ1:

  1. クライアントが Web サーバにアクセスする
  2. Web サーバが期限切れのセッション ID を更新し、古いセッション ID のレコードを即時に削除する

この場合、既に解説した通り、既に削除済みのセッション ID へのアクセスが発生する可能性があります。これは防止できなればなりません。

問題となるアクセスシナリオ2:

  1. クライアントが Web サーバにアクセスする
  2. Web サーバでセッションデータベースのガーベッジコレクションが実行され、クライアントがアクセスしたセッション ID のデータが削除される

この場合、本来は期限切れでガーベッジコレクションされアクセスできないはずのデータにアクセスできてしまう可能性があります。これも防止できなければなりません。

問題となるアクセスシナリオ3: (未初期化のセッション ID が許可されている場合に問題となる)

  1. クライアントが未初期化セッション ID でアクセスする
  2. Web サーバがそのセッション ID でセッションデータベースレコードを作成する

ファイルや Memcached を利用している場合、新しいセッション ID のデータベースは即座に他の接続から見えます。ロックをしていれば適切にロックされます。RDBMS の場合、新しいレコードはコミットするまで他のトランザクションから見えません。これも適切に防止できる必要があります。

RDBMS データベースの場合、ファイルや Memcached などと異り、トランザクション内で削除/追加/更新されたデータは他のトランザクションに即座に反映されません。コミットして初めて他のトランザクションでは削除されていることが分かります。一貫性を保つ為にはトランザクションエラーを検出し、自動ロールバックしたトランザクションを再実行する必要があります。このような要件には SERIALIZABLE レベルのトランザクション分離レベルを利用すると良いです。

このようなセッションデータベース管理を行うサンプルコードが Gist に公開されています。

https://gist.github.com/yohgaki/a7b130bc93b2f9467ccc

<?php

/**
Use following table

CREATE TABLE php_session (
  id text UNIQUE NOT NULL,
  data bytea NOT NULL,
  updated int8 NOT NULL
);

Note:
This save handler handles concurrency error by ignoring failed transactions
and try again upto pgsql_session_save_handler::$max_transaction_errors.

Concurrency errors may happen when browser sent concurrent requests to
web server. e.g. Browsers may send multiple requests for faster page loading,
from multiple frames, from multiple tabs, etc.

Unlike file system based session data storage, database system may not show
newly created/deleted rows in transaction. Database system maximizes concurrency 
by hiding transaction details, but this may result in concurrency errors. In 
order to achive concurrency and consistency, save handler must retry transaction
when error happenned.

*/

class pgsql_session_save_handler implements SessionHandlerInterface {

 protected $db;
 protected $max_transaction_errors = 10;


 public function open($savePath, $sessionName) {
  $this->db = pg_pconnect("host=localhost port=5432 dbname=yohgaki user=yohgaki");
  //$this->gc(0); // This is for debugging purpose only
  if ($this->db === FALSE) {
   return FALSE;
  }
  return TRUE;
 }


 public function close() {
  // Uses persistent connection. Return TRUE always.
  return TRUE;
 }


 public function destroy($sid) {
  $result = pg_query_params($this->db, 'DELETE FROM "php_session" WHERE id = ;',[$sid]);
  if ($result === FALSE) {
   return FALSE;
  }
  // Do not care if it is really deleted or not.
  return TRUE;
 }


 /* Following private functions ignore error intentionally, so that
    transaction errors will be suppressed */
 private function sel_session($sid) {
  // "FOR UPDATE" is needed for proper session data locking.
  return @pg_query_params($this->db, 'SELECT data, updated FROM php_session WHERE id =  FOR UPDATE;', [$sid]);
 }


 private function ins_session($sid) {
  return @pg_query_params($this->db, 'INSERT INTO php_session (id, data, updated) VALUES (, , )', [$sid, '', time()]);
 }


 private function del_session($sid) {
  return @pg_query_params($this->db, 'DELETE FROM php_session WHERE id = ', [$sid]);
 }


 private function upd_session($sid, $data) {
  $data = pg_escape_bytea($this->db, $data);
  return @pg_query_params($this->db, 'UPDATE php_session SET data = , updated =  WHERE id = ', [$sid, $data, time()]);
 }


 public function read($sid) {
  $maxlifetime = (int)ini_get('session.gc_maxlifetime');
  if ($maxlifetime <= 0) {
   trigger_error('Max session life time in seconds should be positive numbers.');
   return FALSE;
  }
  while ($this->max_transaction_errors--) {
   // Clean up old transaction.
   pg_query($this->db, 'COMMIT;');
   // Transaction isolation level must be serializable for data consistency.
   if (!pg_query($this->db, 'BEGIN;') || !pg_query($this->db, 'SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;')) {
    return FALSE;
   }
   // If you would not like to select expired data, select the row and delete it if it is needed.
   $result = $this->sel_session($sid);
   if (!$result) {
    continue;
   }
   $row = pg_fetch_assoc($result);
   /*
     Note: Transaction for concurrent new session ID may fail because
     browser uses multiple concurrent connection to web server.
   */
   if (!$row) {
    // New session
    if (!$this->ins_session($sid)) {
     continue;
    }
    return '';
   }
   else if ($row['updated'] < time() - $maxlifetime) {
    // Expired. Delete and create
    if (!$this->del_session($sid)) {
     continue;
    }
    if (!$this->ins_session($sid)) {
     continue;
    }
    return '';
   }
   return pg_unescape_bytea($row['data']);
  }
  // Under normal circumstances, it's transaction error. Gave up.
  // write() will be called anyway. Let write() call COMMIT.
  return FALSE;
 }


 public function write($sid, $data) {
  /*
    Note:
    upd_session() may result in transaction error and
    COMMIT may result in ROLLBACK due to concurrency error.
    However, COMMIT failure does not raise PHP error.
  */
  $this->upd_session($sid, $data);
  pg_query($this->db, 'COMMIT;');
  return TRUE;
 }


 public function gc($maxlifetime) {
  return pg_affected_rows(pg_query_params($this->db, 'DELETE FROM php_session WHERE updated < ', [time() - $maxlifetime]));
 }
}



//////////// test //////////////////

ini_set('session.save_handler', 'user');
ini_set('session.gc_maxlifetime', 120);

$pgsql_handler = new pgsql_session_save_handler;
session_set_save_handler($pgsql_handler, TRUE);

ob_start();
session_start();
var_dump($_SESSION['cnt']++);

トランザクションの再実行を行なっている部分は read メソッドです。

 public function read($sid) {
  $maxlifetime = (int)ini_get('session.gc_maxlifetime');
  if ($maxlifetime <= 0) {
   trigger_error('Max session life time in seconds should be positive numbers.');
   return FALSE;
  }
  while ($this->max_transaction_errors--) {
   // Clean up old transaction.
   pg_query($this->db, 'COMMIT;');
   // Transaction isolation level must be serializable for data consistency.
   if (!pg_query($this->db, 'BEGIN;') || !pg_query($this->db, 'SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;')) {
    return FALSE;
   }
   // If you would not like to select expired data, select the row and delete it if it is needed.
   $result = $this->sel_session($sid);
   if (!$result) {
    continue;
   }
   $row = pg_fetch_assoc($result);
   /*
     Note: Transaction for concurrent new session ID may fail because
     browser uses multiple concurrent connection to web server.
   */
   if (!$row) {
    // New session
    if (!$this->ins_session($sid)) {
     continue;
    }
    return '';
   }
   else if ($row['updated'] < time() - $maxlifetime) {
    // Expired. Delete and create
    if (!$this->del_session($sid)) {
     continue;
    }
    if (!$this->ins_session($sid)) {
     continue;
    }
    return '';
   }
   return pg_unescape_bytea($row['data']);
  }
  // Under normal circumstances, it's transaction error. Gave up.
  // write() will be called anyway. Let write() call COMMIT.
  return FALSE;
 }

セッションデータベースへの変更はガーベッジコレクションを含め、トランザクション内で行われているのでトランザクションにより一貫性が保たれます。その代わりにトランザクションエラーが発生します。

このコードの場合、

while ($this->max_transaction_errors--) {

のループで指定された回数、トランザクションを再実行するようになっています。

まとめ

HTTP セッション管理用のデータベースなどのように、同時接続が行われる場合はトランザクションを利用しなければなりません。アプリケーションが何もしなくても SERIALIZABLE 分離レベルのトランザクションを利用していれば自動的に一貫性が保たれる訳ではありません。

RDBMS は同時実行性を最大化するため、実行可能なクエリは実行します。途中で無効となってしまうトランザクションも発生します。この様な場合、クエリ/コミットに失敗します。アプリケーションはトランザクションエラーを検出し、必要であればトランザクションを再実行しなければなりません。

本稿の範囲外ですが HTTP セッション管理は直感的に十分と思える管理だけでは不十分です。セッション ID を更新する場合、古いセッション ID のデータを一定期間妥当なセッション ID として利用できるようにしないと、稀に問題が発生します。

Be the first to comment

コメント投稿

Your email address will not be published.


*