Laravelセキュリティのベストプラクティス
Laravelアプリケーションを一般的な脆弱性から保護するための包括的なセキュリティガイドラインです。
いつアクティベートするか
- Laravelの認証と認可(Sanctum、Passport、Jetstream、Breeze)のセットアップ
- ユーザーロール、権限、ポリシーの実装
- 本番セキュリティ設定と環境変数の構成
- Laravelアプリケーションのセキュリティ脆弱性のレビュー
- Laravelアプリケーションの本番デプロイ
- 安全なEloquentクエリとマイグレーションの作成
本番設定
必須の本番設定
// config/app.php
'env' => env('APP_ENV', 'production'),
'debug' => (bool) env('APP_DEBUG', false), // 重要: 本番では常にfalse
'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', // ブール変換で文字列注入を防止
'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>{!! $sanitizedBio !!}</div>
Content Security Policy(CSP)
// config/csp.php
return [
'policy' => [
'default-src' => ["'self'"],
'script-src' => ["'self'", "'strict-dynamic'"],
'style-src' => ["'self'", "'unsafe-inline'"],
'img-src' => ["'self'", 'https:', 'data:'],
'font-src' => ["'self'", 'https:', 'data:'],
'form-action' => ["'self'"],
'frame-ancestors' => ["'none'"],
'base-uri' => ["'self'"],
],
];
// ミドルウェアで適用
// app/Http/Middleware/SecurityHeaders.php
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('Content-Security-Policy', $this->buildCspHeader());
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
return $response;
}
APIセキュリティ
レート制限
// app/Http/Kernel.php
protected $middlewareGroups = [
'api' => [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
// app/Providers/RouteServiceProvider.php
// カスタムレート制限
RateLimiter::for('api', function (Request $request) {
$userId = $request->user()?->id ?: $request->ip();
return Limit::perMinute(60)->by($userId);
});
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
// config/fortify.php (Laravel Fortify使用時)
'limiters' => [
'login' => 5, // 1分間のログイン試行
'two-factor' => 3, // 2FA試行
],
CORS設定
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
'allowed_origins' => explode(',', env('CORS_ALLOWED_ORIGINS', '')),
'allowed_headers' => ['Content-Type', 'X-Requested-With', 'X-CSRF-TOKEN', 'Authorization'],
'exposed_headers' => ['Content-Disposition'],
'max_age' => 0,
'supports_credentials' => true, // Sanctum使用時はtrue
];
APIレスポンスのセキュリティ
// app/Http/Responses/ApiResponse.php (一貫したレスポンス形式)
final class ApiResponse
{
public static function success(mixed $data = null, string $message = '成功', int $code = 200): JsonResponse
{
return response()->json([
'success' => true,
'message' => $message,
'data' => $data,
], $code);
}
public static function error(string $message, int $code = 400, mixed $errors = null): JsonResponse
{
$response = [
'success' => false,
'message' => $message,
];
if ($errors !== null) {
// デバッグ情報を漏らさない
if (app()->environment('production')) {
$response['errors'] = 'リクエストの処理中にエラーが発生しました。';
} else {
$response['errors'] = $errors;
}
}
return response()->json($response, $code);
}
}
デプロイメントのセキュリティ
依存関係の監査
# Laravelのセキュリティアドバイザリをチェック
composer audit
# npmパッケージの脆弱性をチェック
npm audit
# スケジュールされたセキュリティアップデート用のGitHub Dependabotを設定
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "composer"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Tokyo"
open-pull-requests-limit: 10
本番環境の必須チェックリスト
- APP_DEBUGがfalseに設定されている
- APP_KEYが生成され、安全に保存されている
- HTTPSが強制されている
- データベース接続がSSLを使用している
- セッションドライバーがfileではなくdatabase/redisである
- キュードライバーが同期ではなくdatabase/redisである
- キャッシュドライバーがfile/arrayではなくredis/databaseである
- ファイルシステムのアクセス権限が適切である(ストレージディレクトリのchmod 755)
- エラーレポートがスタックトレースを表示していない
- メールドライバーがlogではなくsmtp/sesである
- CSRF保護が有効である
- CORSが適切に設定されている(Sanctumの場合はオリジン制限)
- APIエンドポイントがレート制限されている
- PHPバージョンが最新の安定版(8.2+)である
- Composerのautoloadが最適化されている(composer install --optimize --no-dev)
- APIキャッシュが設定されている(php artisan route:cache, config:cache)
- データベース接続が読み取り/書き込みの分離を使用している
- メール検証が有効である('verified'ミドルウェア)
- Sanctumトークンに有効期限が設定されている
- パスワードポリシーが設定されている(最小12文字、特殊文字)
サーバーレベルのセキュリティ
# nginx設定の例
server {
listen 443 ssl http2;
server_name example.com;
# SSL設定
ssl_certificate /etc/ssl/certs/example.com.crt;
ssl_certificate_key /etc/ssl/private/example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# セキュリティヘッダー
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;
# 隠しLaravelディレクトリへのアクセスをブロック
location ~ /(config|database|resources|storage|vendor)/ {
deny all;
return 404;
}
# .envファイルへのアクセスをブロック
location ~ \.env$ {
deny all;
return 404;
}
# .gitディレクトリへのアクセスをブロック
location ~ /\.[a-zA-Z_]+ {
deny all;
return 404;
}
# アップロードサイズの制限
client_max_body_size 10M;
}
# Apache設定の例(.htaccess)
<IfModule mod_rewrite.c>
RewriteEngine On
# センシティブなファイルへのアクセスをブロック
RewriteRule ^(\.env|\.git|\.env\.example|composer\.json|composer\.lock|artisan)$ - [F,L]
# ディレクトリへの直接アクセスをブロック
RewriteRule ^(config|database|resources|storage|vendor)/ - [F,L]
</IfModule>
# セキュリティヘッダー
<IfModule mod_headers.c>
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set X-XSS-Protection "1; mode=block"
</IfModule>
# ファイルサイズの制限
php_value upload_max_filesize 10M
php_value post_max_size 10M
Security.txt
# public/.well-known/security.txt
Contact: https://example.com/security-report
Encryption: https://example.com/pgp-key.txt
Preferred-Languages: en, ja
Policy: https://example.com/security-policy
Canonical: https://example.com/.well-known/security.txt
// routes/web.php
Route::get('/.well-known/security.txt', function () {
return response()
->view('security-text', [], 200)
->header('Content-Type', 'text/plain');
});