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 ヘッダー)。クライアントは以後のリクエストでそれを送り返します(リクエストの 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> ファイルを読み込み、なければ両方を作ります。

流れ図。リクエスト1:curl が /login に user=mina を POST する。PHP は session_regenerate_id を呼び、データを sess_ と ID を並べた名前のファイルに書き、Set-Cookie: PHPSESSID=ID; path=/ を返す。curl はそれを Cookie jar ファイルに保存する。リクエスト2:curl が同じ ID の Cookie: PHPSESSID を付けて /me を呼び、PHP は対応する sess_ ファイルを読んで user=mina と表示する。点線の経路は、Cookie jar がないとリクエストのたびに空の新しいセッションになることを示す。
PHP のセッションがリクエストをまたいで続く仕組み。HTTP 自体は何も覚えない。Cookie が ID を運び、サーバーはその 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回リクエストしました。

棒グラフ。Cookie jar なしでは200回のリクエストで200個のセッションファイルが残った。curl -c と -b で1つの Cookie jar を共有すると、同じ200回でファイルは1個だけで、そのカウンターは200になった。
session_start() を呼ぶページへ curl で200回リクエストした後、サーバーに残ったセッションファイルの数(PHP 8.5.6、php -S、Apple M1 Pro)。多くのボットのように Cookie を無視するクライアントは、リクエストのたびに新しいセッションを作る。

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 では Secure Cookie を捨てました。ブラウザも 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 などで変換します。

次へ

よくある質問

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 で試すと問題が隠れます。

  • #HTTP
  • #中級
  • #ヘッダー
  • #Cookie
  • #セッション
  • #文字化け
  • #PHP
  • #curl

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑