HTTPヘッダー・Cookie・セッションを curl と PHP で確かめる
HTTP講座3。Shift_JIS の文字化けをバイト単位で再現し、Set-Cookie の属性を curl の Cookie jar で確認。黙って捨てられる Secure Cookie、PHP セッション、固定化攻撃と実測。
シリーズ:HTTP講座 3/6(中級)。 curl -v が読めるようになり(第1回)、実際の Web アプリの不具合を追い始めた人向けです。文字化け、続かないログイン、「セットしたはず」なのに戻ってこない Cookie。どれも60行ほどの PHP アプリと curl で再現し、原因になったヘッダーや属性を特定できるようになります。
HTTP 自体はリクエスト間で何も覚えていません。表示言語の選択からログイン状態まで、「状態」に見えるものはすべてヘッダーに乗って行き来しています。そこが壊れたとき、原因はたいていヘッダー1行で、生のやり取りを見ない限り見つかりません。すべて Apple Silicon Mac(M1 Pro)の PHP 8.5.6(php -S)と curl 8.7.1 で実行しました。パスは ~/demo/ に置き換え、外部へのリクエストは example.com と当サイトへの数回の curl -sI だけです。
デモアプリ
ファイル1つ、switch 1つ。case ごとに1つの挙動を見せます。~/demo/app.php として保存します。
<?php
// php -d session.save_path=./sess -S 127.0.0.1:18104 app.php
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
switch ($path) {
case '/text-sjis': // bytes are Shift_JIS, header says UTF-8
header('Content-Type: text/plain; charset=UTF-8');
echo mb_convert_encoding("こんにちは\n", 'SJIS', 'UTF-8');
break;
case '/text-euckr': // bytes are EUC-KR (CP949), header says UTF-8
header('Content-Type: text/plain; charset=UTF-8');
echo mb_convert_encoding("안녕하세요\n", 'CP949', 'UTF-8');
break;
case '/text-ok':
header('Content-Type: text/plain; charset=UTF-8');
echo "こんにちは 안녕하세요\n";
break;
case '/json-default':
echo json_encode(['msg' => 'こんにちは']), "\n"; // no Content-Type set
break;
case '/json':
header('Content-Type: application/json');
echo json_encode(['msg' => 'こんにちは'], JSON_UNESCAPED_UNICODE), "\n";
break;
case '/prefs':
setcookie('theme', 'dark', ['expires' => time() + 3600, 'path' => '/', 'samesite' => 'Lax']);
setcookie('beta', '1', ['path' => '/app']); // only for /app/...
setcookie('secure_only', 'x', ['path' => '/', 'secure' => true]);
echo "prefs set\n";
break;
case '/login':
session_start();
session_regenerate_id(true); // new id after login
$_SESSION['user'] = $_POST['user'] ?? 'guest';
echo "hello {$_SESSION['user']}\n";
break;
case '/me':
session_start();
$_SESSION['n'] = ($_SESSION['n'] ?? 0) + 1;
echo 'user=' . ($_SESSION['user'] ?? '(none)') . " visits={$_SESSION['n']}\n";
break;
case '/logout':
session_start();
$_SESSION = [];
setcookie(session_name(), '', ['expires' => 1, 'path' => '/']);
session_destroy();
echo "bye\n";
break;
case '/app/x':
echo 'cookies seen: ' . json_encode($_COOKIE) . "\n";
break;
case '/late':
echo "oops, output first\n";
setcookie('late', '1'); // too late: headers already sent
break;
case '/plain':
echo "ok\n";
break;
default:
http_response_code(404);
}
cd ~/demo && mkdir -p sess && php -d session.save_path=./sess -S 127.0.0.1:18104 app.php
session.save_path=./sess は、後でセッションファイルを数えられるように保存先を手元のフォルダーにする設定です。
ヘッダー:名前、大文字小文字、本番サーバーが送るもの
ヘッダーは 名前: 値 の形です。名前は 大文字小文字を区別しません。php -S は Content-type、PHP の header() は Content-Type と送り、HTTP/2 ではすべて小文字になります。example.com への実際の HEAD リクエストです(Cloudflare のリクエスト ID の行を1つ省略)。
$ curl -sI https://example.com/
HTTP/2 200
date: Thu, 12 Nov 2026 14:29:10 GMT
content-type: text/html; charset=utf-8
server: cloudflare
last-modified: Thu, 05 Nov 2026 21:17:41 GMT
allow: GET, HEAD
accept-ranges: bytes
age: 8742
cf-cache-status: HIT
alt-svc: h3=":443"; ma=86400
当サイトの応答の一部です。静的サイトが送るべきセキュリティ系ヘッダーが見えます。
content-type: text/html; charset=UTF-8
vary: Accept-Encoding
etag: W/"22d0-6af53ceb-1d219362a7cb89c4;gz"
content-security-policy: upgrade-insecure-requests
x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin
cache-control: public, max-age=600
つまり headers['Content-Type'] と大文字小文字まで一致させて探すコードは、あるサーバーでは動き、別のサーバーでは失敗します。まともな HTTP ライブラリには大文字小文字を無視する取得方法があるので、そちらを使います。age、etag、cache-control は第4回の話題です。
Content-Type・charset と文字化け
Content-Type はバイト列が何かをクライアントに伝えます。テキストなら charset も付けます。ただしヘッダーは ラベル にすぎず、何も変換しません。
まず正しい例です。xxd でバイトも見ます。
$ curl -s http://127.0.0.1:18104/text-ok
こんにちは 안녕하세요
$ curl -s http://127.0.0.1:18104/text-ok | xxd | head -2
00000000: e381 93e3 8293 e381 abe3 81a1 e381 af20 ...............
00000010: ec95 88eb 8595 ed95 98ec 84b8 ec9a 940a ................
UTF-8 では日本語も韓国語も1文字3バイトです(e3 81 93 が「こ」)。次はラベルが嘘をつく例です。ヘッダーは UTF-8 なのに、中身は古い基幹システムや Excel で保存した CSV のような Shift_JIS です。
$ curl -s http://127.0.0.1:18104/text-sjis
����ɂ���
$ curl -s http://127.0.0.1:18104/text-sjis | xxd
00000000: 82b1 82f1 82c9 82bf 82cd 0a ...........
$ curl -s http://127.0.0.1:18104/text-sjis | iconv -f SHIFT_JIS -t UTF-8
こんにちは
逆の間違い、つまり UTF-8 のバイトを Shift_JIS(CP932)として読むと、おなじみの「縺薙s縺ォ縺。縺ッ」になります(Python の bytes.decode で確認)。「縺」が並んだら UTF-8 を Shift_JIS で読んでいる、� が並んだら Shift_JIS を UTF-8 で読んでいる、と覚えておくと切り分けが速くなります。� は置換文字 U+FFFD で、デコーダーが UTF-8 として不正なバイトをあきらめた印です。文字化けは画面ではなくバイトで診断します。 xxd が実際のエンコーディングを、ヘッダーがサーバーの主張を教えてくれます。
韓国語の EUC-KR(CP949)でも同じことが起きます。
$ curl -s http://127.0.0.1:18104/text-euckr
�ȳ��ϼ���
$ curl -s http://127.0.0.1:18104/text-euckr | xxd
00000000: bec8 b3e7 c7cf bcbc bfe4 0a ...........
JSON にも独自の癖があります。
$ curl -si http://127.0.0.1:18104/json-default | grep -iE 'content-type|msg'
Content-type: text/html; charset=UTF-8
{"msg":"こんにちは"}
$ curl -si http://127.0.0.1:18104/json | grep -iE 'content-type|msg'
Content-Type: application/json
{"msg":"こんにちは"}
header() を呼ばないと PHP は JSON に text/html のラベルを付けます。また json_encode は JSON_UNESCAPED_UNICODE を渡すまで非 ASCII 文字を \uXXXX にエスケープします(JSON としては正しいが、バイト数は2倍でログでは読めない)。JSON は UTF-8 と決まっている(RFC 8259)ので、application/json に charset は不要です。
Cookie:Set-Cookie と curl の Cookie jar
Cookie は、サーバーがクライアントに保存を頼む小さな 名前=値 の組です(レスポンスの Set-Cookie ヘッダー)。クライアントは以後のリクエストでそれを送り返します(リクエストの Cookie ヘッダー)。curl では -c ファイル で保存、-b ファイル で送信です。
$ curl -si -c jar.txt http://127.0.0.1:18104/prefs
HTTP/1.1 200 OK
Host: 127.0.0.1:18104
Date: Thu, 12 Nov 2026 14:30:58 GMT
Connection: close
X-Powered-By: PHP/8.5.6
Set-Cookie: theme=dark; expires=Thu, 12 Nov 2026 15:30:58 GMT; Max-Age=3600; path=/; SameSite=Lax
Set-Cookie: beta=1; path=/app
Set-Cookie: secure_only=x; path=/; secure
Content-type: text/html; charset=UTF-8
prefs set
jar はただのテキストファイルです(タブ区切りで、ドメイン、サブドメインを含むか、パス、Secure、Unix 時刻の有効期限、名前、値)。
127.0.0.1 FALSE / TRUE 0 secure_only x
127.0.0.1 FALSE /app FALSE 0 beta 1
127.0.0.1 FALSE / FALSE 1794497458 theme dark
有効期限 0 は セッション Cookie、つまり Expires/Max-Age がなく、ブラウザを閉じるまでの Cookie です。Path は送り先を決めます。
$ curl -sv -b jar.txt http://127.0.0.1:18104/app/x 2>&1 | grep -E '^> Cookie|cookies seen'
> Cookie: beta=1; secure_only=x; theme=dark
cookies seen: {"beta":"1","secure_only":"x","theme":"dark"}
$ curl -sv -b jar.txt http://127.0.0.1:18104/plain 2>&1 | grep -E '^> Cookie'
> Cookie: secure_only=x; theme=dark
beta は /app/... にだけ送られました。ただし Path はセキュリティの境界ではありません。同じホストの別ページは他の手段で Cookie を読んだり上書きしたりできます。
Secure の落とし穴。 http://127.0.0.1 では、暗号化されていないのに curl は secure_only を保存して送りました。これは意図された動きで、curl は今のブラウザと同じく localhost を安全なコンテキストとして扱います。同じサーバーに --resolve で普通のホスト名を付けると(DNS は使いません)結果が変わります。
$ curl -sv --resolve app.test:18104:127.0.0.1 -c jar2.txt http://app.test:18104/prefs 2>&1 | grep -iE 'cookie|< Set'
* Added cookie theme="dark" for domain app.test, path /, expire 1794497464
< Set-Cookie: theme=dark; expires=Thu, 12 Nov 2026 15:31:04 GMT; Max-Age=3600; path=/; SameSite=Lax
* Added cookie beta="1" for domain app.test, path /app, expire 0
< Set-Cookie: beta=1; path=/app
< Set-Cookie: secure_only=x; path=/; secure
$ grep -v '^#' jar2.txt
app.test FALSE /app FALSE 0 beta 1
app.test FALSE / FALSE 1794497464 theme dark
secure_only には「Added cookie」も警告もなく、黙って捨てられました。「localhost では動くのに、http の検証サーバーではログインできない」という相談の多くはここに行き着きます。
| 属性 | 働き | いつ付けるか |
|---|---|---|
Max-Age / Expires |
寿命。なければセッション Cookie | 設定の保存など。ブラウザ終了で切れてよいログインには付けない |
Path |
このパスの下にだけ送る | ほとんど不要。/ が普通 |
Domain |
サブドメインにも送る | api. と www. で共有が必要なときだけ。省略のほうが厳しい |
Secure |
HTTPS のときだけ送る | 本番では常に |
HttpOnly |
JavaScript から読めない | セッション ID には常に |
SameSite=Lax |
他サイトからのリクエスト(他サイトからの POST など)の多くで送らない | 既定の選択。管理画面は Strict、本当にクロスサイトで使うときだけ None; Secure |
セッション:Cookie 1つで作る「状態」
セッション はデータをサーバーに置き、クライアントには ID だけを渡す仕組みです。PHP の session_start() は PHPSESSID Cookie を読んで sess_<id> ファイルを読み込み、なければ両方を作ります。
Cookie jar がないと curl は Cookie を捨てるので、毎回が初対面です。
$ curl -s http://127.0.0.1:18104/me
user=(none) visits=1
$ curl -s http://127.0.0.1:18104/me
user=(none) visits=1
$ curl -si http://127.0.0.1:18104/me | grep -iE 'set-cookie|cache|expires|pragma'
Set-Cookie: PHPSESSID=8e7bb7345df4509991e181379494cf25; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
session_start() が勝手に追加したヘッダーに注目してください。キャッシュを禁止するヘッダーです。セッションを始めるページは個人ごとの内容なので、PHP はキャッシュに保存させないようにします。前段の CDN がこれを無視すると、ある利用者のページが別の利用者に配られることがあります。CDN が API レスポンスとリダイレクトをキャッシュする を参照してください。
jar を使うとセッションが続き、ログインではデータを保ったまま ID だけが変わります。
$ curl -s -c sjar.txt -b sjar.txt http://127.0.0.1:18104/me
user=(none) visits=1
$ curl -s -c sjar.txt -b sjar.txt http://127.0.0.1:18104/me
user=(none) visits=2
$ grep PHPSESSID sjar.txt
127.0.0.1 FALSE / FALSE 0 PHPSESSID b23d467a32dcb4c9014ff5a8700c4ee1
$ curl -si -c sjar.txt -b sjar.txt -d user=mina http://127.0.0.1:18104/login | grep -iE 'set-cookie|hello'
Set-Cookie: PHPSESSID=345174e3fecbca37cfe447cf3b5e3fba; path=/
hello mina
$ curl -s -c sjar.txt -b sjar.txt http://127.0.0.1:18104/me
user=mina visits=3
$ curl -si -c sjar.txt -b sjar.txt http://127.0.0.1:18104/logout | grep -iE 'set-cookie|bye'
Set-Cookie: PHPSESSID=deleted; expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0; path=/
bye
$ curl -s -c sjar.txt -b sjar.txt http://127.0.0.1:18104/me
user=(none) visits=1
ログイン後の visits=3 は、データが新しい ID に引き継がれたことを示します。Cookie を消すには、過去の日付で同じ Cookie を送り直します。PHP は deleted と Max-Age=0 を書き、curl は jar から削除しました。
よくある間違い
1. 「Cannot modify header information - headers already sent」。 /late は文字を出力してから setcookie() を呼びます。最初のサーバーでは、それでも Cookie が届きました。
$ curl -si http://127.0.0.1:18104/late | grep -E 'Set-Cookie|oops'
Set-Cookie: late=1
oops, output first
Homebrew の php.ini が output_buffering = 4096 で、PHP が最初の 4 KB の出力を溜めていたため、まだヘッダーを足せたのです。-d output_buffering=0(本番設定によくある値で、CLI では常にこの値)で起動すると、同じコードが失敗します。
oops, output first
<br />
<b>Warning</b>: Cannot modify header information - headers already sent by (output started at ~/demo/app.php:53) in <b>~/demo/app.php</b> on line <b>54</b><br />
Set-Cookie はまったく出ません。バッファリングがオンでも、先に 4 KB を超える出力をしたり、include したファイルの <?php の前に BOM や空行があったりすると起きます。「ローカルでは動くのにレンタルサーバーでは動かない」の典型です。対策: ヘッダーと Cookie はすべて出力の前に送ります。「output started at」の場所が犯人を指しています。
2. どんなセッション ID でも受け入れる(セッション固定化)。 この環境の PHP の既定値は session.use_strict_mode => Off、session.cookie_httponly => Off、session.cookie_samesite => no value でした(php -i)。strict mode がオフだと、クライアントが自分でセッション ID を決められます。
$ curl -si -b 'PHPSESSID=attacker123' http://127.0.0.1:18104/me | grep -iE 'set-cookie|user='
user=(none) visits=1
$ ls sess | grep attacker
sess_attacker123
PHP は sess_attacker123 を作り、新しい Cookie すら送りませんでした。攻撃者がこの ID を被害者のブラウザに仕込めて(URL のセッション ID を受け付けるサイトの細工したリンクや、兄弟サブドメインから設定した Cookie など)、被害者が ID の変更なしにログインすると、攻撃者もログイン済みのセッションを共有できます。-d session.use_strict_mode=1 -d session.cookie_httponly=1 -d session.cookie_samesite=Lax で起動した2台目のサーバーではこうなりました。
$ curl -si -b 'PHPSESSID=attacker456' http://127.0.0.1:18105/me | grep -iE 'set-cookie|user='
Set-Cookie: PHPSESSID=8574054420525f021198b29209f694f2; path=/; HttpOnly; SameSite=Lax
user=(none) visits=1
知らない ID は拒否され、新しい ID が発行され、Cookie には HttpOnly と SameSite が付きました。対策: strict mode をオンにし、ログイン時に session_regenerate_id(true) を呼び(/login のように)、cookie_httponly、cookie_samesite、HTTPS なら cookie_secure を設定します。その上に重ねる CSRF トークンは PHP のセキュリティ:XSS・CSRF・セッション で扱っています。
実測:セッションのコスト
セッションファイルは1リクエストあたりは安くても、Cookie を無視するクライアントが相手だと積み上がります。sess フォルダーを毎回空にして、curl で /me に200回リクエストしました。
jar なし:200ファイル(中身はそれぞれ n|i:1; の6バイトですが、この APFS ディスクでは1つ 4 KB のブロックを使い、du で合計 800 KB)。jar あり:n|i:200; を含む1ファイル。クローラーや監視スクリプトは前者と同じ動きをするので、セッションが要らないページで session_start() を呼ぶべきではありません。古いファイルは session.gc_maxlifetime(ここでは1440秒)を過ぎると、リクエストごとに一定の確率で動くガベージコレクションで消されます。
1リクエストの時間コストは小さいものでした。jar ありでウォームアップ1回の後に各50回、中央値は /plain が 0.544 ms、session_start() とファイル書き込みのある /me が 0.579 ms(Apple M1 Pro、ローカル SSD)。ディスクの遅い共有サーバーや、同じ利用者からの並列リクエストが多い場合(PHP はスクリプトが終わるまでセッションファイルをロックします)はもっと悪くなり得ますが、今回は測っていません。
正直な限界・バージョンについて
- Cookie の仕様は RFC 6265(2011年)です。
SameSiteはその改訂版(RFC 6265bis)にあり、私たちの知る限りまだ Internet-Draft です。ブラウザは何年も前から実装しており、Chrome は2020年に属性なしの Cookie の既定値をLaxにしました。ブラウザでは何も実行していません。 - curl 8.7.1 は Cookie について
127.0.0.1を安全とみなし、http のapp.testではSecureCookie を捨てました。ブラウザもhttp://localhostを「潜在的に信頼できる」と扱いますが、ブラウザとバージョンで細部が違い、検証していません。 - PHP の既定値(
use_strict_mode、cookie_httponly、cookie_samesite、output_buffering)は、この Homebrew 版 PHP 8.5.6 と開発用php.iniのものです。レンタルサーバーでは変えられていることが多いので、本番でphpinfo()を確認してください。 - セッション ID はここでは16進32文字でした。PHP 8.4 で
session.sid_lengthとsession.sid_bits_per_characterの設定は非推奨になりました。他の値は試していません。 - 文字化けの見た目はターミナルのフォントや設定で変わります。
xxdのバイトは変わりません。
演習
1. 同僚が curl -X POST -d 'user=a&pass=b' https://staging.example.test/login の後に curl https://staging.example.test/me を実行し、「未ログイン」と返ってきました。何が問題で、直した2つのコマンドはどうなりますか?
2. CSV 取り込み画面で日本語の氏名がすべて「縺」だらけになりますが、データベースを管理ツールで見ると正常です。バイトとラベルのどちらの問題かを判断する、この講座の2つのコマンドは何ですか?
解答
1. curl がセッション Cookie を保存していないので、2回目のリクエストには Cookie ヘッダーがなく、空の新しいセッションになりました。両方に jar を付けます。curl -c jar.txt -b jar.txt -d 'user=a&pass=b' https://staging.example.test/login の後に curl -c jar.txt -b jar.txt https://staging.example.test/me。(-d だけで POST になるので -X POST は外します。)
2. curl -sI URL | grep -i content-type でラベル(サーバーが主張する charset)を、curl -s URL | xxd | head でバイトを見ます。「縺」だらけなら、UTF-8 のバイト(e3 81 93 のような3バイト組)が Shift_JIS として表示されている可能性が高く、ヘッダーかページの charset 指定が Shift_JIS になっていないかを確認します。バイトが 82 b1 のような Shift_JIS なら、データ側を iconv -f CP932 -t UTF-8 などで変換します。
次へ
- 前回:HTTP講座 2/6:ステータスコードとリダイレクト
- 次回:HTTP講座 4/6:ETag と Cache-Control のキャッシュ
- その後:HTTPS・TLS・証明書、keep-alive と圧縮
- 最初から:HTTP講座 1/6:リクエスト・レスポンスと curl -v
- PHP コードでのセッションと CSRF:PHP のセキュリティ:XSS・CSRF・セッション
- すべての講座:プログラミング学習ロードマップ
よくある質問
charset=UTF-8 を指定しているのに ����ɂ��� のように文字化けするのはなぜですか?
ヘッダーとバイト列が食い違っているからです。Content-Type: text/plain; charset=UTF-8 と送りながら、本文のバイトは Shift_JIS(82 b1 82 f1 …)でした。ヘッダーはバイトに貼るラベルにすぎず、変換はしません。データを UTF-8 に変換する(iconv -f SHIFT_JIS で元の文字に戻りました)か、ラベルを実際に合わせます。
curl でログイン API を試すと、次のリクエストでログインが切れるのはなぜですか?
curl は指示しない限り Cookie を保存しないからです。Cookie jar なしでセッションを使うページを3回呼ぶと、毎回 visits=1 で新しいセッションファイルができました。-c jar.txt -b jar.txt を付けるとカウンターが 1、2 と進み、ログインも次のリクエストまで続きました。
Secure 属性の Cookie を http:// のサイトで返すとどうなりますか?
捨てられます。--resolve で 127.0.0.1 に向けた http://app.test では、curl 8.7.1 は theme と beta を保存し、secure_only は何も言わずに捨てました。http://127.0.0.1 では保存しました。curl は localhost を安全なコンテキストとして扱うので、localhost で試すと問題が隠れます。