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('/');
}
授權
Gates
// 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);
// ...
}
Policies
// 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, '未經授權。此區域需要角色:' . $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 = []
// 良好:白名單 fillable 屬性
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 透過 VerifyCsrfToken 中介層預設啟用 CSRF
// 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 Webhooks 使用自己的簽名驗證
// 避免使用籠統的 '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>{!! $sanitizedDescription !!}</div>
API 安全性
速率限制
// app/Http/Kernel.php
protected $middlewareGroups = [
'api' => [
'throttle:api',
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
// ...
],
];
// 在路由中使用自訂速率限制
Route::middleware(['auth:sanctum', 'throttle:100,1'])->group(function () {
Route::get('/orders', [OrderController::class, 'index']);
});
// 定義速率限制器
// App\Providers\AppServiceProvider
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
protected function configureRateLimiting(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(100)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->input('email') . '|' . $request->ip());
});
}
輸入驗證
// 對所有 API 端點使用 FormRequest
// app/Http/Requests/StoreOrderRequest.php
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return true; // 在控制器中處理授權
}
public function rules(): array
{
return [
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|integer|exists:products,id',
'items.*.quantity' => 'required|integer|min:1|max:1000',
'shipping_address' => 'required|string|max:500',
'payment_method' => 'required|in:credit_card,paypal,stripe',
];
}
public function messages(): array
{
return [
'items.*.product_id.exists' => '所選產品不存在。',
];
}
}
// 在控制器中使用
public function store(StoreOrderRequest $request): JsonResponse
{
$validated = $request->validated(); // 僅包含驗證過的資料
$order = Order::create($validated);
return response()->json($order, 201);
}
部署安全設定
伺服器配置
# .htaccess — 防止存取敏感檔案
<FilesMatch "\.(env|log|sql|sqlite)$">
Require all denied
</FilesMatch>
<Files artisan>
Require all denied
</Files>
# nginx 配置 — 隱藏 PHP 版本
server_tokens off;
# 防止存取 .env、儲存體等
location ~* \.(env|log|sql|sqlite)$ {
deny all;
}
location /storage {
# 僅允許存取上傳的檔案
try_files $uri =404;
location ~* \.php$ {
deny all;
}
}
# 安全標頭
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;
安全性標頭中介層
// 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');
// 內容安全性政策 (CSP)
$response->headers->set('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;");
// 僅在必要時嚴格傳輸安全性 (HSTS) — 請先了解其影響
if (app()->environment('production')) {
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
return $response;
}
}
// 在 Kernel 中註冊
protected $middleware = [
\App\Http\Middleware\SecurityHeaders::class,
];
常見漏洞
不安全的重導向
// 危險:開放重導向
return redirect($request->input('redirect_url')); // 可能被導向到惡意網站
// 安全:限制重導向到應用程式內的 URL
return redirect()->intended('/dashboard'); // 僅允許驗證過的應用程式內路徑
// 安全:白名單重導向目標
$allowedDomains = ['example.com', 'app.example.com'];
$redirectTo = $request->input('redirect_url');
$host = parse_url($redirectTo, PHP_URL_HOST);
if (in_array($host, $allowedDomains, true)) {
return redirect($redirectTo);
}
return redirect('/');
檔案上傳漏洞
// app/Http/Requests/UploadFileRequest.php
class UploadFileRequest extends FormRequest
{
public function rules(): array
{
return [
'avatar' => [
'required',
'image',
'mimes:jpeg,png,jpg,gif,svg',
'max:2048', // 最大 2MB
function ($attribute, $value, $fail) {
// 驗證實際內容類型
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $value->path());
finfo_close($finfo);
$allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
if (!in_array($mimeType, $allowedMimes)) {
$fail('檔案類型無效。');
}
},
],
];
}
public function store(UploadFileRequest $request): RedirectResponse
{
// 以安全名稱儲存並防止覆寫
$path = $request->file('avatar')->store('avatars', 'public');
// 或使用自訂名稱
$filename = time() . '_' . Str::random(10) . '.' . $request->file('avatar')->extension();
$path = $request->file('avatar')->storeAs('avatars', $filename, 'public');
auth()->user()->update(['avatar' => $path]);
return back()->with('status', '頭像已更新!');
}
}
除錯頁面暴露
// .env 檔案
APP_DEBUG=false // 生產環境:永遠為 false
APP_ENV=production
// bootstrap/app.php 中的原本檢查
$app->loadEnvironmentFrom('.env');
$app->withFacades();
$app->withEloquent();
// 當 APP_DEBUG 為 true 時,安全地記錄錯誤,但使用自訂例外處理器
// app/Exceptions/Handler.php
public function register(): void
{
$this->reportable(function (Throwable $e) {
// 記錄到您偏好的服務
// Log::channel('slack')->report($e);
});
$this->renderable(function (NotFoundHttpException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json(['message' => '記錄未找到。'], 404);
}
return response()->view('errors.404', [], 404);
});
}
資料庫安全性
// 在 Schema 中使用唯讀資料庫使用者 (如適用)
// config/database.php
'connections' => [
'mysql' => [
'driver' => 'mysql',
'read' => [
'host' => env('DB_READ_HOST', env('DB_HOST', '127.0.0.1')),
],
'write' => [
'host' => env('DB_WRITE_HOST', env('DB_HOST', '127.0.0.1')),
],
'sticky' => env('DB_STICKY', false),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => 'InnoDB', // 支援交易與外部鍵
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
],
// 遷移中的外部鍵約束
// database/migrations/create_posts_table.php
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('title');
$table->text('body');
$table->timestamps();
});
// 資料層級安全性:永遠不要直接對使用者暴露 ID
// 對公開 ID 使用 UUID
Schema::create('posts', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignId('user_id')->constrained();
$table->string('title');
$table->text('body');
$table->timestamps();
});
工具與資源
安全套件
# Laravel 專用安全工具
composer require --dev spatie/laravel-backtrace # 更好的例外處理
composer require --dev beyondcode/laravel-query-detector # N+1 查詢偵測
composer require --dev therobfonz/laravel-mandrill-errors # 郵件錯誤通知
# Laravel Debugbar (僅開發環境)
composer require --dev barryvdh/laravel-debugbar
# 授權審查
composer require --dev spatie/laravel-permission # 角色與權限管理
安全掃描
# 相依性安全檢查
composer audit # Laravel 10+/Composer 2.4+
# 使用本地安全掃描工具
composer require --dev enlightn/enlightn
php artisan enlightn
# 安全建議檢查
composer require --dev spatie/security-advisories