0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

curlでは全緑、実ブラウザで6機能が401停止|API打鍵が見ないSPAの層

0
Last updated at Posted at 2026-07-17

はじめに

自己ホスト型のアプリを共有レンタルサーバーに置き、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 打鍵メモには、既知のプロキシ挙動を踏まえて AuthorizationX-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 経路が同一トークンを AuthorizationX-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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?