お問い合わせフォームに名前と本文を入力し、送信ボタンを押す。画面には「送信しました」と表示され、管理画面にも内容が届く。ここまで確認できれば、機能としては完成したように見えます。

しかし、サーバーが受け取るリクエストは、その画面から送られたものだけではありません。文字数制限を書き換えたリクエスト、他人のIDを指定したリクエスト、何千回も繰り返される送信も、同じ入口へ届きます。画面を普通に操作して成功したことと、想定外の使われ方を安全に拒否できることは、別の確認です。

セキュリティの土台になるのは、「どこから値が入り、何を読み書きし、誰に結果を返すのか」を追うことです。攻撃名をたくさん覚えるより、この流れの途中で信用してよいものと、まだ信用してはいけないものを見分けるほうが役に立ちます。

守るもの、入口、許す操作を先に決める

同じお問い合わせフォームでも、守る対象は一つではありません。送信者のメールアドレス、問い合わせ本文、管理者だけが読める返信履歴、メール送信サービスのAPIキー、そして大量送信によって消費される料金があります。

外部から値が入る場所も、画面上の入力欄だけではありません。

  • フォームの入力値
  • URLに含まれるIDや検索条件
  • APIへ送られるJSON
  • CookieやAuthorization header
  • アップロードされたファイル
  • 外部サービスから届くWebhook

次に、「誰が、どの操作をしてよいか」を具体的にします。未ログインの人は問い合わせを送れる。送信者は自分の問い合わせだけを見られる。担当者は返信できる。管理者は担当者を変更できる。この区別が曖昧だと、実装側も「ログインしていれば許可」のような広すぎる判定になりがちです。

守るもの、入口、操作する人の組み合わせを考える作業は、threat modelingと呼ばれます。大がかりな図を用意しなくても、機能ごとに次の三点が答えられれば、確認すべき場所が見えてきます。

  1. 漏れたり書き換えられたりすると困るものは何か
  2. そのものへ到達できる入口はどこか
  3. 読む、作る、変更する、削除する権限を誰に与えるか

「お問い合わせを送る」と「管理画面で読む」は別の操作です。ひとまとめに「フォーム機能」と考えず、操作ごとに境界を置くと、必要な検証と権限がはっきりします。

画面の制限とサーバーの検証は役割が違う

HTMLのrequiredmaxlengthは、入力漏れをその場で知らせたり、長すぎる文章を誤って入れないようにしたりするために役立ちます。

<input name="title" required maxlength="100">

ただし、この制限はブラウザ上にあります。開発者ツールでHTMLを書き換えることも、画面を通さずAPIへ直接リクエストを送ることもできます。そのため、サーバーは受け取った値をあらためて検証します。

たとえば記事を保存するAPIなら、少なくとも次のような条件が必要です。

  • titleは文字列で、空ではなく、100文字以内である
  • statusdraftまたはpublishedのどちらかである
  • categoryIdは実在し、その利用者が選べるカテゴリーを指している
  • 日付は解釈可能で、許可した範囲に収まっている

型が合っているだけでは十分ではありません。categoryIdが文字列でも、そのカテゴリーが存在しなければ保存できません。存在していても、別の組織に属するカテゴリーなら使わせてはいけません。形式の検証、データとの照合、権限の確認は、それぞれ別の仕事です。

検証に失敗したときの動作も重要です。無理に値を補って保存すると、利用者は成功したと思い、壊れたデータだけが残ることがあります。どの項目が受け付けられなかったかを利用者へ返し、サーバー側には調査に必要なエラー分類を残します。ただし、SQL全文や接続情報のような内部情報はレスポンスへ出しません。

通常の画面操作ではHTMLの入力制限を通り、直接送信された不正な値はAPI側の検証で保存前に拒否される流れ

値を使う場所によって危険は変わる

入力検証を一度通せば、その値をどこで使っても安全になるわけではありません。同じ文字列でも、HTMLとして表示するのか、SQLの条件に使うのか、ファイル名に使うのかで必要な扱いが変わります。

画面へ表示するときは、文字列とHTMLを区別する

Cross-Site Scripting、略してXSSは、投稿やプロフィールなどに入った内容が、別の利用者のブラウザでJavaScriptとして実行される問題です。Reactは通常、文字列を画面へ表示するときに危険な記号をエスケープします。その保護を外す代表的な箇所がdangerouslySetInnerHTMLです。

<div dangerouslySetInnerHTML={{ __html: article.body }} />

ブログ本文のようにHTMLを表示する必要がある場合は、信頼できる方法でsanitizeした結果だけを渡します。<script>という文字を削除するだけでは足りません。イベント属性、危険なURL、SVGなど、スクリプトが動く経路は複数あるからです。

URLをリンクへ入れる場合も、単なる文字列表示とは条件が違います。https:だけを許すのか、同じサイト内の相対URLも許すのかを決めます。「画面へ出す直前に、その出力先に合う方法で安全に扱う」という考え方が必要です。

SQLやコマンドへ入れるときは、命令と値を分ける

利用者の入力をSQL文字列へ直接つなぐと、値として受け取るはずだった文字列が、SQLの一部として解釈されることがあります。

// 入力値をSQL文字列へ直接埋め込んでいる
const query = `SELECT * FROM users WHERE email = '${email}'`

placeholderやORMの安全なAPIを使い、SQLという命令と、検索に使う値を分けます。

const user = await db
  .prepare("SELECT * FROM users WHERE email = ?1")
  .bind(email)
  .first()

同じ注意は、OSのコマンドにも当てはまります。ファイル変換などでexecspawnを使い、そこへ利用者の入力を文字列結合していると、想定外のコマンドとして解釈されるおそれがあります。コマンドを呼ばずに済むAPIを選ぶ、引数を配列で渡す、許可する値を限定するなど、入力が命令にならない形にします。

ファイルパスも同様です。利用者が送った../../secretのような名前をそのまま保存先へつなぐと、予定していない場所へ到達する可能性があります。外部から来た名前を保存先のパスとして信用せず、アプリ側で保存用のIDを作ります。

ログイン後にも、操作ごとの権限を確認する

認証は「誰なのか」を確かめる処理です。認可は「その人が、この操作をしてよいか」を確かめる処理です。ログインに成功しても、すべてのデータを読んだり変更したりしてよいことにはなりません。

UI、API、データベース、秘密情報とログの各層で、入力検証、認証認可、最小権限、Secrets非表示を重ねる多層防御の図

たとえばPATCH /api/articles/123で記事を更新するとします。画面では自分の記事にだけ編集ボタンを表示していても、URLの123を別の記事IDへ変えてAPIを直接呼ぶことはできます。API側では、現在の利用者がその記事の所有者か、編集を許された担当者かを確認しなければなりません。

リクエストのbodyにuserIdが含まれていても、それを本人確認には使えません。送信者が自由に書き換えられるからです。現在の利用者は、検証済みのsessionやtokenから特定します。そのうえで、対象データとの関係を確認します。

管理者機能も、画面を隠すだけでは守れません。管理画面へのURLを知っている一般利用者がAPIを呼んでも拒否される必要があります。逆に、管理者用APIがすべての操作を一つの強い鍵で行っているなら、鍵が漏れたときの影響が大きくなります。

アプリが使うデータベース、ストレージ、外部APIの認証情報にも、必要な操作だけを許します。画像を読む処理へ削除権限を渡さない、特定の環境でしか使わないTokenを別環境へ置かない、といった絞り込みが最小権限です。画面、API、データベース、外部サービスの複数の層で確認することで、一つの設定ミスがそのまま情報漏えいになるのを防ぎます。

ログイン状態を保つsessionやtokenにも、有効期限と無効化の方法が必要です。Cookieで持つなら、JavaScriptから読む必要がない認証情報にはHttpOnlyを付け、HTTPSだけで送るSecureやCSRF対策に関わるSameSiteを用途に合わせます。ログアウト後やパスワード変更後にも古いsessionが長く使い続けられると、端末を失くしたときやtokenが漏れたときの影響が残ります。

認証エラーを直すために、有効期限の確認や署名の検証を外すのは危険です。開発環境だけの一時的な回避であっても、その分岐が本番で有効にならない条件を明確にし、回避を残さずに直します。

ブラウザの仕組みを権限の代わりにしない

Cookieは、条件に合うリクエストへブラウザが自動的に付けます。この便利な動作を利用し、ログイン中の利用者が意図していない操作を別サイトから送らせる問題がCross-Site Request Forgery、CSRFです。

対策には、CookieのSameSite設定、CSRF Token、OriginやRefererの確認などがあります。認証方式によって必要な組み合わせは変わりますが、データを変更する操作をGETに置かないことは基本です。

避ける例: GET /api/delete-account

リンクを開いただけでアカウントが削除される設計では、本人が削除ボタンを押したかどうかを区別しにくくなります。POSTやDELETEへ変えるだけですべて解決するわけではありませんが、少なくとも「表示のためのアクセス」と「状態を変える操作」を分けられます。

CORSは、別のoriginから届いたレスポンスを、ブラウザ上のJavaScriptへ渡してよいかを制御する仕組みです。特定のWebサイトだけを許可しても、curlや別のサーバーからAPIを呼ぶことはできます。したがって、CORSは認証や認可の代わりになりません。

エラーを消すためにAccess-Control-Allow-Origin: *を加えると、意図していないサイトからもレスポンスを読めるようになる場合があります。公開APIなら適切なこともありますが、Cookieや非公開データを扱うなら、許可するorigin、method、headerを実際の利用元に合わせます。

Webhookは、送信元を確かめてから処理する

決済、メール、GitHubなどの外部サービスは、出来事をWebhookとしてアプリのAPIへ送ることがあります。URLを知っていれば誰でもリクエスト自体は送れるため、「外部サービス専用のURLだから本物」とは判断できません。

多くのWebhookでは、送信元がbodyへ署名を付けます。サーバー側で署名を検証し、検証が終わる前にJSONを作り直したり、データベースを更新したりしないようにします。署名の計算に元のbodyが必要なサービスもあるためです。

正しいWebhookが、通信の都合で複数回届くこともあります。イベントIDを記録し、同じ決済完了通知が二度届いても注文や請求を二重に作らないようにします。古い通知の再送をどこまで受け付けるか、失敗時に外部サービスが何回再試行するかも確認します。

Webhookのsecretは、利用者のブラウザへ渡す値ではありません。受信するサーバーだけに置き、PreviewとProductionで送信先とsecretを分けます。

Webhookの元のbodyと署名を検証し、イベントIDの重複を確認してからデータを一度だけ更新する流れ

ファイルと大量アクセスは、処理量まで入力になる

プロフィール画像のアップロードでは、ファイル名だけでなく、ファイルそのものと処理に必要な資源が外部から入ってきます。拡張子が.jpgでも、中身が画像とは限りません。Content-Type headerも送信側で変更できます。

アップロード時には、少なくとも次を決めます。

  • 許可する形式と、実際の内容をどう確認するか
  • 一ファイルと利用者全体の容量上限
  • 画像の縦横サイズや展開後の大きさ
  • アプリ側で付ける保存名
  • 読み取り、上書き、削除を許す人
  • 問題のあるファイルを隔離・削除する方法

利用者が送ったファイル名は、表示用の情報として別に保存できますが、保存先のパスには使わないほうが安全です。アプリ側でランダムなIDを発行し、実行環境の一時フォルダーではなく、権限と容量を管理できるストレージへ置きます。公開するときも、ブラウザが内容を誤解しないよう正しいContent-Typeを返します。

送信されたファイルをサーバーで形式、容量、画像サイズまで検査し、アプリが発行した保存名で権限付きストレージへ置く流れ

一件ずつなら正しい操作でも、短時間に何千件も届けば、CPU、データベース、ストレージ、外部APIの料金を使い切ることがあります。特に、LLM、画像生成、メール送信、重い検索、ファイル変換、ログイン試行、パスワード再設定は、回数の上限が必要です。

rate limitは、単にIPアドレスごとに一律で止めればよいとは限りません。会社や学校の共有回線では、多くの利用者が同じIPに見えることがあります。ログイン済みの利用者ID、APIキー、機能の種類などを組み合わせ、どの単位で何回まで許すかを決めます。上限に達したときは、拒否したことと再試行できる時期を返し、その大量アクセス自体も監視できるようにします。

CAPTCHAやメール確認は、自動化を難しくする補助になります。ただし、認可や料金上限の代わりにはなりません。突破された場合にも被害が広がらない上限をサーバー側へ置きます。

Secretsと依存パッケージは実行環境の一部

APIキーやデータベース接続情報は、ソースコードへ直接書かず、環境ごとのSecretsとして管理します。ただし、Secretsの画面へ登録しただけでは安全になりません。コードがその値をどこへ渡すかまで確認します。

  • ブラウザへ送られるJavaScriptに含まれていないか
  • APIレスポンスへ返していないか
  • console.logやエラー追跡サービスへ記録していないか
  • GitHub Actionsのコマンドやログへ展開されていないか
  • PreviewとProductionで同じ強い鍵を共有していないか
  • 使わなくなった鍵が有効なまま残っていないか

Next.jsでNEXT_PUBLIC_から始まる環境変数は、ブラウザ側のコードから参照できる形になります。名前にSECRETと付けていても、NEXT_PUBLIC_が付けば秘密にはできません。

秘密情報を誤ってcommitした場合、あとから行を削除するだけでは不十分です。Gitの履歴、CIのログ、すでに取得されたcloneに残っている可能性があるため、漏れたものとして失効させ、新しい鍵へ交換します。新しい鍵へ切り替わったことを確かめてから、古い鍵を無効にします。

npmで追加したパッケージも、アプリや開発環境で実行されるコードです。importした機能だけでなく、インストール時にscriptが動くこともあります。似た名前の別パッケージ、更新が止まったパッケージ、必要以上に多くの依存を連れてくるパッケージを入れると、確認範囲が広がります。

新しい依存を追加するときは、正確な配布元、用途、保守状況、権限、依存数、ライセンスを見ます。npm auditの件数が0でも、未報告の問題や悪意ある更新、危険な使い方までは検出できません。パッケージを信頼する判断と、アプリ内で安全に使う設計の両方が必要です。

安全性は、許可と拒否の両方で確かめる

HTTPSやsecurity headerは、ブラウザとサーバーの間、そしてブラウザ内での被害を抑える層です。HTTPSは通信を暗号化し、接続先の確認に使われます。Content Security Policyは、読み込めるscriptや画像などを制限します。X-Content-Type-Optionsやframe関連の設定も、ブラウザの解釈や埋め込みを制御します。

設定値が存在するだけでなく、実際のレスポンスに付いていることを確認します。Content Security Policyの警告を消すために、すべてのscriptを許す設定へ広げてしまえば、置いた意味が薄れます。使っている外部script、font、画像を把握し、必要な接続先だけを許します。

動作確認では、成功する操作だけでなく、拒否されるべき操作も試します。

  1. 未ログインで非公開データを要求する
  2. 一般利用者で管理者用APIを呼ぶ
  3. 自分のデータを更新できることを確認する
  4. URLのIDだけを他人のものへ変える
  5. 画面の文字数制限を超えてAPIへ直接送る
  6. 同じリクエストを短時間に繰り返す
  7. エラーレスポンスとログに秘密情報がないか確認する

拒否されたときは、単に画面がエラーになるだけでなく、データが変更されていないことも確かめます。サーバーが403を返していても、その前に更新処理が走っていたら安全ではありません。

変更後の確認をエージェントへ任せる場合は、成功条件と禁止条件を同時に渡すと、確認内容が具体的になります。

この機能に入る外部入力と、読み書きするデータを列挙してください。形式の検証、データとの照合、権限確認を行う場所をそれぞれ示してください。

画面の制限を外してAPIを直接呼び、未ログイン、一般利用者、所有者、管理者で結果を確認してください。ボタンの非表示やCORSは権限確認として数えないでください。

この変更で追加・参照するSecrets、外部API、パッケージを列挙してください。ブラウザ、レスポンス、ログ、Gitの差分へ秘密情報が出ないことと、権限・回数・料金の上限を確認してください。

許可される操作と拒否される操作のテストを追加してください。拒否後にデータが変わっていないことまで検証してください。

通常の入力で成功する。書き換えた入力は、データへ届く前に拒否される。別の利用者の操作は権限確認で止まり、秘密情報はレスポンスにもログにも現れない。どの条件をどの場所で止めたかが追えると、セキュリティは漠然とした不安ではなく、確認できる仕様になります。