Laravelセキュリティのベストプラクティス
Laravelアプリケーションを一般的な脆弱性から保護するための包括的なセキュリティガイドライン。
アクティベーションのタイミング
- Laravelの認証と認可(Sanctum、Passport、Jetstream、Breeze)のセットアップ
- ユーザーロール、権限、ポリシーの実装
- 本番環境のセキュリティ設定と環境変数の構成
- Laravelアプリケーションの脆弱性レビュー
- Laravelアプリケーションの本番環境へのデプロイ
- 安全なEloquentクエリとマイグレーションの作成
本番環境の構成
重要な本番環境設定
// config/app.php
'env' => env('APP_ENV', 'production'),
'debug' => (bool) env('APP_DEBUG', false), // 重要: 本番環境では絶対にtrueにしない
'key' => env('APP_KEY'), // 必須: php artisan key:generate
// config/session.php
'secure' => env('SESSION_SECURE_COOKIE', true),
'http_only' => true,
'same_site' => 'lax',
// 起動時にAPP_KEYが設定されているか確認
// bootstrap/app.php またはサービスプロバイダ
if (empty(config('app.key'))) {
throw new RuntimeException('APP_KEYが設定されていません。実行: php artisan key:generate');
}
環境ファイルのセキュリティ
# .env をバージョン管理にコミットしてはいけません
# .gitignore にはデフォルトで .env が含まれています
# 代わりに.env.exampleをプレースホルダー付きで使用
DB_PASSWORD=
APP_KEY=
SANCTUM_TOKEN_PREFIX=
# 起動時に必要な変数を検証
// AppServiceProvider::boot() 内
$requiredKeys = ['app.key', 'database.connections.mysql.database', 'database.connections.mysql.username'];
foreach ($requiredKeys as $key) {
if (empty(config($key))) {
throw new RuntimeException("必須の設定キーが不足しています: {$key}");
}
}
HTTPSの強制
// AppServiceProvider::boot() またはミドルウェア
if (app()->environment('production')) {
URL::forceScheme('https');
request()->server->set('HTTPS', 'on');
}
// config/app.php の信頼できるプロキシ(ロードバランサー)
// 特定のIP範囲を使用 — * はすべてを信頼し、X-Forwarded-*のなりすましを許します
// AWS: '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'
'trusted_proxies' => ['10.0.0.0/8', '172.16.0.0/12'],
// ミドルウェアを介して本番環境でHTTPSを強制
// app/Http/Middleware/ForceHttps.php
public function handle($request, Closure $next)
{
if (!$request->secure() && app()->environment('production')) {
return redirect()->secure($request->getRequestUri());
}
return $next($request);
}
認証
Sanctum(APIトークン認証)
// config/sanctum.php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
env('APP_URL') ? ',' . parse_url(env('APP_URL'), PHP_URL_HOST) : ''
)));
'expiration' => 60 * 24, // トークンの有効期限(分単位、null = 無期限)
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
// アビリティを持つトークンの発行
$token = $user->createToken('api-token', ['read', 'write'])->plainTextToken;
// ルートでのアビリティ検証
Route::middleware('auth:sanctum')->group(function () {
Route::get('/orders', function () {
// ユーザーは'read'アビリティを持っている必要があります
abort_unless(Auth::user()->tokenCan('read'), 403);
// ...
})->middleware('abilities:read');
Route::post('/orders', function () {
// ユーザーは'write'アビリティを持っている必要があります
abort_unless(Auth::user()->tokenCan('write'), 403);
// ...
})->middleware('abilities:write');
});
パスワードセキュリティ
// config/hashing.php
// デフォルトはbcrypt。Argon2idの方が強力です。
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 12), // より強力なハッシュにするために増やす
],
'argon' => [
'memory' => 65536,
'threads' => 4,
'time' => 4,
],
// RegisterRequestでのパスワードバリデーション
public function rules(): array
{
return [
'password' => [
'required',
'confirmed',
Password::min(12)
->letters()
->mixedCase()
->numbers()
->symbols()
->uncompromised(), // haveibeenpwnedをチェック
],
];
}
// ログイン試行のレート制限
// App\Http\Controllers\Auth\AuthenticatedSessionController
protected function authenticated(Request $request, $user)
{
if ($user->wasRecentlyLockedOut()) {
// 不審なログインをユーザーに通知
$user->notify(new SuspiciousLoginNotification($request->ip()));
}
}
セッション管理
// config/session.php
'driver' => env('SESSION_DRIVER', 'database'), // database/redis > file
'lifetime' => env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
'encrypt' => env('SESSION_ENCRYPT', false),
// ログイン時にセッションを再生成
// App\Http\Controllers\Auth\AuthenticatedSessionController
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate(); // 重要: セッション固定化を防ぎます
return redirect()->intended(RouteServiceProvider::HOME);
}
// ログアウト時にセッションを無効化
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
認可
ゲート
// App\Providers\AuthServiceProvider
use App\Models\Post;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
public function boot(): void
{
Gate::define('update-post', function (User $user, Post $post): bool {
return $user->id === $post->user_id;
});
Gate::define('publish-post', function (User $user): bool {
return $user->role === 'editor' || $user->role === 'admin';
});
// before()を使用したスーパー管理者のオーバーライド
Gate::before(function (User $user, string $ability): ?bool {
if ($user->role === 'super-admin') {
return true; // すべてのアビリティを付与
}
return null; // 通常のチェックにフォールスルー
});
}
// コントローラーでの使用
public function update(Request $request, Post $post): RedirectResponse
{
Gate::authorize('update-post', $post);
// または: $this->authorize('update-post', $post);
// または: abort_unless(Auth::user()->can('update-post', $post), 403);
// ...
}
ポリシー
// App\Policies\PostPolicy
class PostPolicy
{
use HandlesAuthorization;
public function viewAny(?User $user): bool
{
return true; // 公開リスト
}
public function view(?User $user, Post $post): bool
{
return $post->is_published || ($user && $user->id === $post->user_id);
}
public function create(User $user): bool
{
return $user->hasVerifiedEmail(); // 最初にメールを確認する必要があります
}
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
public function delete(User $user, Post $post): bool
{
return $user->id === $post->user_id && $post->created_at->diffInDays(now()) <= 30;
}
public function restore(User $user, Post $post): bool
{
return $user->role === 'admin';
}
public function forceDelete(User $user, Post $post): bool
{
return $user->role === 'super-admin';
}
}
// AuthServiceProviderに登録
protected $policies = [
Post::class => PostPolicy::class,
];
// コントローラーでの使用
public function show(Post $post): View
{
$this->authorize('view', $post);
return view('posts.show', compact('post'));
}
// Bladeでの使用
@can('update', $post)
<a href="{{ route('posts.edit', $post) }}">編集</a>
@endcan
@cannot('update', $post)
<span>この投稿は編集できません</span>
@endcannot
ミドルウェアによる認可
// ルートでのミドルウェアの使用
Route::put('/posts/{post}', [PostController::class, 'update'])
->middleware('can:update,post');
Route::get('/posts/create', [PostController::class, 'create'])
->middleware('can:create,App\Models\Post');
// カスタム認可ミドルウェア
// app/Http/Middleware/CheckRole.php
class CheckRole
{
public function handle(Request $request, Closure $next, string $role): mixed
{
if (!$request->user() || $request->user()->role !== $role) {
abort(403, 'Unauthorized. This area requires role: ' . $role);
}
return $next($request);
}
}
// Kernelに登録
protected $routeMiddleware = [
'role' => \App\Http\Middleware\CheckRole::class,
];
// ルートでの使用
Route::middleware(['auth', 'role:admin'])->group(function () {
Route::get('/admin', [AdminController::class, 'index']);
});
Eloquentセキュリティ
マスアサインメント保護
// 悪い例: $guarded = [] はすべてのカラムへのマスアサインメントを許可します
// 本番環境では決して $guarded = [] を使用しないでください
// 良い例: フィラブル属性をホワイトリスト化
final class User extends Authenticatable
{
protected $fillable = [
'name',
'email',
'phone',
'avatar',
];
// 'role', 'is_admin', 'is_verified' は絶対にここに追加しないでください
}
// 良い例: リクエストでどのフィールドを入力できるかを明示的に制御
public function store(StoreUserRequest $request): RedirectResponse
{
$user = User::create($request->safe()->only([
'name', 'email', 'phone', 'avatar'
]));
// $request->safe() は検証済みデータのみを使用します
// $request->only() は検証ルールなしでは安全ではありません
}
// 悪い例: リクエストデータで直接ユーザーを作成
User::create($request->all()); // マスアサインメントに対して脆弱!
// より良い例: 作成にDTOを使用
$user = User::create($request->validated()); // 検証済みフィールドのみ
SQLインジェクション防止
// 良い例: Eloquentは自動的にパラメータ化クエリを実行します
User::where('email', $userInput)->first();
User::whereRaw('email = ?', [$userInput])->first();
// 良い例: クエリビルダーもパラメータ化します
DB::table('users')->where('email', $userInput)->first();
DB::select('SELECT * FROM users WHERE email = ?', [$userInput]);
// 悪い例: 生の文字列補間
DB::select("SELECT * FROM users WHERE email = '{$userInput}'"); // 脆弱!
User::whereRaw("email = '{$userInput}'")->first(); // 脆弱!
// 悪い例: エスケープされていない入力での whereRaw/orderByRaw
User::orderByRaw($userInput); // 脆弱!
User::groupByRaw($userInput); // 脆弱!
// 悪い例: 連結での DB::statement
DB::statement("INSERT INTO users (email) VALUES ('{$userInput}')"); // 脆弱!
属性キャスト
final class User extends Authenticatable
{
protected $casts = [
'email_verified_at' => 'datetime',
'is_admin' => 'boolean', // booleanにキャストすると文字列インジェクションを防ぎます
'settings' => 'array', // 自動的にjson_encode/json_decode
'metadata' => 'encrypted:array', // Laravel 11+ 暗号化キャスト
'password' => 'hashed', // Laravel 10+ 設定時に自動ハッシュ化
];
}
モデルセキュリティ
final class User extends Authenticatable
{
// 機密属性をJSON/APIレスポンスから隠す
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
];
// 安全な計算済み属性のみ追加
protected $appends = ['full_name']; // 安全
// 機密の計算済みデータは絶対に追加しない
}
final class Post extends Model
{
// ソフトデリートされたレコードをフィルタリングするグローバルスコープ
use SoftDeletes;
// 遅延読み込みを制限してN+1を防ぐ(オプションの厳格モード)
// AppServiceProvider::boot()
// Model::preventLazyLoading(!app()->isProduction());
}
CSRF対策
デフォルトの保護
// Laravel CSRFはVerifyCsrfTokenミドルウェアによってデフォルトで有効
// app/Http/Kernel.php (protected $middlewareGroups['web'])
// すべてのPOST/PUT/PATCH/DELETEフォームには @csrf を含める必要があります
<form method="POST" action="/posts">
@csrf
<input type="text" name="title">
<button type="submit">作成</button>
</form>
ルートの除外(慎重に)
// app/Http/Middleware/VerifyCsrfToken.php
class VerifyCsrfToken extends Middleware
{
// 外部のCSRF対策があるルートのみ除外(webhookなど)
protected $except = [
'stripe/*', // Stripe webhookは独自の署名検証を使用
// 包括的な 'api/*' は避けてください — ステートフルなSanctumルートはCSRFが必要です。
// 特定のステートレスなwebhook/エンドポイントルートのみを除外します。
];
}
JavaScriptでのCSRF
<meta name="csrf-token" content="{{ csrf_token() }}">
<script>
// Axiosの例(LaravelにはAxiosが付属)
axios.defaults.headers.common['X-CSRF-TOKEN'] = document.querySelector(
'meta[name="csrf-token"]'
).getAttribute('content');
// Fetchの例
fetch('/posts', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
</script>
XSS防止
Bladeテンプレートセキュリティ
{{-- 安全: Bladeによって自動エスケープ --}}
{{ $userInput }}
{{-- 危険: 生の出力 — ユーザー入力では絶対に使用しないでください --}}
{!! $userInput !!}
{{-- 安全: 制御された信頼できるコンテンツでのみ {!! !!} を使用 --}}
{!! $trustedHtmlFromYourServer !!}
{{-- 良い例: 特定のエスケープディレクティブを使用 --}}
@js($data) {{-- JavaScript用にJSONエンコード --}}
@json($data) {{-- テンプレート内でJSONエンコード --}}
{{-- 悪い例: 生のHTML内の直接のユーザー入力 --}}
<div>{!! $user->bio !!}</div> {{-- ユーザーがbioを提供する場合、脆弱 --}}
安全なHTML処理
// 一部のHTMLを許可する必要がある場合は、ホワイトリストアプローチを使用
use HTMLPurifier; // 必要: composer require ezyang/htmlpurifier
public function sanitizeHtml(string $dirty): string
{
$config = \HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,b,i,a[href],ul,ol,li,br');
$config->set('URI.AllowedSchemes', ['http', 'https', 'mailto']);
$purifier = new \HTMLPurifier($config);
return $purifier->purify($dirty);
}
// Blade内:
<div>{!! $sanitizedHtml !!}</div>
APIセキュリティ
APIレート制限
// app/Http/Kernel.php
protected $middlewareGroups = [
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:60,1', // 1分あたり60リクエスト
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
// 特定のルートのカスタムレート制限
Route::middleware('throttle:20,1')->group(function () {
Route::post('/login', [AuthController::class, 'login']);
});
// ユーザーベースのレート制限(ログイン後)
Route::middleware(['auth:sanctum', 'throttle:100,1'])->group(function () {
Route::get('/orders', [OrderController::class, 'index']);
});
CORS設定
// config/cors.php または config/cors.php(Laravel 9+)
return [
// 許可するパス — APIルートのみ制限
'paths' => ['api/*', 'sanctum/csrf-cookie'],
// 許可するオリジン — 本番環境ではカンマ区切りリストを指定
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:3000')],
// 許可するメソッド
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
// 許可するヘッダー
'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With', 'X-CSRF-TOKEN'],
// 公開するヘッダー
'exposed_headers' => ['X-Pagination-Total'],
// 認証情報
'supports_credentials' => true, // Sanctumには必須
// プリフライトリクエストの最大有効期間(秒)
'max_age' => 86400,
];
APIリクエストバリデーション
// App/Http/Requests/StoreOrderRequest
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return true; // 認可はポリシー/ゲートで別途処理
}
public function rules(): array
{
return [
'items' => 'required|array|min:1|max:50',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|integer|min:1|max:100',
'shipping_address' => 'required|string|max:500',
'payment_method' => 'required|in:credit_card,paypal',
];
}
// 機密フィールドをログに残さない
protected function passedValidation(): void
{
$this->replace($this->except(['credit_card_number', 'cvv']));
}
// 機密データはリクエストクエリに含めない(URLに表示される)
}
デプロイ設定
安全な環境変数
# 本番環境の .env 設定
APP_ENV=production
APP_DEBUG=false
APP_KEY=base64:... # 必ず設定
DB_HOST=your-database-host
DB_DATABASE=your-database
DB_USERNAME=your-username
DB_PASSWORD=your-strong-database-password # 強力なパスワードを使用
REDIS_PASSWORD=your-redis-password # Redisに認証が必要な場合
MAIL_PASSWORD=your-mail-password
# サードパーティサービスキー
STRIPE_KEY=pk_live_...
STRIPE_SECRET=sk_live_...
# 機密性の高いサービス URL には本番ドメインの HTTPS を使用
APP_URL=https://your-production-domain.com
SANCTUM_STATEFUL_DOMAINS=your-production-domain.com
サーバー設定
# nginx 設定
server {
listen 443 ssl http2;
server_name your-production-domain.com;
ssl_certificate /etc/ssl/certs/your-cert.crt;
ssl_certificate_key /etc/ssl/private/your-key.key;
root /var/www/html/public;
index index.php;
# セキュリティヘッダー
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';" always;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
# 機密ファイルへのアクセスを拒否
location ~ \.env {
deny all;
}
location ~ \.git {
deny all;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
# 悪意のある入力をブロック
if ($query_string ~* "<script>") {
return 403;
}
}
# ログ記録と監視
access_log /var/log/nginx/your-app-access.log;
error_log /var/log/nginx/your-app-error.log;
# ファイルアップロードの制限
client_max_body_size 10M;
}
依存関係のセキュリティ
Composerセキュリティ監査
# 知られている脆弱性がないか依存関係を監査
composer audit
# すべての依存関係を最新に保つ
composer update
# ローカル開発のためのセキュリティ脆弱性スキャナー
composer require --dev enlightn/enlightn
php artisan enlightn
サードパーティパッケージのセキュリティ
// services.json 内の信頼できるパッケージのみを使用
// 定期的に監査: composer audit
// パッケージがアクティブにメンテナンスされているか確認
composer show --latest your/package
// 可能性のある悪意のあるコードがないかパッケージのソースを確認
// パッケージのGitHubリポジトリ、スター、フォーク、最終更新日を確認
ロギングと監視
安全なロギング
// config/logging.php
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['daily'], // 毎日のローテーション
'ignore_exceptions' => false,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'error'), // 本番環境では error 以上
'days' => 14, // 14日後にログをローテーション
],
// セキュリティイベント専用チャンネル
'security' => [
'driver' => 'daily',
'path' => storage_path('logs/security.log'),
'level' => 'info',
'days' => 90,
],
];
// ログに機密情報を含めない
Log::info('ユーザーがログインしました', ['user_id' => $user->id]); // 安全
Log::info('ユーザーがログインしました', ['password' => $request->password]); // 危険
// セキュリティイベントのログ記録
public function store(LoginRequest $request): RedirectResponse
{
// ...
Log::channel('security')->info('ログイン成功', [
'user_id' => $user->id,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'timestamp' => now(),
]);
// ...
}
セキュリティヘッダー
Laravelでのセキュリティヘッダー
// config/secure-headers.php を作成するか、ミドルウェアを使用
// App\Http\Middleware\SecurityHeaders.php
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
// Content-Security-Policy(アプリケーションのニーズに合わせて調整)
$csp = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';";
$response->headers->set('Content-Security-Policy', $csp);
return $response;
}
}
// Kernel.phpのミドルウェアグループに登録
protected $middlewareGroups = [
'web' => [
// ...
\App\Http\Middleware\SecurityHeaders::class,
],
];
メンテナンス
安全なメンテナンスモード
# Laravelメンテナンスモードを有効化
php artisan down --secret="your-secret-token" --render="errors::503"
# 特定IPのバイパスを許可
php artisan down --allow=192.168.1.1 --allow=10.0.0.0/8
# メンテナンスモードを終了
php artisan up
定期的なセキュリティタスク
// App\Console\Kernel.php
protected function schedule(Schedule $schedule): void
{
// 依存関係の脆弱性スキャン
$schedule->exec('composer audit')->daily()->emailOutputTo('[email protected]');
// データベースバックアップ(暗号化)
$schedule->command('backup:run --only-db')->daily()->at('02:00');
// セッションテーブルのクリーンアップ
$schedule->command('session:gc')->hourly();
// 期限切れトークンのクリーンアップ
$schedule->command('sanctum:prune-expired --hours=24')->daily();
}