Laravelセキュリティ実装ガイド:CSRF、XSS、認可、CSPとアクセシビリティ

php elephant sticker
Photo by RealToughCandy.com on Pexels.com

Laravelには、CSRF対策、出力エスケープ、クエリのバインディング、認証、認可などの仕組みが用意されています。しかし、初期機能があるだけでアプリケーション全体の安全が保証されるわけではありません。開発者が防御を無効にしたり、認可を置き忘れたり、運用時の設定を誤ったりすれば、攻撃や情報漏えいにつながります。

扱う範囲は、LaravelでWebアプリケーションを開発するときに確認したい防御です。実装と運用の順に整理します。コード例は考え方を示す最小例です。Laravelはメジャーバージョンによってファイル構成や登録方法が変わるため、採用中のバージョンの公式ドキュメントと照合してから適用してください。

最初に把握したい脅威と防御の対応

個別の設定に入る前に、何を何から守るのかを対応づけます。一つの対策ですべての攻撃を防ぐのではなく、入力、処理、出力、通信、運用の各段階に防御を置きます。

脅威または事故 平易な説明 Laravelでの主な防御
CSRF ログイン中の利用者に、意図しない更新処理を送らせる攻撃 webミドルウェア群、@csrf、SanctumのCSRFフロー
XSS 入力された文字列をブラウザがスクリプトとして実行してしまう問題 Bladeのエスケープ、HTMLサニタイズ、CSP
SQLインジェクション 入力値がSQLの命令として解釈される問題 Eloquent、Query Builder、プレースホルダー
マスアサインメント 想定外の入力項目までモデルへ一括代入する問題 代入可能属性の許可リスト、検証済みデータの明示的な選択
権限逸脱 ログイン済みでも許されていないデータを閲覧または更新する問題 Gate、Policy、認可ミドルウェア
総当たりと濫用 ログインや投稿を短時間に繰り返す行為 Rate Limiter、監視、必要に応じた追加防御
情報漏えい エラー、ログ、ファイル、設定から秘密情報が外へ出る事故 本番設定、非公開ストレージ、ログ項目の制限、暗号化

この対応表は優先順位表ではありません。たとえばCSPを厳しくしても、認可漏れや不適切なファイル公開は防げません。各レイヤーを別々にテストする必要があります。

CSRF対策はフレームワークの既定動作を保つ

CSRF(Cross-Site Request Forgery)は、認証済みのブラウザを利用して、本人が意図しない状態変更を送らせる攻撃です。Laravelのwebミドルウェア群にはCSRF防御が含まれています。通常のBladeフォームでは、状態を変更するリクエストに@csrfを置きます。

@csrf

CSRF対象のルートを安易に除外すると、この防御が届かなくなります。外部サービスから受け取るWebhookなど、ブラウザのセッションを使わない入口は、署名検証や専用の認証を備えた別の境界として設計します。

SPAではSanctumの手順を一式で確認する

同一トップレベルドメインでSanctumのセッション認証を使うSPAでは、/sanctum/csrf-cookieを先に呼び出し、その後のリクエストでXSRF-TOKENクッキーに対応するヘッダーを送ります。CORS、Cookieのドメイン、資格情報付きリクエストの設定も関係するため、ヘッダーを一つ加えるだけでは完成しません。実装時はLaravel SanctumのSPA認証手順を構成全体で確認してください。

SameSite属性はクロスサイト送信を制限する補助策です。アプリケーションの遷移要件に合わせてLaxやStrictを選びますが、CSRF防御や認可の代わりにはなりません。

XSS対策は出力先に合わせる

XSS(Cross-Site Scripting)は、外部から入った値がHTMLやJavaScriptとして解釈され、ブラウザ上で実行される問題です。Bladeの{{ }}はHTML向けにエスケープします。まずはこの既定動作を保ちます。

{{ $user->name }}

HTML本文、属性、URL、JavaScriptでは、安全な出力方法が異なります。「入力時に危険な記号を消したから安全」とは限りません。表示する場所に合ったエスケープを行い、利用者がHTMLを投稿できる機能では、許可する要素と属性を限定するHTMLサニタイザーを使います。CSPは被害を抑える追加防御であり、エスケープやサニタイズの代替ではありません。

CSPはレポートから始める

CSP(Content Security Policy)は、ブラウザが読み込めるスクリプトや画像などの取得元を応答ヘッダーで制限する仕組みです。既存画面へ急に強制すると、必要なスクリプトやスタイルまで止める可能性があります。まずContent-Security-Policy-Report-Onlyで違反を把握し、必要な取得元を整理してから強制ポリシーへ移します。

$policy = "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none';";

$response->headers->set(
    'Content-Security-Policy-Report-Only',
    $policy
);

上の値は導入手順を示す最小例です。画像配信、CDN、外部フォント、決済、解析などの構成を調査せず、そのまま強制ポリシーへ変更しないでください。インラインスクリプトが必要な場合は、無条件のunsafe-inline追加で済ませず、nonceやハッシュを含めて設計します。

データベース防御は入力項目と権限を分ける

SQLには値を埋め込まない

SQLインジェクションは、利用者の入力をSQL文字列へ連結した結果、入力が値ではなく命令として解釈される問題です。EloquentとQuery Builderを基本とし、生SQLが必要な場合もプレースホルダーへ値を渡します。

$user = User::query()->where('email', $email)->first();

列名や並び順はプレースホルダーで値として渡せない場合があります。検索画面のソート項目などは、受け入れる値を許可リストへ対応づけます。

$sortColumns = [
    'newest' => 'created_at',
    'title' => 'title',
];

$column = $sortColumns[$request->string('sort')->toString()] ?? 'created_at';
$query->orderBy($column, 'desc');

マスアサインメントは二段階で絞る

マスアサインメントは、リクエスト配列をそのままcreateやupdateへ渡し、is_adminなど本来変更させない列まで更新してしまう問題です。フォーム検証は「入力として妥当か」を決め、モデルの許可リストは「一括代入してよい属性か」を決めます。役割が違うため、両方を設けます。

$data = $request->validated();

$post = Post::create([
    'title' => $data['title'],
    'body' => $data['body'],
    'status' => $data['status'],
]);

Laravel 13の新規コードでは#[Fillable([...])]属性が公式文書で案内されています。従来のprotected $fillableを使う既存アプリケーションもあるため、プロジェクトのバージョンと規約に合わせます。バージョン選択や運用方針は、関連記事のPHPとLaravelで業務Webアプリを作る前に決めることも参考になります。

認証と認可を同じ判定にしない

認証は「誰が操作しているか」を確認する処理です。認可は「その利用者が対象の操作をしてよいか」を判定する処理です。ログイン済みであることだけを確認して更新を許すと、別の利用者が所有するデータを操作できるおそれがあります。

Policyでリソース単位の権限を表す

class PostPolicy
{
    public function update(User $user, Post $post): bool
    {
        return $user->id === $post->user_id;
    }
}
use Illuminate\Support\Facades\Gate;

Gate::authorize('update', $post);
$post->update($request->validated());

対象の存在を知られたくない場合は404相当として扱う設計もあります。ただし、403と404を場当たり的に混在させると、画面、API、監査ログの意味が揃いません。どの資源を秘匿するかを決め、Policyの応答と例外処理を統一します。

パスワードは復号できる形で保存しない

パスワードにはHash::make()を使います。Hash::needsRehash()は、保存済みハッシュの計算条件を更新すべきか判定するための機能です。再ハッシュには本人が入力した平文が必要になるため、独立したバッチで保存済みハッシュを再計算することはできません。認証処理や利用中の認証パッケージの挙動と合わせて設計します。

ログイン、パスワード再設定、メール確認では、アカウントの存在を応答文から推測されないようにします。一方で、利用者には次に取れる操作を示します。情報を隠すことと、案内を曖昧にすることは同じではありません。

レート制限は対象と識別単位から決める

レート制限は、一定時間内の試行回数を抑える仕組みです。ログインの総当たり、コメント連投、重いAPI処理の濫用などに使えます。ただし、回数を少なくすれば安全になるわけではありません。利用者が共有回線を使う場合、IPアドレスだけで制限すると無関係な利用者まで巻き込む可能性があります。

use Illuminate\Support\Facades\RateLimiter;

$executed = RateLimiter::attempt(
    'send-message:'.$user->id,
    $perMinute = 5,
    function (): void {
        // メッセージ送信処理
    }
);

例の「5回」は製品要件ではありません。操作の危険度、通常の利用頻度、共有IP、再試行手段、監視方法を踏まえて決めます。HTTPルートへ名前付きリミッターを適用する場合は、超過時の429応答や再試行可能な時刻も確認します。短時間の集中を抑えるレート制限と、日次や月次の総量を管理するクォータの違いは、Laravelで利用量制限を設計する方法で詳しく整理しています。

Cookie、CORS、HTTPヘッダーの境界を整える

セッションCookie

  • secure:HTTPS通信でのみCookieを送る。
  • http_only:クライアント側スクリプトからのCookie参照を抑える。
  • same_site:クロスサイト送信の条件を制御する。

本番環境ではHTTPSを前提に設定します。ただし、外部認証や別オリジン構成がある場合、SameSiteを厳しくするだけで正しい設計になるとは限りません。ログインから復帰までの実際の通信で確認します。

CORS

CORS(Cross-Origin Resource Sharing)は、ブラウザが別オリジンの応答をフロントエンドへ渡してよいかを制御する仕組みです。APIの認証や認可を置き換えるものではありません。許可するオリジン、メソッド、ヘッダーを必要な範囲へ絞り、資格情報を送る構成ではワイルドカードに頼らず、想定オリジンからの実通信を試します。

セキュリティヘッダー

  • X-Content-Type-Options: nosniff:宣言したMIMEタイプを尊重させる。
  • Content-Security-Policyのframe-ancestors:埋め込み可能な親ページを制限する。
  • Referrer-Policy:外部遷移時に送る参照元情報を制御する。
  • Permissions-Policy:位置情報やマイクなど、ブラウザ機能の利用範囲を制御する。

HSTSは、以後のアクセスをHTTPSへ限定するようブラウザへ指示します。全サブドメインがHTTPSで動くことを確認せずincludeSubDomainsを加えると、未対応のサブドメインへ接続できなくなる可能性があります。preloadはさらに影響が長く残るため、通常のヘッダー追加とは別の運用判断として扱います。

ミドルウェア登録はLaravelの世代に合わせる

古いLaravelアプリケーションではapp/Http/Kernel.phpへミドルウェアを登録する構成が一般的でした。Laravel 13の新規構成では、グローバルミドルウェアをbootstrap/app.phpで追加します。既存アプリケーションの構成を確認せず、別世代の例へ置き換えないでください。

use App\Http\Middleware\SecurityHeaders;
use Illuminate\Foundation\Configuration\Middleware;

->withMiddleware(function (Middleware $middleware): void {
    $middleware->append(SecurityHeaders::class);
})

ヘッダーをアプリケーションで付けるか、WebサーバーやCDNで付けるかも決めます。複数の層から異なる値を返すと、意図したポリシーを判断しにくくなります。応答ヘッダーを結合テストで確認し、配信経路の最終出力を基準にします。

ファイル、署名付きURL、暗号化を混同しない

アップロードは内容、名前、保存先を確認する

$data = $request->validate([
    'avatar' => [
        'required',
        'file',
        'mimes:jpg,jpeg,png',
        'extensions:jpg,jpeg,png',
        'max:2048',
    ],
]);

$path = $request->file('avatar')
    ->store('avatars/'.auth()->id());

Laravelのmimesルールは、指定した拡張子に対応するMIMEタイプをファイル内容から推測して検証します。利用者が付けた拡張子との一致はextensionsルールの役割です。上限や許可形式は用途ごとに決め、画像として再処理する場合は、その処理系でも読み込めることを確認します。

機密ファイルは公開ディレクトリへ直接置かず、認可を通るダウンロード処理またはストレージの一時URLから配信します。元のファイル名は表示用メタデータとして扱い、保存パスや応答ヘッダーへ無検証で流用しません。

署名は認可の代わりではない

署名付きURLは、URLのパラメーターが発行後に改ざんされていないことや、有効期限内であることを確認できます。しかし、リンクを入手した人がそのファイルを閲覧してよいかまでは判断しません。私有ファイルでは、signedの検証に加えて、ログイン状態とPolicyによる閲覧権限を確認します。

HashとCrypt

目的 選ぶ仕組み 確認点
パスワードの照合 Hash 元の値へ戻さず、入力値との一致だけを調べる
アプリケーションが後で復号する値 Crypt 暗号鍵の保管、ローテーション、復号失敗時の処理を設計する

APP_KEYを変更すると既存の暗号化値やセッションへ影響します。漏えい時に単純な差し替えだけで済ませず、影響範囲、以前の鍵を使う猶予、再暗号化、セッション失効を含めて対応します。

ログはマスクする前に書く項目を減らす

パスワード、セッションクッキー、アクセストークン、認証コード、秘密鍵はログへ書きません。メールアドレスなどの個人情報も、障害調査に必要な項目と保持期間を決めます。構造化ログでは、記録を許可するフィールドを先に定義すると、リクエスト全体や例外コンテキストの流入を抑えやすくなります。

正規表現によるマスキングは補助策です。表記の揺れ、入れ子のデータ、バイナリ、別名のフィールドを取りこぼす可能性があります。「あとで消す」ことを前提にせず、秘密情報をログ関数へ渡さない設計を優先します。

本番ではAPP_DEBUG=falseとし、利用者向け応答には内部のクラス名、SQL、ファイルパス、設定値を出しません。詳細はアクセスを制限した監視基盤へ送り、利用者には受付番号や次の操作を案内します。

二要素認証とエラー表示をアクセシブルにする

二要素認証は、パスワードとは別の要素を使って本人確認を強める仕組みです。Laravel FortifyはTOTPとリカバリーコードを扱えます。導入時は、有効化、確認、無効化、端末紛失、リカバリーコード再発行までを一つの利用者フローとしてテストします。

確認コードを一文字ずつ別欄へ分けるUIは、フォーカスの自動移動や貼り付けで問題を起こすことがあります。単一の入力欄は実装を簡潔にしやすく、コピーと貼り付けも保ちやすい選択です。ただし、単一欄にしただけで適合が決まるわけではありません。ラベル、説明、エラーとの関連、キーボード操作、スクリーンリーダーで確認します。

  • autocomplete='one-time-code'を指定し、OSやブラウザの入力支援を妨げない。
  • inputmode='numeric'で数字入力を補助する。
  • aria-invalidとaria-describedbyで、入力欄、説明、エラーを関連づける。

inputmode='numeric'は入力補助であり、サーバー側の検証ではありません。桁数、有効期限、試行回数はサーバー側で判定します。WCAG 2.2のアクセシブル認証では、記憶や転記の負担を減らす仕組みとして、パスワードマネージャーやコピーと貼り付けを妨げないことが例示されています。詳しくはWCAG 2.2のアクセシブル認証の実装方法を参照してください。

エラーは文字と関連づけで伝える

  • どの項目に問題があるかをテキストで示す。
  • 安全性を損なわない範囲で、修正方法を示す。
  • aria-invalidで無効な入力欄を示し、aria-describedbyで説明とエラーへ関連づける。
  • 動的に追加される緊急度の高いエラーではrole='alert'を検討する。ページ読み込み時からある文章へ一律に付けない。
  • 色だけに頼らず、文字、枠線、アイコンなどを組み合わせる。

Webアクセシビリティを設計、実装、運用へ組み込む手順は、Webアクセシビリティの実務チェックポイントでも解説しています。

テストで防御が届いていることを確かめる

設定ファイルを見ただけでは、ルート、CDN、例外処理、JavaScriptを通った最終挙動は確認できません。攻撃を模した入力だけでなく、正しい利用者が操作を続けられることもテストします。

対象 確認例 失敗時に調べる場所
CSRF 防御情報のない状態変更リクエストが拒否される ルートのミドルウェア群、除外設定、SPAのCookie設定
認可 他人のレコードを閲覧、更新、削除できない Policy、ルートモデルバインディング、管理者例外
レート制限 上限後に429となり、再試行方法が伝わる 識別キー、キャッシュ、プロキシ経由のIP
署名付きURL 改ざんまたは期限切れのURLが拒否される 署名ミドルウェア、時刻、認可判定
アップロード 偽装した形式、過大なファイル、未許可拡張子が拒否される 検証ルール、保存先、配信時のContent-Type
アクセシビリティ キーボードだけで完了でき、エラーと状態変化を把握できる フォーカス順、ラベル、説明関連、ライブリージョン

HTTPステータスはアプリケーションの例外処理やLaravelの世代で変わる場合があります。テストでは数字だけを固定せず、「防御が働いた結果として期待する応答か」「利用者へ秘密情報を出していないか」も検証します。

リリース前後のチェックリスト

入力とデータ

  • 状態変更フォームにCSRF防御が届いている。
  • Eloquentまたはバインディングを使い、入力値をSQLへ連結していない。
  • 検証済みデータと一括代入可能な属性を別々に絞っている。
  • ファイルの内容、拡張子、サイズ、保存先、配信権限を確認している。

認証と認可

  • 認証後のすべての資源操作に認可判定がある。
  • ログイン、再設定、二要素認証にレート制限と回復手段がある。
  • リカバリーコードの表示、再発行、端末紛失時の手順を確認している。
  • パスワードマネージャー、貼り付け、キーボード操作を妨げていない。

HTTPと画面

  • CORSの許可範囲とCookie設定を実環境のオリジンで確認している。
  • CSPはレポートを確認してから強制し、配信経路の最終ヘッダーをテストしている。
  • エラーは文字で説明し、入力欄とプログラム上も関連づいている。
  • HSTSの対象ドメインと復旧手順を確認している。

運用

  • 本番のAPP_DEBUGがfalseである。
  • composer auditなどで依存パッケージの勧告を継続確認する。
  • 秘密情報をログへ渡さず、閲覧権限と保持期間を管理している。
  • バックアップからの復旧、鍵のローテーション、セッション失効を手順として試している。

Laravelセキュリティを実装へ落とす順序

最初にLaravelの既定防御を無効にしていないかを確認し、次に入力、出力、認証、認可、ファイル配信の境界を点検します。その後、CSPやHSTSなど影響範囲の広い設定を段階的に導入し、結合テストとアクセシビリティ検証で最終挙動を確かめます。

安全な画面とは、攻撃を拒否するだけの画面ではありません。正しい利用者がエラーの理由と次の操作を理解でき、補助技術やキーボードでも認証と復旧を完了できることが必要です。防御と使いやすさを同じ受け入れ条件にすると、リリース後の運用でも判断がぶれにくくなります。

この記事に関連する株式会社greedenの取り組み

Laravelの安全設計は、認証や権限だけでなく、テストと保守を含む開発全体で整える必要があります。株式会社greedenは、要件定義から実装、テスト、保守改善まで一気通貫でWebシステム開発を支援しています。

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)