はじめに
自己ホスト型のアプリを共有レンタルサーバーに置き、curl で API を叩いて「本番も健全」と判断している方へ。
ある日、自己ホスト型 OSS の本番デモ環境を curl で総打鍵しました。ログイン、一覧取得、作成、更新、CSV 取込、CSV エクスポート——全 API が 2xx で全緑。「本番も健全」と結論しました。
翌日、同じ環境を実ブラウザ(Playwright)で打鍵したところ、6 つの機能が停止していました。銀行 CSV 取込、手入力データの CSV 一括取込、そして 4 種類の CSV エクスポート。すべて 401 No Bearer token was provided. です。
前日の curl は嘘をついていません。curl は、curl が手で組み立てたヘッダを持つリクエストにだけ緑を返します。問題は、curl が投げるリクエストと、SPA が本番環境で実際に投げるリクエストが、別物だったことです。壊れていたのは curl が構造上「見ない」層でした。
この記事は、その観測ギャップの正体と、同型のバグが 3 製品で連鎖して見つかった経緯、そして「curl 全緑」を信用しないための運用の学びをまとめたものです。
なお、ここで背景になっている「共有レンタルサーバーの前段プロキシが Authorization ヘッダを剥がす」現象そのものと、その根因切り分け・恒久対策の実装は、別記事「共有レンタルサーバーで Authorization ヘッダだけが消える」に書きました。本記事はそちらの深掘りではなく、「curl 緑/ブラウザ赤」という観測ギャップの側が主題です。
この記事で分かること:
- 「curl で全緑」が「実ユーザーは壊れていない」を意味しない構造的な理由
- 同型バグ(共有クライアントを迂回した生 fetch)が 3 製品で連鎖した検出の流れ
- 実ブラウザ×本番でしか出ないバグの再現条件と、不変条件を lint で構造ごと塞ぐ再発防止
curl が見ない層
前提:本番だけがヘッダを 1 つ落とす
対象の製品はいずれも PHP バックエンド + SPA フロントで、認証は JWT の Bearer トークンです。共有レンタルサーバーの前段プロキシが標準の Authorization ヘッダを握りつぶす環境のため、バックエンドは X-Authorization というミラーヘッダへのフォールバックを持ちます。フロントの共有 API クライアントは、リクエストのたびに両方を必ず併送します。
// 共有 API クライアント(イメージ)。Authorization と X-Authorization を必ず両方送る。
function withAuthHeaders(headers = {}) {
const token = getToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
headers['X-Authorization'] = `Bearer ${token}` // 前段プロキシ対策のミラー
}
return headers
}
この共有クライアントを通る限り、本番でも認証は通ります。ローカルの Docker はプロキシが挟まらないので、そもそも Authorization が剥がれず、こちらも常に通ります。
curl 打鍵は「正しいヘッダを持つ API 呼び出し」しか検証しない
前日の curl 打鍵メモには、既知のプロキシ挙動を踏まえて Authorization と X-Authorization を両方とも手で付けて叩いた、と記録されていました。つまり curl は、SPA のリクエスト構築コードを丸ごと自分の手打ちヘッダに置き換えていた。だから:
- 本番でプロキシが
Authorizationを剥がしても、curl はミラーを手で付けているので通る。 - SPA のどのコードがどんなヘッダを組み立てているかは、curl の検証範囲の外。
curl が検証していたのは「正しいヘッダを持つリクエストにサーバーが正しく応答するか」であって、「本番プロキシ環境で SPA が実際に組み立てて送るリクエストが正しいか」ではありませんでした。後者こそが実ユーザーの通る経路です。
壊れていたのは「共有クライアントを迂回した生 fetch」
赤くなった 6 機能を追うと、原因は 1 種類でした。共有 API クライアントを通さず、生の fetch() を直接呼んで Authorization だけを付けていたコードです。ファイル I/O 系(アップロード・バイナリダウンロード)は共通の JSON リクエスト経路に乗せづらく、各所で手組みされがちで、そこでミラーが取りこぼされていました。
// 迂回パターン(バグの形)。共有クライアントを通さず、ミラーを付け忘れる。
const res = await fetch(path, {
headers: { Authorization: `Bearer ${token}` }, // X-Authorization が無い
})
このコードは、
-
ローカル開発では通る(プロキシが無いので
Authorizationが剥がれない) - curl 打鍵でも通る(そもそもこのコードを実行していない。curl が手でミラーを付けている)
-
本番の実ブラウザでだけ 401(プロキシが
Authorizationを剥がし、ミラーが無いので Bearer が消える)
という、テスト手法の穴にぴたりとはまる壊れ方をします。curl の全緑は、環境差とコードパスの差の両方を同時に隠していました。
3 製品で連鎖検出
面白かったのは、これが 1 製品の 1 バグでは終わらなかったことです。フリート内の複数製品を実ブラウザで横断打鍵したところ、同型のバグ(=共有クライアントを迂回した生 fetch がミラーを落とす)が 3 製品で連鎖して見つかりました。いずれも curl 打鍵では原理的に検出できなかったものです。
| 製品 | 壊れていた機能 | Issue / PR |
|---|---|---|
| 案件パイプライン(deal) | 監査ログ CSV エクスポート(1 機能) | #83 / PR #85 |
| 書類保管(vault) | エクスポート ZIP・CSV | #173 / PR #177 |
| 消込(clear) | CSV 取込 2 種+ CSV エクスポート 4 種(計 6 機能) | #312 / PR #313 |
- 6 機能の内訳(clear)は、生 fetch の迂回箇所 3 つに対応します。エクスポート用の共通ダウンロード関数 1 つを 4 画面(銀行取引・消込・前受金・督促の CSV)が共有、それとは別にアップロード 2 経路(銀行 CSV 取込・手入力売掛 CSV 一括取込)。3 コード箇所 → 6 機能です。
- deal では先に 1 箇所が見つかっていました。同じ観測手法を横展開したら、vault と clear で未修正のまま残っていた同型が芋づる式に出てきた、という順序です。
- 逆に、同じフリートの請求書製品(invoice)はこの軸ではクリーンでした。監査ログ CSV エクスポートも本番で 200。つまりこれは「全製品が必ず踏むバグ」ではなく、「共有クライアントを迂回した箇所がある製品だけが踏むバグ」——コードパスの分岐の問題だと裏取りできました。
修正は「2 行」ではなく「構造」で入れた
各 PR に共通するのは、該当箇所にミラーを 1 行足して終わりにしていない点です。
-
deal(PR #85): 認証ヘッダの組み立てを共有ヘルパー(
buildAuthHeaders())に抽出し、JSON 経路とバイナリダウンロード経路の両方が同じヘルパーを使う形に統一。さらに「CSV 経路が同一トークンをAuthorizationとX-Authorizationの両方で送る」ことを MSW で実リクエストヘッダから固定する回帰テストを追加。 -
vault(PR #177): 共有クライアントに認証付きバイナリダウンロード
postBlobを足し、ページ側は返ってきた blob をブラウザ保存するだけに。そのうえで、生 fetch をshared/api/client.ts以外で呼ぶことを ESLint で禁止しました。
// vault/frontend/eslint.config.js — 生 fetch の再侵入を lint で封じる
{
// All network calls must go through the shared API client, which adds
// the Authorization + X-Authorization mirror (#118). A raw fetch drops
// the mirror and 401s behind the shared-hosting proxy (see #173).
selector: "CallExpression[callee.name='fetch']",
message:
'Do not call fetch() directly — use the shared apiClient (shared/api/client.ts) so the Authorization/X-Authorization headers are sent.',
},
これで「うっかり生 fetch で認証ヘッダのミラーを落とす」バグ クラス全体が、CI の lint で止まるようになります。1 箇所の症状ではなく、症状を生む構造(共有クライアントの迂回)を塞いだわけです。
(なお clear では同じセッションの実ブラウザ打鍵で、CSV とは無関係の設定保存が 500になる別バグも見つかりました。こちらは共有クライアント経由なのでヘッダの話とは根因が違い、本番 MySQL の affected-row セマンティクス(値が変わらない no-op な UPDATE が「変更 0 行」を返すのを「行が無い」と誤判定して INSERT に落ち、organization_id の主キー重複で 500)という別レイヤーの問題でした。SQLite は matched を changed として数えるため、ローカルの SQLite テストでは再現しませんでした。curl 全緑はヘッダ以外の観点でも安全を保証しない、という補強例として付記しておきます。)
実ブラウザ打鍵を QA に
今回いちばん効いたのは、テスト手法を「サーバーの応答検証」から「実クライアントの再現」に寄せたことでした。
- curl は API の契約を検証する道具であって、フロントの QA ではない。curl の緑は「このヘッダをこう送れば通る」以上のことを言っていません。
- 実ブラウザ(headless Chromium + Playwright)は、本番環境で SPA が実際に組み立てるリクエストを検証する。プロキシによるヘッダ剥がしも、共有クライアント迂回による付け忘れも、実ブラウザで打って初めて可視化されました。
- 決定的なのは「本番に対して」打鍵したこと。ローカル Docker はプロキシが無いので、実ブラウザでもこの 6 機能は通ってしまいます。環境差(プロキシの有無)とコードパス差(共有クライアント迂回の有無)が両方揃う本番でしか赤にならない以上、再現には本番相当の環境が要ります。
観点を一枚にすると、curl/ローカル/本番実ブラウザは「見えるバグの集合」が違います。
| 検証手段 | プロキシのヘッダ剥がし | 共有クライアント迂回 | このバグを検出 |
|---|---|---|---|
| ローカル curl | 再現しない(プロキシ無し) | 実行しない(手打ちヘッダ) | ✗ |
| 本番 curl(両ヘッダ手付け) | 手で回避 | 実行しない | ✗ |
| ローカル実ブラウザ | 再現しない(プロキシ無し) | 実行するが剥がれない | ✗ |
| 本番 実ブラウザ | 再現する | 実行して剥がれる | ✓ |
curl 全緑のレポートは、この表の下 3 行を見ていなかったわけです。
学び
- 「curl で全緑」は「実ユーザーが壊れていない」ことを意味しない。 curl はリクエスト構築という SPA の主要部分を、自分の手打ちヘッダで置き換えてしまう。API の契約検証としては有効だが、フロントの受け入れ検証には別レイヤーの打鍵が要る。
- 最終確認は、実クライアントで・本番相当の環境に対して。 環境差(プロキシ)とコードパス差(迂回)が重なる所でしか出ないバグは、その両方を揃えないと再現しない。実ブラウザ × 本番の組み合わせが最短でした。
- 同型バグは横展開して探す。 1 製品で「共有クライアント迂回」パターンを見つけたら、他製品にも同じ観点を当てる。今回は deal の 1 件を起点に、vault で 2・clear で 6、計 8 機能ぶんの未修正が芋づるで出ました(deal の起点 1 件は別)。
-
見つけた不変条件は lint で固定する。 「ネットワーク呼び出しは共有クライアント経由のみ」をコメントの規約で終わらせず
no-restricted-syntaxで機械強制すると、同じクラスのバグが CI で二度と通らなくなる。症状ではなく構造を塞ぐと、次の担当者が迂回コードを書いた瞬間に赤で気づけます。
一次資料
- 実ブラウザ全機能打鍵レポート(clear / deal / vault / invoice、2026-07-11)
- deal: Issue #83 → PR #85(監査 CSV export に X-Authorization ミラーを併送)
- vault: Issue #173 → PR #177(export を共有クライアント経由に統一+生 fetch を lint で禁止)
- clear: Issue #312 → PR #313(アップロード・ダウンロード fetch に X-Authorization ミラーを併送)
関連記事
- 品質・QA の連作:「営業スクショ」で一次欠陥を刈り、一覧==PDF の等値オラクルへ昇格させた話(公開準備中)→ 本記事(curl 緑/ブラウザ赤) → 敵対的打鍵で本番を殴る話(執筆中)
- 姉妹編:再発防止は「気をつける」ではなく lint に落とす — 生 fetch を ESLint で禁止(公開準備中)。「不変条件を lint で固定する」学びの続きはこちら。
- 背景:共有レンタルサーバーで Authorization ヘッダが消える(公開済)— 本記事の「ミラーが剥がれる」前提の詳細
── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp