API Security Audit Checklist cho Laravel: OWASP Best Practices
·
14 min read
Giới Thiệu
API là cửa ngõ chính của ứng dụng hiện đại, và cũng là mục tiêu tấn công phổ biến nhất. Bài viết này cung cấp checklist bảo mật toàn diện cho Laravel API dựa trên OWASP API Security Top 10.
OWASP API Security Top 10 (2023)
- API1:2023 - Broken Object Level Authorization
- API2:2023 - Broken Authentication
- API3:2023 - Broken Object Property Level Authorization
- API4:2023 - Unrestricted Resource Consumption
- API5:2023 - Broken Function Level Authorization
- API6:2023 - Unrestricted Access to Sensitive Business Flows
- API7:2023 - Server Side Request Forgery (SSRF)
- API8:2023 - Security Misconfiguration
- API9:2023 - Improper Inventory Management
- API10:2023 - Unsafe Consumption of APIs
1. Authentication Security
Secure Token Configuration
// config/sanctum.php
return [
'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) : ''
))),
'guard' => ['web'],
'expiration' => 60 * 24, // 24 hours
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
'middleware' => [
'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
],
];
Token Abilities (Scopes)
// app/Http/Controllers/AuthController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
class AuthController extends Controller
{
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required|string',
'device_name' => 'required|string|max:255',
]);
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
// Generic error message to prevent user enumeration
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
// Check if account is locked
if ($user->isLocked()) {
throw ValidationException::withMessages([
'email' => ['Account is temporarily locked. Please try again later.'],
]);
}
// Define abilities based on user role
$abilities = $this->getAbilitiesForUser($user);
// Revoke old tokens for this device
$user->tokens()
->where('name', $request->device_name)
->delete();
$token = $user->createToken(
$request->device_name,
$abilities,
now()->addHours(24)
);
return response()->json([
'token' => $token->plainTextToken,
'expires_at' => $token->accessToken->expires_at,
]);
}
private function getAbilitiesForUser(User $user): array
{
return match ($user->role) {
'admin' => ['*'],
'manager' => ['users:read', 'users:write', 'orders:read', 'orders:write'],
'staff' => ['orders:read', 'orders:write'],
default => ['profile:read', 'profile:write'],
};
}
}
Check Token Abilities
// app/Http/Controllers/UserController.php
public function index(Request $request)
{
// Check for specific ability
if (!$request->user()->tokenCan('users:read')) {
abort(403, 'Insufficient permissions');
}
return UserResource::collection(User::paginate());
}
// Or use middleware
Route::get('/users', [UserController::class, 'index'])
->middleware(['auth:sanctum', 'abilities:users:read']);
Brute Force Protection
// app/Services/LoginAttemptService.php
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
class LoginAttemptService
{
private const MAX_ATTEMPTS = 5;
private const DECAY_MINUTES = 15;
private const LOCKOUT_MINUTES = 60;
public function recordFailedAttempt(string $email, string $ip): void
{
$emailKey = $this->getEmailKey($email);
$ipKey = $this->getIpKey($ip);
RateLimiter::hit($emailKey, self::DECAY_MINUTES * 60);
RateLimiter::hit($ipKey, self::DECAY_MINUTES * 60);
// If too many attempts, lock the account
if ($this->tooManyAttempts($email, $ip)) {
$this->lockAccount($email);
}
}
public function tooManyAttempts(string $email, string $ip): bool
{
$emailKey = $this->getEmailKey($email);
$ipKey = $this->getIpKey($ip);
return RateLimiter::tooManyAttempts($emailKey, self::MAX_ATTEMPTS) ||
RateLimiter::tooManyAttempts($ipKey, self::MAX_ATTEMPTS * 3);
}
public function clearAttempts(string $email, string $ip): void
{
RateLimiter::clear($this->getEmailKey($email));
RateLimiter::clear($this->getIpKey($ip));
}
private function lockAccount(string $email): void
{
Cache::put(
"account_locked:{$email}",
true,
now()->addMinutes(self::LOCKOUT_MINUTES)
);
// Log security event
Log::channel('security')->warning('Account locked due to too many failed attempts', [
'email' => $email,
]);
}
public function isAccountLocked(string $email): bool
{
return Cache::has("account_locked:{$email}");
}
private function getEmailKey(string $email): string
{
return 'login_attempts:email:' . hash('sha256', strtolower($email));
}
private function getIpKey(string $ip): string
{
return 'login_attempts:ip:' . $ip;
}
}
2. Authorization (BOLA & BFLA Protection)
Policy-Based Authorization
// app/Policies/OrderPolicy.php
<?php
namespace App\Policies;
use App\Models\Order;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class OrderPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): bool
{
return $user->tokenCan('orders:read');
}
public function view(User $user, Order $order): bool
{
// Admin can view all
if ($user->isAdmin()) {
return true;
}
// Users can only view their own orders
return $user->id === $order->user_id;
}
public function update(User $user, Order $order): bool
{
if (!$user->tokenCan('orders:write')) {
return false;
}
// Only order owner or admin can update
return $user->id === $order->user_id || $user->isAdmin();
}
public function delete(User $user, Order $order): bool
{
// Only admins can delete
return $user->isAdmin() && $user->tokenCan('orders:delete');
}
}
Controller Authorization
// app/Http/Controllers/OrderController.php
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use App\Http\Requests\UpdateOrderRequest;
use App\Http\Resources\OrderResource;
use Illuminate\Http\Request;
class OrderController extends Controller
{
public function __construct()
{
$this->authorizeResource(Order::class, 'order');
}
public function show(Order $order)
{
// Authorization handled by authorizeResource
return new OrderResource($order->load(['items', 'customer']));
}
public function update(UpdateOrderRequest $request, Order $order)
{
// Extra validation for sensitive fields
if ($request->has('status') && !$request->user()->can('updateStatus', $order)) {
abort(403, 'Cannot update order status');
}
$order->update($request->validated());
return new OrderResource($order);
}
}
Prevent Mass Assignment Vulnerabilities
// app/Models/User.php
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
// Only allow these fields to be mass assigned
protected $fillable = [
'name',
'email',
'password',
];
// Never allow these fields to be mass assigned
protected $guarded = [
'id',
'role',
'is_admin',
'email_verified_at',
'two_factor_secret',
];
// Hidden from JSON/array output
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
];
}
3. Input Validation
Request Validation
// app/Http/Requests/StoreUserRequest.php
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class StoreUserRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', User::class);
}
public function rules(): array
{
return [
'name' => [
'required',
'string',
'min:2',
'max:255',
'regex:/^[\pL\s\-\']+$/u', // Only letters, spaces, hyphens, apostrophes
],
'email' => [
'required',
'string',
'email:rfc,dns',
'max:255',
'unique:users,email',
],
'password' => [
'required',
'string',
Password::min(12)
->letters()
->mixedCase()
->numbers()
->symbols()
->uncompromised(3),
'confirmed',
],
'phone' => [
'nullable',
'string',
'regex:/^\+?[1-9]\d{1,14}$/', // E.164 format
],
'avatar' => [
'nullable',
'image',
'mimes:jpeg,png,webp',
'max:2048', // 2MB
'dimensions:min_width=100,min_height=100,max_width=2000,max_height=2000',
],
];
}
public function messages(): array
{
return [
'name.regex' => 'Name can only contain letters, spaces, hyphens, and apostrophes.',
'password.uncompromised' => 'This password has been found in a data breach. Please choose a different password.',
];
}
protected function prepareForValidation(): void
{
$this->merge([
'email' => strtolower(trim($this->email)),
'name' => trim($this->name),
]);
}
}
SQL Injection Prevention
// ❌ NEVER do this - SQL Injection vulnerable
$users = DB::select("SELECT * FROM users WHERE email = '{$request->email}'");
// ✅ Use parameter binding
$users = DB::select('SELECT * FROM users WHERE email = ?', [$request->email]);
// ✅ Or use Eloquent/Query Builder
$users = User::where('email', $request->email)->get();
// ✅ For complex queries, use named bindings
$users = DB::select(
'SELECT * FROM users WHERE email = :email AND status = :status',
['email' => $request->email, 'status' => 'active']
);
XSS Prevention
// app/Http/Middleware/SanitizeInput.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class SanitizeInput
{
protected array $except = [
'password',
'password_confirmation',
'current_password',
];
public function handle(Request $request, Closure $next)
{
$input = $request->all();
array_walk_recursive($input, function (&$value, $key) {
if (is_string($value) && !in_array($key, $this->except)) {
// Remove null bytes
$value = str_replace(chr(0), '', $value);
// Remove control characters except newlines
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value);
// Trim whitespace
$value = trim($value);
}
});
$request->merge($input);
return $next($request);
}
}
4. Rate Limiting
Advanced Rate Limiting
// app/Providers/RouteServiceProvider.php
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->configureRateLimiting();
}
protected function configureRateLimiting(): void
{
// Default API rate limit
RateLimiter::for('api', function (Request $request) {
$user = $request->user();
if ($user) {
// Different limits based on subscription tier
$limit = match ($user->subscription_tier) {
'enterprise' => 10000,
'business' => 5000,
'pro' => 1000,
default => 100,
};
return Limit::perMinute($limit)->by($user->id);
}
// Guest rate limit
return Limit::perMinute(20)->by($request->ip());
});
// Strict rate limit for authentication endpoints
RateLimiter::for('auth', function (Request $request) {
return [
Limit::perMinute(5)->by($request->ip()),
Limit::perMinute(10)->by($request->input('email', 'guest')),
];
});
// Rate limit for expensive operations
RateLimiter::for('expensive', function (Request $request) {
return Limit::perHour(10)
->by($request->user()?->id ?: $request->ip())
->response(function (Request $request, array $headers) {
return response()->json([
'error' => 'Too many requests',
'message' => 'Please try again later',
'retry_after' => $headers['Retry-After'],
], 429, $headers);
});
});
// Sliding window rate limit
RateLimiter::for('uploads', function (Request $request) {
return Limit::perDay(50)
->by($request->user()->id)
->response(function () {
return response()->json([
'error' => 'Daily upload limit reached',
], 429);
});
});
}
}
Apply Rate Limits
// routes/api.php
Route::middleware(['throttle:auth'])->group(function () {
Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']);
Route::post('/password/forgot', [PasswordController::class, 'forgot']);
});
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::get('/users', [UserController::class, 'index']);
Route::middleware('throttle:expensive')->group(function () {
Route::post('/reports/generate', [ReportController::class, 'generate']);
Route::post('/exports', [ExportController::class, 'create']);
});
Route::middleware('throttle:uploads')->group(function () {
Route::post('/files', [FileController::class, 'upload']);
});
});
5. Security Headers
Security Headers Middleware
// app/Http/Middleware/SecurityHeaders.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// Prevent MIME type sniffing
$response->headers->set('X-Content-Type-Options', 'nosniff');
// XSS Protection (legacy browsers)
$response->headers->set('X-XSS-Protection', '1; mode=block');
// Prevent clickjacking
$response->headers->set('X-Frame-Options', 'DENY');
// Control referrer information
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions Policy
$response->headers->set('Permissions-Policy',
'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()'
);
// Content Security Policy for API
$response->headers->set('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
// Strict Transport Security
if ($request->secure()) {
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
}
// Remove server information
$response->headers->remove('X-Powered-By');
$response->headers->remove('Server');
return $response;
}
}
CORS Configuration
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
'allowed_origins' => explode(',', env('CORS_ALLOWED_ORIGINS', '')),
'allowed_origins_patterns' => [],
'allowed_headers' => [
'Accept',
'Authorization',
'Content-Type',
'X-Requested-With',
'X-CSRF-TOKEN',
],
'exposed_headers' => [
'X-RateLimit-Limit',
'X-RateLimit-Remaining',
'Retry-After',
],
'max_age' => 7200,
'supports_credentials' => true,
];
6. Logging & Monitoring
Security Event Logging
// app/Services/SecurityLogger.php
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\Request;
class SecurityLogger
{
public function logAuthenticationAttempt(Request $request, bool $success, ?string $reason = null): void
{
$context = [
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'email' => $request->input('email'),
'success' => $success,
'reason' => $reason,
'timestamp' => now()->toIso8601String(),
];
if ($success) {
Log::channel('security')->info('Authentication successful', $context);
} else {
Log::channel('security')->warning('Authentication failed', $context);
}
}
public function logAuthorizationFailure(Request $request, string $action, string $resource): void
{
Log::channel('security')->warning('Authorization denied', [
'user_id' => $request->user()?->id,
'action' => $action,
'resource' => $resource,
'ip' => $request->ip(),
'url' => $request->fullUrl(),
'timestamp' => now()->toIso8601String(),
]);
}
public function logSuspiciousActivity(Request $request, string $activity, array $details = []): void
{
Log::channel('security')->alert('Suspicious activity detected', [
'user_id' => $request->user()?->id,
'activity' => $activity,
'details' => $details,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'url' => $request->fullUrl(),
'timestamp' => now()->toIso8601String(),
]);
}
public function logDataAccess(Request $request, string $resource, string $action, int $recordCount): void
{
Log::channel('audit')->info('Data access', [
'user_id' => $request->user()?->id,
'resource' => $resource,
'action' => $action,
'record_count' => $recordCount,
'ip' => $request->ip(),
'timestamp' => now()->toIso8601String(),
]);
}
}
Logging Configuration
// config/logging.php
'channels' => [
// ... other channels
'security' => [
'driver' => 'daily',
'path' => storage_path('logs/security.log'),
'level' => 'debug',
'days' => 90,
'permission' => 0600,
],
'audit' => [
'driver' => 'daily',
'path' => storage_path('logs/audit.log'),
'level' => 'info',
'days' => 365,
'permission' => 0600,
],
],
7. SSRF Prevention
// app/Services/SafeHttpClient.php
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\PendingRequest;
class SafeHttpClient
{
private array $blockedHosts = [
'localhost',
'127.0.0.1',
'::1',
'0.0.0.0',
'169.254.169.254', // AWS metadata
'metadata.google.internal', // GCP metadata
];
private array $blockedCidrs = [
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'127.0.0.0/8',
'169.254.0.0/16',
'fc00::/7',
'fe80::/10',
];
public function get(string $url): PendingRequest
{
$this->validateUrl($url);
return Http::timeout(10)
->withOptions([
'allow_redirects' => [
'max' => 3,
'strict' => true,
'on_redirect' => function ($request, $response, $uri) {
$this->validateUrl((string) $uri);
},
],
]);
}
private function validateUrl(string $url): void
{
$parsed = parse_url($url);
if (!$parsed || !isset($parsed['host'])) {
throw new \InvalidArgumentException('Invalid URL');
}
$host = strtolower($parsed['host']);
// Check blocked hosts
if (in_array($host, $this->blockedHosts)) {
throw new \InvalidArgumentException('Access to this host is not allowed');
}
// Resolve hostname and check IP
$ips = gethostbynamel($host);
if ($ips === false) {
throw new \InvalidArgumentException('Could not resolve hostname');
}
foreach ($ips as $ip) {
if ($this->isBlockedIp($ip)) {
throw new \InvalidArgumentException('Access to this IP range is not allowed');
}
}
}
private function isBlockedIp(string $ip): bool
{
foreach ($this->blockedCidrs as $cidr) {
if ($this->ipInCidr($ip, $cidr)) {
return true;
}
}
return false;
}
private function ipInCidr(string $ip, string $cidr): bool
{
[$subnet, $bits] = explode('/', $cidr);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$ip = ip2long($ip);
$subnet = ip2long($subnet);
$mask = -1 << (32 - $bits);
return ($ip & $mask) === ($subnet & $mask);
}
return false;
}
}
8. API Security Checklist
Authentication
- Use strong token-based authentication (Sanctum/Passport)
- Implement token expiration
- Use secure token storage (httpOnly cookies for web)
- Implement brute force protection
- Support MFA/2FA
- Secure password reset flow
Authorization
- Implement resource-level authorization (Policies)
- Check ownership for every resource access
- Use ability-based access control
- Prevent mass assignment vulnerabilities
- Validate all user inputs
Data Protection
- Use HTTPS everywhere
- Encrypt sensitive data at rest
- Hash passwords with bcrypt/argon2
- Sanitize all outputs
- Implement proper error handling (no stack traces)
Rate Limiting
- Apply rate limits to all endpoints
- Stricter limits for authentication endpoints
- Per-user rate limiting
- Implement backoff strategies
Headers & CORS
- Set all security headers
- Configure CORS properly
- Use Content-Security-Policy
- Enable HSTS
Logging & Monitoring
- Log all authentication events
- Log authorization failures
- Monitor for suspicious patterns
- Set up alerts for anomalies
Infrastructure
- Keep dependencies updated
- Run security scans regularly
- Implement WAF if possible
- Regular penetration testing
Kết Luận
API security là quá trình liên tục, không phải one-time setup. Hãy:
- Review code thường xuyên
- Update dependencies định kỳ
- Monitor logs và alerts
- Conduct security audits
- Stay updated với OWASP guidelines