Skip to content

私のAPIは数ヶ月間ずっと無防備だったのに、全く気付かなかった

Harry
Lang
原文 中文
日本語

一、私を突然ハッとさせたその質問

先日、友人とLingoContextのバックエンドアーキテクチャについて話していた時のこと。彼は非常に素朴な質問を投げかけてきました。

「このAIのAPIって、君の拡張機能からしか叩けないんだよね? 他の人が勝手に使ったりはできないんでしょ?」

私は無意識に「もちろん。CORSでoriginを制限してるから、chrome-extension:// からのアクセスしか許可してないよ」と答えました。しかし、言葉の途中で少し後ろめたさを感じ始めました。なぜなら、そのことを自分で実際に検証したことがないと突然気付いたからです。

ターミナルを開き、自分の本番APIに対して一つのコマンドを走らせてみました:

curl -X POST https://lingo-context-api.vercel.app/api/analyze/stream \
  -H 'Content-Type: application/json' \
  -H 'Origin: chrome-extension://gjcgecdgmhbehagblealbdghojkoeakk' \
  -d '{"text":"hello"}'

ストリーミングでJSONが返ってきました。そこにはGeminiからの回答が含まれていました。私はたった今、誰でも書ける一行のcurlコマンドで、自分が自腹を切っているAIサービスを呼び出したのです。 その瞬間、「拡張機能専用のAPI」という私の理解が、最初から完全に間違っていたことに気付きました。この記事は、その勘違いに対する反省と、私がどのようにこれを修正したかの記録です。


二、よくあるが致命的な誤解:「自分の拡張機能にだけAPIを叩かせる」

最初に思いついたアプローチは次のようなものでした:

この考え方は、ブラウザのセキュリティモデルの下では理にかなっています。しかし、APIの乱用を防ぐという目的においては、致命的な欠陥があります:

拡張機能IDは公開されている

Chromeウェブストアを開けば、拡張機能のインストールリンクの中にIDがしっかりと記載されています:

https://chromewebstore.google.com/detail/lingocontext-%E2%80%94-context-aw/gjcgecdgmhbehagblealbdghojkoeakk
																		   ────────────────────────────────
                                                                                       これが拡張機能IDです

拡張機能のコードは公開されている

Chrome拡張機能は .crx ファイルとしてパッケージ化されていますが、本質的にはただのzipファイルです。ダウンロードして解凍すれば、誰でも content.jsbackground.jsmanifest.json を直接読むことができます。したがって、「クライアント内に検証用のシークレットキーを隠す」というアプローチは、最初から存在し得ない道だったのです。

Origin ヘッダーは任意に偽造できる

ブラウザはリクエスト送信時に自動的に Origin を設定しますが、サーバーが受け取るのはただの文字列に過ぎません。curl -H 'Origin: chrome-extension://任意のID' のようにすれば、CORSのホワイトリストチェックを簡単に通過できてしまいます。CORSはユーザーのブラウザが悪意のあるサイトに悪用されるのを防ぐためのものであり、curlを防ぐためのものではありません。

真のセキュリティモデル

これら三つの事実を組み合わせると、結論は明確になります:

「自分の拡張機能にだけAPIを叩かせる」という要件は、技術的に実現不可能である。信頼できるのは常にクライアントではなく、ログインしたユーザーそのものである。

拡張機能はあくまでUI層であり、ユーザーがAPIを操作するための入り口に過ぎません。信頼されるべき主体は、Google OAuthを通じてログインを完了し、セッションCookieを保持している実際のユーザーなのです。

したがって、修正の方向性も明確になりました。コストのかかるすべてのAPIには認証を必須とすること。CORSは残しますが、それは多層防御の一層としてであり、唯一の防御層ではありません。


三、現状監査:どのエンドポイントが無防備だったか

見直すつもりで、すべてのルーティングをスキャンしてみました:

ルーティングログイン必須か備考
/api/words/*ensureAuthenticated単語帳のCRUD
/api/user/*ensureAuthenticatedユーザー設定
/api/analyze無防備Gemini / DeepSeekの呼び出し
/api/analyze/stream無防備Gemini / DeepSeekのストリーミング呼び出し
/api/tts無防備Edge TTS、帯域幅を消費
/api/word-definition無防備クイック翻訳のためのAI呼び出し
/api/furigana❌ オープンローカルのkuromoji、安価
/api/dictionary❌ オープン無料のサードパーティ辞書呼び出し

データベースと単語帳は守られていましたが、実際にコストが発生するAIエンドポイントはすべてオープンになっていました。開発初期に「ユーザーデータ関連」のルーティングに認証を追加したものの、「AIの呼び出し=ユーザーデータ関連」であるという事実を忘れていたのです。攻撃者の視点から見れば、AIエンドポイントこそが最も乱用する動機があるものです。他人の単語帳には興味がなくても、他人のGeminiの枠をタダで使えるなら絶対に使いたいはずです。


四、修正案:三層防御

私は単に「一箇所直して終わり」というアプローチは取らず、この問題を三つの層に分けました:

第一層:認証(最も重要)

コストのかかる四つのルーティングすべてに ensureAuthenticated ミドルウェアを追加しました。有効なセッションCookieを持たないリクエストは、即座に401を返し、ビジネスロジックには一切入りません:

// server/index.js
app.use(
  "/api/analyze",
  ensureAuthenticated,
  aiPerMinute,
  aiPerDay,
  analyzeRoutes
);
app.use(
  "/api/analyze/stream",
  ensureAuthenticated,
  aiPerMinute,
  aiPerDay,
  analyzeStreamRoutes
);
app.use(
  "/api/word-definition",
  ensureAuthenticated,
  aiPerMinute,
  aiPerDay,
  wordDefinitionRoutes
);
app.use("/api/tts", ensureAuthenticated, ttsPerMinute, ttsRoutes);

この層でブロックされるのは、OAuthログインによる身元証明がないすべてのリクエストです。curlでこれを迂回するには、Google OAuthのフローを完全に完了させる必要があります。しかし、OAuthのフローはブラウザ上で人間がGoogleとやり取りすることを要求するため、自動化することはできません。

同時に、クライアント側で識別して表示しやすいように、401のレスポンスボディを構造化しました:

res.status(401).json({
  error: "Unauthorized. Please login.",
  message: "Please sign in via the LingoContext popup to use this feature.",
  code: "AUTH_REQUIRED",
});

以前は { error: '...' } しかなく、フロントエンドには曖昧なメッセージしか表示できませんでした。新バージョンでは code フィールドを追加したため、フロントエンドは AUTH_REQUIRED を検知して「再試行」ボタンではなく「ログイン」ボタンを直接レンダリングできるようになりました。エラーレスポンス自体も立派なUXの一部です。

第二層:ユーザー単位のレートリミット

認証は「身元が不明な人」しか防ぐことができません。しかし、特定のユーザーのセッションが漏洩した場合や、ユーザー自身がスクリプトを書いてAPIを乱用しようとした場合、認証だけでは不十分です。

そこで express-rate-limit を導入しました。キーの設計が非常に重要です:

function keyByUserOrIp(req) {
  if (req.user && req.user.id != null) {
    return `u:${req.user.id}`; // ユーザーIDを優先
  }
  return `ip:${req.ip || "unknown"}`; // 未ログインの場合はIP
}

なぜIPを直接使わないのでしょうか? IPアドレスは不安定だからです。ユーザーがWi-Fiを切り替えたり、VPNを使ったり、オフィスの共有IPを使ったりすると、IPベースのレートリミットは通常のユーザーを誤ってブロックしてしまいます。ユーザーIDこそが「割り当ての単位」の本体です。

具体的な制限:

リミッターウィンドウデフォルト値制御変数
AI API / 分60秒30RATE_LIMIT_AI_PER_MIN
AI API / 日24時間1500RATE_LIMIT_AI_PER_DAY
TTS / 分60秒60RATE_LIMIT_TTS_PER_MIN
公開API / 分60秒60/IPRATE_LIMIT_PUBLIC_PER_MIN

すべてのしきい値は環境変数で制御されています。そのため、もしある日突然DDoS攻撃を受けたり、Geminiのクォータが逼迫したりしても、コードを再デプロイすることなく、環境変数を一つ変更するだけで即座に制限を強めることができます

429レスポンスも構造化されています:

res.status(429).json({
  error: "rate_limited",
  message:
    "You're sending requests too quickly. Please wait a moment and try again.",
  code: "RATE_LIMITED",
  limiter: name,
  retry_after_seconds: retryAfter,
});

Retry-After ヘッダーも付与しているため、クライアントは自動で再試行するかどうかを判断できます。

第三層:CORS + CSRF はそのまま

「CORSはcurlを防げないのだから、設定する意味はない」と主張する記事をよく見かけますが、私はそれに賛同しません。CORSは全く別のシナリオを防ぐためのものです:

ユーザーがブラウザで私の拡張機能と悪意のあるサイトを同時に開いている場合。悪意のあるサイトのJSが、(ユーザーのセッションCookieを持ったまま)こっそりと私のAPIを叩こうとする。

このシナリオでは、Origin はブラウザによって自動的に設定されるため、悪意のあるサイトがそれを偽造することはできません。CORS + CSRFのoriginチェックは、この層で攻撃をブロックしてくれます。

つまり、三層の防御にはそれぞれの役割分担があるのです:

一つとして欠けてはならない層です。


五、最もつまずきやすい部分:既存の機能を壊さないこと

ロックを追加すること自体は難しくありません。難しいのは、既存のユーザー体験を壊さないことです。私はロックを追加すること自体よりも、この部分に多くの時間を費やしました。

ステップ1:クライアントが本当にセッションを送っているか確認する

拡張機能のfetchリクエストに credentials: 'include' が付与されていなければ、ユーザーがログインしていてもCookieは送信されず、ロックを追加した途端に全員が401で弾かれてしまいます。

私は background.js 内のすべてのfetch呼び出しをスキャンしました:

const response = await fetch(`${backendUrl}/analyze/stream`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ... }),
    credentials: 'include'   // ← 4つの呼び出しすべてにこれがあることを確認
});

コストのかかる4つのエンドポイント(analyze/stream、furigana、word-definition、tts)には、すでに credentials: 'include' が設定されていました。これは、ログイン済みのユーザーは、ロックの前後で全く違いを感じないということを意味します。Cookieは自動的に送られ、セッションは自動的に検証されます。

ステップ2:各エンドポイントのフォールバック経路を監査する

拡張機能には、すでに多くのエンドポイントに対して優雅なフォールバック(機能縮退)の経路が書かれていましたが、私がそれに気づいていなかっただけでした。

エンドポイント失敗時の拡張機能の反応ロック後の影響
/api/ttsWeb Speech API (speakWithWebSpeech) に自動フォールバック影響なし — 未ログインユーザーにはブラウザ内蔵のTTSが聞こえるだけ
/api/word-definition静かに空の配列を返し、クイック定義エリアをスキップする影響なし — 単にプラスアルファの機能が一つ減るだけ
/api/furigana静かにnullを返し、クイックふりがなをスキップする影響なし — AI解析が返ってきた後にふりがなはレンダリングされる
/api/analyzeエラーUIを表示する修正が必要 — ユーザーに「Backend Error: 401」を見せてはいけない

つまり、TTS、word-definition、furiganaの3つの経路がロックされたとしても、拡張機能全体の体験が途切れることはないということです。Web Speechへのフォールバックのおかげで、安心してTTSをロックすることができました。もし機能にフォールバック経路がなければ、認証を追加する前に401エラー時のユーザー体験をよく考える必要があります。

ステップ3:analyzeの401を「ログイン」プロンプトに変換する

唯一、ユーザーの目に直接触れる(エラーのポップアップとして表示される)のは、/api/analyze/stream の失敗時です。これには特別な処理が必要です。

background.js は、401の code をコンテンツスクリプトにパススルーします:

if (!response.ok) {
  const error = await response.json().catch(() => ({}));
  port.postMessage({
    error: true,
    status: response.status,
    code: error.code, // ← 新規追加
    message:
      error.message || error.error || `Backend Error: ${response.status}`,
  });
}

content.js のストリームハンドラは code === 'AUTH_REQUIRED' を識別し、通常の「再試行」ではなく、「Sign in」ボタンを備えたエラーインターフェースをレンダリングします:

const isAuth = msg.code === "AUTH_REQUIRED" || msg.status === 401;
popup.innerHTML = renderError(msg.message, { authRequired: isAuth });

renderErrorauthRequired オプションを受け取り、🔒 +「Sign in」を表示するか、⚠️ +「Try Again」を表示するかを決定し、ボタンの data-action もそれに合わせて切り替えます:

const actionAttr = authRequired ? 'data-action="login"' : 'data-action="retry"';
const actionLabel = authRequired ? loginBtnStr : tryAgainStr;
const icon = authRequired ? "🔒" : "⚠️";

そして「Sign in」ボタンのクリックアクションは、拡張機能に以前から存在していた OPEN_LOGIN メッセージフローを再利用します。新しいIPCを追加することも、popup.htmlを変更することも、ダッシュボードを変更することもありませんでした。変更全体は2つのファイル、20行以内で完結しました。

トレードオフ:どのルーティングをロックし、どれをロックしないか

すべてのルーティングをロックすべきではありません。私はそれぞれについて個別に判断しました:

ルーティングロックするか理由
/api/analyze*するGemini/DeepSeekを呼び出し、一回ごとに実際のコストがかかる
/api/word-definitionする同上。最大トークン数は100だが、量が多くなればコストがかかる
/api/ttsするEdge TTSは帯域幅を消費する;また、クライアントにはすでにWeb Speechのフォールバックがある
/api/furiganaしない(レートリミットのみ)ローカルのkuromojiを使用し、外部コストゼロ;未ログイン時にもトリガーされる;IP単位のレートリミットで十分
/api/dictionaryしない(レートリミットのみ)無料のjisho/dictionaryapiを呼び出す;同上

機能自体のコスト構造クライアントにフォールバック経路があるかどうか が、ロックするかどうかを決定する2つの軸です。何も考えずにすべてをロックするのは安全ですが体験を損ないますし、すべてをオープンにしたままにするのは無防備です。


六、検証:テスト+エンドツーエンドのスモークテスト

コードを修正して終わりではありません。検証が必要です:

ユニットテスト

古い authMiddleware.test.js では、401ボディの正確な形状をハードコードしていました:

expect(res.json).toHaveBeenCalledWith({ error: "Unauthorized. Please login." });

これを構造化マッチに変更し、「拡張機能が認証エラーを認識してUIを切り替えられること」を、テストで表現可能な契約に変えました:

expect(res.json).toHaveBeenCalledWith(
  expect.objectContaining({
    error: "Unauthorized. Please login.",
    code: "AUTH_REQUIRED",
    message: expect.stringMatching(/sign in/i),
  })
);

index.test.js に4つの新しいケースを追加し、コストのかかる各ルーティングへの匿名アクセスが 401 + code: 'AUTH_REQUIRED' を返すことをアサートしました。

意外な発見がありました。analyzeRoute.test.jsanalyzeStreamRoute.test.js には、最初からモックのミドルウェアに ensureAuthenticated が組み込まれていたのです。つまり、テストを書いたときの「私」は、これらのルーティングがすでに保護されていると思い込んでいたということです。実際には本番環境のコードのほうが異常値でした。テスト駆動開発によって、実装と意図の不一致が浮き彫りになりました。

最終的に、210/210のサーバーテスト + 13/13のルートテストがすべてパスしました。

6.2 エンドツーエンドのスモークテスト

私は最小限のExpressハーネスを作成し、本番環境と全く同じミドルウェアをマウントし、モック化したpassportで「未ログイン」状態をシミュレートした上で、curlを実行しました:

=== Cost-bearing routes (expect 401)
{"error":"Unauthorized. Please login.","message":"Please sign in via the LingoContext popup to use this feature.","code":"AUTH_REQUIRED"} HTTP 401
{"error":"Unauthorized. Please login.","message":"...","code":"AUTH_REQUIRED"} HTTP 401
{"error":"Unauthorized. Please login.","message":"...","code":"AUTH_REQUIRED"} HTTP 401
{"error":"Unauthorized. Please login.","message":"...","code":"AUTH_REQUIRED"} HTTP 401

=== Open public routes (expect 200)
HTTP 200  POST /api/furigana
HTTP 200  GET  /api/dictionary?word=x

期待通りに完全に一致しました。


七、今回のリファクタリングが残したいくつかの原則

今回の問題は、表面上はCORSへの誤解でしたが、本質的にはセキュリティ意識の補習授業でした。これを抽象化すると、今後繰り返し再利用できるいくつかの原則があると思います。

1. いかなるクライアントも信用しない

コードがユーザーのデバイス上で実行されている限り、それを秘密として扱うことはできません。ブラウザ拡張機能、モバイルアプリ、デスクトップクライアントは、本質的にはただのAPIのエントリーポイントに過ぎず、信頼できる身元そのものではありません。

サーバーが真に信頼できるのは、「このリクエストは私の拡張機能から来たように見える」ということではなく:

このリクエストが認証済みのユーザーから来たものかどうか。

ですから、「特定のクライアントにのみAPIの呼び出しを許可する」という設計をしていることに気づいたら、立ち止まって考え直すのが最善です。これを「特定の種類のユーザーIDにのみAPIの呼び出しを許可する」に変更できないか?と。

2. VibeCodingは確かに生産性を解放するが、セキュリティの死角も拡大する

現在、AIを使ってコードを書き、機能を追加し、エンドポイントを生成するのは非常に速く、生産性は確かに大きく解放されています。しかし、問題もそこにあります:コードを書くのが速ければ速いほど、それが安全であると思い込みやすくなるということです。

特にセキュリティ問題において、多くの場合「機能が使えない」ことではなく、「機能が簡単に乱用されてしまう」ことが問題になります。バグのようにすぐにエラーを吐くわけではないため、専門家以外がこれらの問題を即座に発見するのは非常に困難です。

ですから、AIはコードを書くためだけでなく、セキュリティレビューを行うためにも使うべきだと今は感じています。例えば、デプロイする前に、いくつか異なるAIに聞いてみるのも良いでしょう:

このエンドポイントは迂回される可能性がありますか? もしあなたが攻撃者なら、これをどのように乱用しますか? ここに認証、レートリミット、CORS、CSRFに関する問題はありませんか? どのエンドポイントが実際のコストを発生させる可能性がありますか?

複数のAIに異なる角度からレビューさせることで、絶対的な安全が保証されるわけではありませんが、少なくとも自分では気づかなかった死角を多く浮き彫りにしてくれるはずです。

3. エラーレスポンスもUXである

{ error: 'Unauthorized' }{ error, message, code: 'AUTH_REQUIRED' } では、フロントエンドにとってまったくレベルの違うものです。

前者は単に「エラーが起きた」ことしか伝えられませんが、後者はフロントエンドに「これは認証の問題だから、再試行ボタンではなくログインボタンを表示すべきだ」と明確に知らせることができます。

つまり、エラーレスポンスはただ適当な文字列を返して終わり、というものではありません。それもまた、製品体験の一部なのです。

4. 防御は階層化し、各層の責任を明確にする

CORSは悪意のあるサイトを防ぎ、認証は匿名の呼び出しを防ぎ、レートリミットは身元の乱用を防ぎます。

これら三つの層は、同じ問題を解決しているわけではありません。CORSがcurlを防げないからといってCORSが無用だということにはなりませんし、認証が匿名リクエストをブロックするからといってレートリミットを省略していいことにはなりません。

セキュリティは、単一の「万能な解決策」で解決するものではなく、各層がそれぞれ何を防いでいるのかを正確に把握することで成立するのです。


八、最後に

この一件には少し恥ずかしい思いをしました。私が毎日メンテナンスしているプロジェクトに、数ヶ月間も見て見ぬふりをしていたほど明白な脆弱性があったのですから。しかし、それ以上に反省させられたのは、「私のAPIは私の拡張機能からしか叩けない」という私の信念が、一度も検証されていなかったということです。私はcurlを走らせたことも、攻撃者の立場になったことも、「もし自分がこのAPIを乱用したかったらどうするか」と自分自身に真剣に問いかけたこともありませんでした。

コードを書くことと、コードを守ることは、二つの異なる思考回路です。前者は「それは動くか?」と問い、後者は「それは乱用されるか?」と問います。以前の私は、圧倒的に前者に慣れきっていました。

もしあなたが、バックエンドを持つブラウザ拡張機能、モバイルアプリ、またはデスクトップクライアントを開発しているのなら——どうか10分だけ時間を取って、自分のAPIに対してcurlを走らせてみてください。ログインが必要なはずなのに、実際には無防備に叩けてしまうエンドポイントがないか確認してみてください。きっと、非常に驚くことになるはずです。

Previous
「何を読むか」を外注する:読書システムにマイクロカーネルを作った
Next
面接における自己価値感の低さ:なぜ私は常に過剰準備をしてしまうのか