セキュリティ設計 - ha1t/php-gm-server GitHub Wiki
本ページでは、PHP GM Server のセキュリティ実装と設計方針について説明します。このサーバーは Gemini Protocol クライアントからのアクセスに対して、パストラバーサル攻撃の防止、リクエスト長制限、TLS/SSL の必須化、自己署名証明書による Trust On First Use(TOFU)モデルの実装を通じて、堅牢なセキュリティを提供します。
StaticFileHandler クラスは、ファイルシステムのパストラバーサル攻撃(../ を用いた上位ディレクトリへのアクセス)を防止するため、PHP の realpath() 関数を使用してパスを正規化しています。
ドキュメントルート自体が初期化時に realpath() で正規化され、その後のファイルアクセスでは、リクエストパスと組み合わせたパスが再度 realpath() で解決されます。これにより、シンボリックリンクを含む複雑なパス構造も完全に展開されます。
public function __construct(string $docRoot)
{
$this->docRoot = realpath($docRoot) ?: $docRoot;
}Sources: php-gm-server/src/StaticFileHandler.php:29
ファイルハンドラーが実際のファイルを読み取る前に、解決されたパスがドキュメントルート内に存在することを確認します。以下の 2 つの条件のいずれかを満たす必要があります:
- 解決されたパスがドキュメントルートと完全に一致
- 解決されたパスがドキュメントルートの直下(DIRECTORY_SEPARATOR で区切られている)
$realPath = realpath($filePath);
if ($realPath === false) {
return null;
}
if ($realPath !== $this->docRoot && !str_starts_with($realPath, $this->docRoot . DIRECTORY_SEPARATOR)) {
return null;
}Sources: php-gm-server/src/StaticFileHandler.php:43-50
この二重チェックにより、攻撃者が絶対パスやシンボリックリンクを利用してドキュメントルート外のファイルにアクセスすることは不可能になります。
flowchart TD
A[クライアントリクエスト<br/>パス: /some/path] -->|path パラメータ| B[StaticFileHandler::handle]
B --> C["filePath = docRoot + path"]
C --> D{ディレクトリか?}
D -->|はい| E["filePath += /index.gmi"]
D -->|いいえ| F["realpath を実行"]
E --> F
F --> G{realpath 成功?}
G -->|失敗| H["null を返却"]
G -->|成功| I["realPath を取得"]
I --> J{docRoot内チェック}
J -->|失敗| H
J -->|成功| K{ファイル存在?}
K -->|いいえ| H
K -->|はい| L["ファイル内容 + MIME<br/>を返却"]
Gemini Protocol の仕様により、各リクエストは URL を含む 1 行のテキストで、最大長は 1024 バイト(CRLF 含む)です。RequestParser クラスはこの制限を厳密に実装しています。
private const MAX_REQUEST_LENGTH = 1024;
public static function parse(string $raw): ?array
{
$line = explode("\r\n", $raw, 2)[0];
if ($line === '' || strlen($line) > self::MAX_REQUEST_LENGTH) {
return null;
}
// ...
}Sources: php-gm-server/src/RequestParser.php:9-20
GeminiServer の接続ハンドラーは、受信したデータをバッファに蓄積しながら、バッファサイズが 1024 バイトを超えた場合、CRLF がまだ検出されていない場合に即座にエラーレスポンスを返して接続を切断します。
$conn->on('data', function (string $data) use ($conn, &$buffer) {
$buffer .= $data;
if (!str_contains($buffer, "\r\n")) {
if (strlen($buffer) > 1024) {
$conn->write(ResponseBuilder::badRequest('Request too long'));
$conn->end();
}
return;
}
// ...
});Sources: php-gm-server/src/GeminiServer.php:51-60
これにより、DoS 攻撃やバッファオーバーフロー試行から保護されます。
flowchart TD
A["クライアント接続<br/>TLS確立"] --> B["データ受信"]
B --> C["バッファに追加"]
C --> D{CRLF 検出?}
D -->|いいえ| E{バッファ > 1024?}
E -->|はい| F["59 Request too long<br/>接続終了"]
E -->|いいえ| B
D -->|はい| G["RequestParser::parse"]
G --> H{パース成功?}
H -->|失敗| I["59 Invalid request<br/>接続終了"]
H -->|成功| J["StaticFileHandler::handle"]
J --> K{ファイル発見?}
K -->|いいえ| L["51 Not found<br/>接続終了"]
K -->|はい| M["20 MIME<br/>ファイル内容<br/>接続終了"]
GeminiServer は ReactPHP の SecureServer ラッパーを使用して、すべてのクライアント接続を TLS/SSL で暗号化しています。TcpServer 上に SecureServer を重ねることで、平文接続は許可されません。
$tcp = new TcpServer("{$this->host}:{$this->port}", $loop);
$server = new SecureServer($tcp, $loop, [
'local_cert' => $this->certPath,
'allow_self_signed' => true,
'verify_peer' => false,
]);Sources: php-gm-server/src/GeminiServer.php:35-40
| 設定項目 | 値 | 説明 |
|---|---|---|
local_cert |
server.pem | サーバー証明書と秘密鍵を含むファイルパス |
allow_self_signed |
true | 自己署名証明書を許可(開発・個人使用用) |
verify_peer |
false | クライアント証明書を要求しない |
Gemini Protocol の公式ポートは 1965 です。サーバーはデフォルトでこのポートでリッスンします。
Sources: php-gm-server/bin/server.php:12
CertificateGenerator クラスは起動時に自動的に自己署名証明書を生成します。既存の証明書が存在しない場合のみ、新規生成が行われます。
public function generate(string $hostname): string
{
$pemPath = $this->certDir . '/server.pem';
if (file_exists($pemPath)) {
return $pemPath;
}
// ... 証明書生成処理 ...
}Sources: php-gm-server/src/CertificateGenerator.php:16-22
| 項目 | 値 | 説明 |
|---|---|---|
| キータイプ | RSA 2048 ビット | 中程度の暗号強度、生成速度とのバランス |
| 有効期間 | 365 日(1 年) | 定期的なセキュリティ確認のため |
| フォーマット | PEM | 秘密鍵と証明書を同一ファイル |
| ファイルパーミッション | 0600 | 所有者のみアクセス可能 |
| 署名者 | 自己署名 | CA による検証なし |
| Common Name | hostname | サーバーのホスト名(環境変数で指定) |
$privateKey = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
$dn = [
'commonName' => $hostname,
];
$csr = openssl_csr_new($dn, $privateKey);
$cert = openssl_csr_sign($csr, null, $privateKey, 365);Sources: php-gm-server/src/CertificateGenerator.php:28-38
Gemini Protocol は TOFU (Trust On First Use) セキュリティモデルを採用しています。これは以下の特性を持つ方式です:
-
初回接続時の証明書フィンガープリント記録:クライアントは初めてサーバーに接続する際に、サーバー証明書のフィンガープリント(通常は SHA-256)をローカルに記録します。
-
以後の検証:同じサーバーへの後続接続では、受け取った証明書のフィンガープリントが記録されたものと一致することを確認します。
-
一致しない場合の警告:証明書が変更された場合(サーバー側での再生成、中間者攻撃など)、クライアントは警告を表示し、ユーザーに確認させます。
-
CA 検証不要:自己署名証明書であっても、その継続性が保証されれば信頼できるという前提です。
この方式は個人サイトやホビープロジェクトに適しており、CA による証明書管理コストを削減できます。
sequenceDiagram
participant Client as Geminiクライアント
participant Server as PHP GM Server
rect rgb(200, 220, 255)
Note over Client,Server: 初回接続時
Client->>Server: TLS接続要求
Server-->>Client: 自己署名証明書<br/>fingerprint: ABC123...
Client->>Client: フィンガープリント<br/>をローカル保存
Client->>Client: ホストキー認証完了
Client->>Server: Geminiリクエスト
Server-->>Client: レスポンス
end
rect rgb(220, 255, 220)
Note over Client,Server: 2回目以降の接続
Client->>Server: TLS接続要求
Server-->>Client: 同一証明書<br/>fingerprint: ABC123...
Client->>Client: ローカル記録と比較
alt フィンガープリント一致
Client->>Client: 接続信頼済み
else フィンガープリント不一致
Client->>Client: ⚠️ 警告表示
Client->>Client: ユーザー確認待機
end
end
Sources: php-gm-server/src/CertificateGenerator.php:46-47
| 脅威 | 対策 | 実装箇所 |
|---|---|---|
| パストラバーサル攻撃 | realpath + ドキュメントルート内チェック | StaticFileHandler |
| バッファオーバーフロー | リクエスト長制限(1024 バイト) | RequestParser, GeminiServer |
| 平文通信による盗聴 | TLS/SSL 必須化 | SecureServer |
| 中間者攻撃 | 自己署名証明書 + TOFU モデル | CertificateGenerator |
| 不正フォーマット | URL スキーム検証(gemini のみ) | RequestParser |
| ファイルシステムエラー | null チェック、例外キャッチ | StaticFileHandler, GeminiServer |
このサーバーは「個人サイト公開」と「PHP 勉強会での発表」を目的としており、以下の特性を持つ環境での運用を想定しています。
- 信頼できるネットワーク環境:自宅の LAN、または個人・小規模団体のサーバー
- 限定的なクライアント:既知のクライアント(Amfora、Lagrange など)
- データ機密性の要件が低い:パブリックな情報が主体
- 高可用性要件がない:ダウンタイムが許容される
これらの前提がない環境では、以下の追加対策を検討してください。
| 項目 | 現状 | 推奨事項 |
|---|---|---|
| クライアント認証 | 未実装 | リバースプロキシや OS レベルのファイアウォール使用 |
| アクセス制御 | パス名に基づくのみ | 認証機構の導入 |
| ログ記録 | コンソール出力のみ | ファイル出力、ログ集約の検討 |
| 暗号スイート | React/Socket デフォルト | TLS 1.3 への制限検討(PHP/OpenSSL 設定が必要) |
| HSTS 相当機構 | なし(不要) | Gemini プロトコルは TLS 必須のため不要 |
| レート制限 | なし | nginx リバースプロキシなど外部ツール推奨 |
セキュリティ関連のエラーレスポンスは、以下の Gemini Protocol ステータスコードで返却されます。
public static function badRequest(string $reason = 'Bad request'): string
{
return "59 {$reason}\r\n";
}
public static function notFound(): string
{
return "51 Not found\r\n";
}Sources: php-gm-server/src/ResponseBuilder.php:14-22
| ステータスコード | 用途 | 具体例 |
|---|---|---|
| 59 Bad Request | プロトコルエラー、セキュリティ違反 |
59 Request too long, 59 Invalid request
|
| 51 Not Found | ファイル不在、パストラバーサル検出 | 51 Not found |
| 20 Success | 正常なファイル配信 | 20 text/gemini |
接続エラーが発生した場合、コンソールにエラーメッセージが出力されるとともに、該当接続は自動的に終了されます。これにより、メモリリークや僵尸接続の発生を防いでいます。
$conn->on('error', function (\Throwable $e) {
echo "Connection error: {$e->getMessage()}\n";
});
$server->on('error', function (\Throwable $e) {
echo "Error: {$e->getMessage()}\n";
});Sources: php-gm-server/src/GeminiServer.php:44-47, 85-87
flowchart TD
A["サーバー起動<br/>bin/server.php"] --> B["環境変数取得"]
B --> C["GEMINI_HOST<br/>GEMINI_PORT<br/>GEMINI_DOC_ROOT<br/>GEMINI_CERT_DIR<br/>GEMINI_HOSTNAME"]
C --> D["CertificateGenerator<br/>初期化"]
D --> E{証明書ファイル<br/>存在?}
E -->|あり| F["既存ファイル使用"]
E -->|なし| G["OpenSSL で<br/>新規生成"]
G --> H["RSA 2048<br/>365日有効<br/>自己署名"]
H --> F
F --> I["StaticFileHandler<br/>初期化"]
I --> J["docRoot を<br/>realpath 正規化"]
J --> K["GeminiServer<br/>初期化"]
K --> L["SecureServer<br/>TLS リッスン"]
L --> M["ポート 1965<br/>で待機"]
Sources: php-gm-server/bin/server.php:11-23
すべてのパース・ファイルハンドリング処理は、失敗時に null を返却する設計です。これにより、エラーケースが明示的に処理され、デフォルトの "Not found" レスポンスが返却されます。
PHP 8.1 の strict_types と型宣言により、型チェックの甘さによるセキュリティ問題を最小化しています。
<?php
declare(strict_types=1);Sources: php-gm-server/src/StaticFileHandler.php:1-3
- プロジェクト概要 — プロジェクト全体像と技術スタック
- アーキテクチャ設計 — システム全体の構成とデータフロー
- Geminiプロトコル仕様実装 — プロトコル仕様の詳細
- StaticFileHandlerコンポーネント — ファイルハンドリングと MIME 判定
- RequestParserコンポーネント — リクエストパース実装
- GeminiServerコンポーネント — サーバーメインループ
- CertificateGeneratorコンポーネント — 証明書生成機構
- TLS証明書管理 — 証明書管理と設定
- リクエスト処理フロー — 完全なリクエスト処理フロー
- エラーハンドリング戦略 — エラーレスポンス戦略