OWASP Top 10 for PHP Developers
The OWASP Top 10 is a standard awareness document that lists the most critical web application security risks. Although it covers web applications in general, PHP developers face these issues regularly.
This guide breaks down each of the OWASP Top 10 vulnerabilities with examples written in PHP. For each vulnerability, I will try to give an insecure code example, followed by a more secure implementation.
1. Broken Access Control
Access control ensures users can only do what they're allowed to do. When it breaks, unauthorized users can access data or perform actions they shouldn't.
Vulnerable Code Example
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use App\Repository\UserRepository;
class ProfileController extends AbstractController
{
#[Route('/profile/{userId}', name: 'app_profile_edit', methods: ['POST'])]
public function editProfile(Request $request, int $userId, UserRepository $userRepository): Response
{
// No verification if the logged-in user is the same as $userId
$userData = $request->request->all();
$userRepository->update($userId, $userData);
return $this->json(['message' => 'Profile updated successfully']);
}
}
In this example, any authenticated user can modify any other user's profile by simply changing the user ID parameter.
Secure Solution
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use App\Repository\UserRepository;
readonly class ProfileController extends AbstractController
{
#[Route('/profile/{userId}', name: 'app_profile_edit', methods: ['POST'])]
public function editProfile(Request $request, int $userId, UserRepository $userRepository): Response
{
// Get current authenticated user
$currentUser = $this->getUser();
// Check if the user is authorized to edit this profile
if ($currentUser?->getId() !== $userId && !$this->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('You don\'t have permission to edit this profile');
}
// Using a form or DTO would be even better for input validation
$userData = $request->request->all();
$userRepository->update($userId, $userData);
return $this->json(['message' => 'Profile updated successfully']);
}
}
2. Cryptographic Failures
This category includes issues with encryption, password hashing, and sensitive data storage.
Vulnerable Code Example
namespace App\Handler\User;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
class CreateUserHandler
{
public function __construct(
private EntityManagerInterface $entityManager
) {}
// Storing passwords in plain text
public function handle(string $username, string $password): User
{
$user = new User();
$user->setUsername($username);
$user->setPassword($password); // Plain text password
$this->entityManager->persist($user);
$this->entityManager->flush();
return $user;
}
}
// Using weak encryption
namespace App\Service;
class EncryptionService
{
public function encrypt(string $data): string
{
$key = "hardcoded-secret-key";
return openssl_encrypt($data, "des-ede3-cbc", $key);
}
}
Secure Solution
namespace App\Handler\User;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
readonly class CreateUserHandler
{
public function __construct(
private EntityManagerInterface $entityManager,
private UserPasswordHasherInterface $passwordHasher
) {}
// Proper password hashing with Symfony's password hasher
public function handle(string $username, string $password): User
{
$user = new User();
$user->setUsername($username);
// Hash password using Symfony's password hasher
$hashedPassword = $this->passwordHasher->hashPassword(
$user,
$password
);
$user->setPassword($hashedPassword);
$this->entityManager->persist($user);
$this->entityManager->flush();
return $user;
}
}
namespace App\Service;
readonly class EncryptionService
{
private const string CIPHER_METHOD = 'aes-256-cbc';
public function __construct(
private string $encryptionKey,
) {
// Key should be injected via dependency injection
// and configured in services.yaml from environment variables
}
public function encrypt(string $data): string
{
// Generate a random initialization vector
$iv = random_bytes(16);
// Use AES-256 encryption
$encrypted = openssl_encrypt($data, self::CIPHER_METHOD, $this->encryptionKey, 0, $iv);
// Return both the IV and encrypted data
return base64_encode($iv . $encrypted);
}
public function decrypt(string $encryptedData): string
{
$decoded = base64_decode($encryptedData);
// Extract the IV (first 16 bytes)
$iv = substr($decoded, 0, 16);
$encrypted = substr($decoded, 16);
// Decrypt the data
return openssl_decrypt($encrypted, self::CIPHER_METHOD, $this->encryptionKey, 0, $iv);
}
}
3. Injection
Injection happens when user input is not properly checked before it's used in commands or database queries. This lets attackers insert harmful code that can make your application do things it wasn't supposed to do.
Vulnerable Code Example
namespace App\Repository;
use App\Entity\User;
use Doctrine\DBAL\Connection;
class UserRepository
{
public function __construct(
private Connection $connection
) {}
// SQL Injection vulnerability
public function findByUsername(string $username): ?array
{
$sql = "SELECT * FROM users WHERE username = '$username'";
$result = $this->connection->executeQuery($sql);
return $result->fetchAssociative() ?: null;
}
}
namespace App\Service;
class ImageService
{
// Command Injection vulnerability
public function generateThumbnail(string $filename): void
{
$command = "convert {$filename} -resize 100x100 thumbnail_{$filename}";
exec($command);
}
}
If an attacker provides a username like admin' --
, they could bypass password verification. For the command injection, an attacker might use a filename like image.jpg; rm -rf /
.
Secure Solution
namespace App\Repository;
use App\Entity\User;
use Doctrine\DBAL\Connection;
readonly class UserRepository
{
public function __construct(
private Connection $connection
) {}
// Preventing SQL Injection with prepared statements
public function findByUsername(string $username): ?array
{
$sql = "SELECT * FROM users WHERE username = :username";
$stmt = $this->connection->prepare($sql);
$stmt->bindValue('username', $username);
$result = $stmt->executeQuery();
return $result->fetchAssociative() ?: null;
}
// Even better in Symfony: use the Doctrine QueryBuilder
public function findByUsernameWithQueryBuilder(string $username): ?array
{
return $this->connection->createQueryBuilder()
->select('*')
->from('users')
->where('username = :username')
->setParameter('username', $username)
->executeQuery()
->fetchAssociative() ?: null;
}
}
namespace App\Service;
use Symfony\Component\Process\Process;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\HttpFoundation\File\File;
readonly class ImageService
{
public function __construct(
private ValidatorInterface $validator
) {}
// Preventing Command Injection
public function generateThumbnail(string $filePath): void
{
// First validate that it's a real file and it's an image
$file = new File($filePath);
$violations = $this->validator->validate($file, [
new Assert\File([
'maxSize' => '10M',
'mimeTypes' => [
'image/jpeg',
'image/png',
'image/gif',
],
'mimeTypesMessage' => 'Please upload a valid image (JPEG, PNG, GIF)',
])
]);
if (count($violations) > 0) {
throw new \InvalidArgumentException((string) $violations);
}
// Get safe filename
$safeFilename = $file->getFilename();
// Use Symfony Process component instead of exec()
$process = new Process([
'convert',
$file->getPathname(),
'-resize',
'100x100',
dirname($file->getPathname()) . '/thumbnail_' . $safeFilename
]);
$process->run();
if (!$process->isSuccessful()) {
throw new ProcessFailedException($process);
}
}
}
4. Insecure Design
Insecure design is about problems in the planning stage, before you write any code. It happens when developers don't think about how attackers might misuse the system or don't include security as part of the requirements.
Vulnerable Design Example
namespace App\Handler\User;
use App\Repository\UserRepository;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
class ResetPasswordHandler
{
public function __construct(
private UserRepository $userRepository,
private MailerInterface $mailer
) {}
// Password reset functionality that emails the actual password
public function handle(string $email): string
{
$user = $this->userRepository->findOneByEmail($email);
if ($user) {
$newPassword = bin2hex(random_bytes(8));
$this->userRepository->updatePassword($user->getId(), $newPassword);
$email = (new Email())
->to($email)
->subject('Password Reset')
->text("Your new password is: $newPassword");
$this->mailer->send($email);
}
return "If your email exists, a new password has been sent.";
}
}
Secure Design Solution
namespace App\Handler\User;
use App\Entity\PasswordResetToken;
use App\Repository\UserRepository;
use App\Repository\PasswordResetTokenRepository;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
readonly class RequestPasswordResetHandler
{
public function __construct(
private UserRepository $userRepository,
private PasswordResetTokenRepository $tokenRepository,
private MailerInterface $mailer,
private UrlGeneratorInterface $urlGenerator
) {}
public function handle(string $email): string
{
$user = $this->userRepository->findOneByEmail($email);
if ($user) {
// Generate a unique token with expiration
$token = bin2hex(random_bytes(32));
$expiry = new \DateTimeImmutable('+1 hour');
// Create and store token entity
$resetToken = new PasswordResetToken();
$resetToken->setUser($user);
$resetToken->setToken($token);
$resetToken->setExpiresAt($expiry);
$this->tokenRepository->save($resetToken, true);
// Create reset URL with Symfony's URL generator
$resetUrl = $this->urlGenerator->generate('app_reset_password', [
'token' => $token
], UrlGeneratorInterface::ABSOLUTE_URL);
// Send email with Symfony Mailer
$email = (new Email())
->to($user->getEmail())
->subject('Password Reset')
->text("Click this link to reset your password: $resetUrl\nThis link will expire in 1 hour.");
$this->mailer->send($email);
}
// Always return the same message regardless of whether the email exists
return "If your email exists, a password reset link has been sent.";
}
}
readonly class CompletePasswordResetHandler
{
public function __construct(
private PasswordResetTokenRepository $tokenRepository,
private UserPasswordHasherInterface $passwordHasher
) {}
public function handle(string $token, string $newPassword): string
{
$resetRequest = $this->tokenRepository->findOneByToken($token);
if (!$resetRequest || $resetRequest->getExpiresAt() < new \DateTimeImmutable()) {
throw new \InvalidArgumentException("Invalid or expired token");
}
$user = $resetRequest->getUser();
// Update the password with Symfony's password hasher
$hashedPassword = $this->passwordHasher->hashPassword($user, $newPassword);
$user->setPassword($hashedPassword);
// Invalidate the token
$this->tokenRepository->remove($resetRequest, true);
return "Password has been updated successfully";
}
}
5. Security Misconfiguration
Security misconfiguration happens when security settings aren't properly configured, or default settings are left unchanged.
Vulnerable Example
// PHP configuration in production
ini_set('display_errors', 1);
error_reporting(E_ALL);
// Leaving debug information in error messages
try {
// Some database operation
} catch (Exception $e) {
echo "Error: " . $e->getMessage() . " in " . $e->getFile() . " on line " . $e->getLine();
}
Secure Solution
// PHP configuration in production
$errorSettings = match ($_ENV['APP_ENV']) {
'production' => [
'display_errors' => 0,
'error_reporting' => 0,
],
default => [
'display_errors' => 1,
'error_reporting' => E_ALL,
],
};
ini_set('display_errors', $errorSettings['display_errors']);
error_reporting($errorSettings['error_reporting']);
// Proper error handling
try {
// Some database operation
} catch (\Throwable $e) { // Using Throwable to catch both Exception and Error
// Log detailed error for developers
error_log($e->getMessage() . " in " . $e->getFile() . " on line " . $e->getLine());
// Show generic error to users using match expression
$errorMessage = match ($_ENV['APP_ENV']) {
'production' => "An error occurred. Please try again later.",
default => "Error: " . $e->getMessage(),
};
echo $errorMessage;
}
6. Vulnerable and Outdated Components
Using components with known vulnerabilities can lead to serious security breaches.
Vulnerable Example
// Using outdated libraries in composer.json
{
"require": {
"php": "^7.2",
"symfony/http-foundation": "4.1.0",
"phpmailer/phpmailer": "5.2.19"
}
}
Secure Solution
// Updated composer.json with secure versions and PHP 8.4
{
"require": {
"php": "^8.4",
"symfony/http-foundation": "^7.0",
"phpmailer/phpmailer": "^6.9"
},
"config": {
"audit": {
"abandoned": "fail"
}
}
}
- Regularly run
composer update
to keep dependencies up-to-date - Use tools like
composer audit
to check for known vulnerabilities - Subscribe to security announcements for your frameworks and libraries
7. Identification and Authentication Failures
Authentication failures include anything that allows attackers to assume other users' identities or bypass authentication.
Vulnerable Example
namespace App\Service;
// Weak password requirements
class PasswordValidator
{
public function validate(string $password): bool
{
// Only checks length
return strlen($password) >= 6;
}
}
namespace App\Controller;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class SecurityController extends AbstractController
{
// Session fixation vulnerability
#[Route('/login', name: 'app_login', methods: ['POST'])]
public function login(
Request $request,
UserRepository $userRepository,
UserPasswordHasherInterface $passwordHasher
): Response {
$data = json_decode($request->getContent(), true);
$username = $data['username'] ?? '';
$password = $data['password'] ?? '';
$user = $userRepository->findOneByUsername($username);
if ($user && $passwordHasher->isPasswordValid($user, $password)) {
// Just set user ID in session - vulnerable to session fixation
$request->getSession()->set('user_id', $user->getId());
return $this->json(['message' => 'Login successful']);
}
return $this->json(['message' => 'Invalid credentials'], Response::HTTP_UNAUTHORIZED);
}
}
Secure Solution
namespace App\Service;
enum PasswordStrength: string
{
case WEAK = 'weak';
case MEDIUM = 'medium';
case STRONG = 'strong';
case VERY_STRONG = 'very_strong';
}
readonly class PasswordStrengthValidator
{
private const int MIN_LENGTH = 12;
public function validate(string $password): bool
{
// Check minimum length
if (strlen($password) < self::MIN_LENGTH) {
return false;
}
// Must contain uppercase
if (!preg_match('/[A-Z]/', $password)) {
return false;
}
// Must contain lowercase
if (!preg_match('/[a-z]/', $password)) {
return false;
}
// Must contain number
if (!preg_match('/[0-9]/', $password)) {
return false;
}
// Must contain special character
if (!preg_match('/[^A-Za-z0-9]/', $password)) {
return false;
}
return true;
}
public function checkStrength(string $password): PasswordStrength
{
$score = 0;
if (strlen($password) >= 8) $score++;
if (strlen($password) >= self::MIN_LENGTH) $score++;
if (preg_match('/[A-Z]/', $password)) $score++;
if (preg_match('/[a-z]/', $password)) $score++;
if (preg_match('/[0-9]/', $password)) $score++;
if (preg_match('/[^A-Za-z0-9]/', $password)) $score++;
return match($score) {
0, 1, 2 => PasswordStrength::WEAK,
3, 4 => PasswordStrength::MEDIUM,
5 => PasswordStrength::STRONG,
6 => PasswordStrength::VERY_STRONG,
};
}
}
namespace App\Controller;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
readonly class SecurityController extends AbstractController
{
// Preventing session fixation
#[Route('/login', name: 'app_login_api', methods: ['POST'])]
public function login(
Request $request,
UserRepository $userRepository,
UserPasswordHasherInterface $passwordHasher
): Response {
$data = json_decode($request->getContent(), true);
$username = $data['username'] ?? '';
$password = $data['password'] ?? '';
$user = $userRepository->findOneByUsername($username);
if (!$user || !$passwordHasher->isPasswordValid($user, $password)) {
throw new AuthenticationException('Invalid credentials');
}
// Regenerate session ID to prevent session fixation
$request->getSession()->migrate(true);
// Store session data securely
$sessionData = [
'user_id' => $user->getId(),
'ip_address' => $request->getClientIp(),
'user_agent' => $request->headers->get('User-Agent'),
'last_activity' => time(),
];
// Using foreach loop to set session values (more readable)
foreach ($sessionData as $key => $value) {
$request->getSession()->set($key, $value);
}
return $this->json(['message' => 'Login successful']);
}
}
// Add a request listener for session validation
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
readonly class SessionValidationSubscriber implements EventSubscriberInterface
{
public function __construct(
private RequestStack $requestStack,
) {}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 10], // Run early
];
}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
$session = $request->getSession();
// If the user is not logged in, nothing to validate
if (!$session->has('user_id')) {
return;
}
$isSessionHijacked = $session->get('ip_address') !== $request->getClientIp() ||
$session->get('user_agent') !== $request->headers->get('User-Agent');
if ($isSessionHijacked) {
$session->invalidate();
throw new AccessDeniedException('Session validation failed');
}
$isSessionExpired = time() - $session->get('last_activity') > 1800; // 30 minutes
if ($isSessionExpired) {
$session->invalidate();
throw new AccessDeniedException('Session expired');
}
// Update last activity time
$session->set('last_activity', time());
}
}
8. Software and Data Integrity Failures
This involves code and infrastructure that don't protect against integrity violations, such as using untrusted plugins, libraries, or modules.
Vulnerable Example
// Loading JavaScript from an untrusted CDN without integrity check
function loadScripts()
{
echo '<script src="https://some-cdn.com/jquery.min.js"></script>';
}
// Auto-updating without verification
function updateApplication()
{
$updateZip = file_get_contents("https://updates.example.com/latest.zip");
file_put_contents("update.zip", $updateZip);
// Extract and apply without verifying signature or checksum
}
Secure Solution
namespace App\Service;
readonly class ScriptIntegrityService
{
// Type declaration for constants
private const array SCRIPT_INTEGRITY_HASHES = [
'jquery.min.js' => 'sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo',
'bootstrap.min.js' => 'sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl',
];
// Using Subresource Integrity for scripts
public function getIntegrityTagForScript(string $scriptName): string
{
if (!isset(self::SCRIPT_INTEGRITY_HASHES[$scriptName])) {
throw new \InvalidArgumentException("No integrity hash available for $scriptName");
}
return sprintf(
'<script src="https://some-cdn.com/%s" integrity="%s" crossorigin="anonymous"></script>',
$scriptName,
self::SCRIPT_INTEGRITY_HASHES[$scriptName]
);
}
// Generate all script tags with integrity
public function getAllScriptTags(): array
{
$tags = [];
foreach (self::SCRIPT_INTEGRITY_HASHES as $script => $hash) {
$tags[] = $this->getIntegrityTagForScript($script);
}
return $tags;
}
}
readonly class ApplicationUpdater
{
private const string SIGNATURE_ALGO = OPENSSL_ALGO_SHA256;
public function __construct(
private string $publicKeyPath,
private string $updateServer,
) {}
// Secure update process
public function update(): bool
{
// Download the update and its signature
$updateZip = file_get_contents("{$this->updateServer}/latest.zip");
$signature = file_get_contents("{$this->updateServer}/latest.zip.sig");
if ($updateZip === false || $signature === false) {
throw new \RuntimeException("Failed to download update files");
}
// Verify the signature using the public key
$publicKey = file_get_contents($this->publicKeyPath);
if ($publicKey === false) {
throw new \RuntimeException("Failed to read public key");
}
$verified = openssl_verify($updateZip, $signature, $publicKey, self::SIGNATURE_ALGO);
if ($verified !== 1) {
throw new \RuntimeException("Update signature verification failed");
}
file_put_contents("update.zip", $updateZip);
// Now safe to extract and apply the update
return true;
}
}
9. Security Logging and Monitoring Failures
Without proper logging and monitoring, attackers can remain undetected in your systems.
Vulnerable Example
namespace App\Controller;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class SecurityController extends AbstractController
{
// Insufficient logging
#[Route('/login', name: 'app_login', methods: ['POST'])]
public function login(
Request $request,
UserRepository $userRepository,
UserPasswordHasherInterface $passwordHasher
): Response {
$data = json_decode($request->getContent(), true);
$username = $data['username'] ?? '';
$password = $data['password'] ?? '';
$user = $userRepository->findOneByUsername($username);
if ($user && $passwordHasher->isPasswordValid($user, $password)) {
// Authentication logic...
return $this->json(['message' => 'Login successful']);
}
return $this->json(['message' => 'Invalid credentials'], Response::HTTP_UNAUTHORIZED);
}
}
namespace App\Handler\User;
use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
class DeleteUserHandler
{
public function __construct(
private UserRepository $userRepository,
private EntityManagerInterface $entityManager,
) {}
// Not logging important security events
public function handle(int $userId): void
{
$user = $this->userRepository->find($userId);
if (!$user) {
throw new \InvalidArgumentException('User not found');
}
$this->entityManager->remove($user);
$this->entityManager->flush();
}
}
Secure Solution
namespace App\Enum;
enum LogSeverity: string
{
case DEBUG = 'debug';
case INFO = 'info';
case WARNING = 'warning';
case ERROR = 'error';
case ALERT = 'alert';
case CRITICAL = 'critical';
}
namespace App\Controller;
use App\Entity\User;
use App\Enum\LogSeverity;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Psr\Log\LoggerInterface;
readonly class SecurityController extends AbstractController
{
public function __construct(
private LoggerInterface $securityLogger,
) {}
// Comprehensive security logging
#[Route('/login', name: 'app_login', methods: ['POST'])]
public function login(
Request $request,
UserRepository $userRepository,
UserPasswordHasherInterface $passwordHasher
): Response {
$data = json_decode($request->getContent(), true);
$username = $data['username'] ?? '';
$user = $userRepository->findOneByUsername($username);
if ($user && $passwordHasher->isPasswordValid($user, $data['password'] ?? '')) {
// Authentication logic...
// Using context array with logSecurityEvent helper method
$this->logSecurityEvent(
LogSeverity::INFO,
'Successful login',
[
'username' => $username,
'ip' => $request->getClientIp(),
'user_agent' => $request->headers->get('User-Agent')
]
);
return $this->json(['message' => 'Login successful']);
}
$this->logSecurityEvent(
LogSeverity::WARNING,
'Failed login attempt',
[
'username' => $username,
'ip' => $request->getClientIp(),
'user_agent' => $request->headers->get('User-Agent')
]
);
return $this->json(['message' => 'Invalid credentials'], Response::HTTP_UNAUTHORIZED);
}
// Helper method to standardize logging format
private function logSecurityEvent(LogSeverity $severity, string $message, array $context = []): void
{
// Add timestamp and unique request ID to all logs
$context['timestamp'] = (new \DateTimeImmutable())->format('c');
$context['request_id'] = $_SERVER['HTTP_X_REQUEST_ID'] ?? uniqid('req-', true);
// Log with appropriate level based on enum
match($severity) {
LogSeverity::DEBUG => $this->securityLogger->debug($message, $context),
LogSeverity::INFO => $this->securityLogger->info($message, $context),
LogSeverity::WARNING => $this->securityLogger->warning($message, $context),
LogSeverity::ERROR => $this->securityLogger->error($message, $context),
LogSeverity::ALERT => $this->securityLogger->alert($message, $context),
LogSeverity::CRITICAL => $this->securityLogger->critical($message, $context),
};
}
}
namespace App\Handler\User;
use App\Entity\User;
use App\Enum\LogSeverity;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
readonly class DeleteUserHandler
{
public function __construct(
private UserRepository $userRepository,
private EntityManagerInterface $entityManager,
private LoggerInterface $securityLogger,
private TokenStorageInterface $tokenStorage,
) {}
// Logging critical operations
public function handle(int $userId): void
{
$user = $this->userRepository->find($userId);
if (!$user) {
throw new \InvalidArgumentException('User not found');
}
$currentUser = $this->tokenStorage->getToken()?->getUser();
$currentUserId = $currentUser instanceof User ? $currentUser->getId() : null;
$this->entityManager->remove($user);
$this->entityManager->flush();
$this->securityLogger->alert('User deleted', [
'event' => 'user_deletion',
'deleted_user' => [
'id' => $userId,
'username' => $user->getUsername(),
],
'performed_by' => $currentUserId,
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'timestamp' => (new \DateTimeImmutable())->format('c'),
]);
}
}
namespace App\EventSubscriber;
use App\Enum\LogSeverity;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
use Symfony\Component\Security\Http\Event\LogoutEvent;
use Symfony\Component\HttpFoundation\RequestStack;
use Psr\Log\LoggerInterface;
readonly class SecurityEventSubscriber implements EventSubscriberInterface
{
// Type declaration for constants
private const string EVENT_TYPE_LOGIN = 'login';
private const string EVENT_TYPE_LOGOUT = 'logout';
private const string EVENT_TYPE_LOGIN_FAILURE = 'login_failure';
public function __construct(
private LoggerInterface $securityLogger,
private RequestStack $requestStack
) {}
public static function getSubscribedEvents(): array
{
return [
LoginSuccessEvent::class => 'onLoginSuccess',
LoginFailureEvent::class => 'onLoginFailure',
LogoutEvent::class => 'onLogout',
];
}
public function onLoginSuccess(LoginSuccessEvent $event): void
{
$request = $this->requestStack->getCurrentRequest();
$user = $event->getUser();
// Using PHP 8.4 enhanced string interpolation
$username = $user instanceof \Stringable ? (string)$user : 'unknown';
$this->logSecurityEvent(
LogSeverity::INFO,
"User '{$username}' logged in successfully",
[
'event_type' => self::EVENT_TYPE_LOGIN,
'username' => $username,
'ip' => $request?->getClientIp() ?? 'unknown',
'user_agent' => $request?->headers->get('User-Agent') ?? 'unknown',
'authentication_type' => $event->getAuthenticator()::class
]
);
}
public function onLoginFailure(LoginFailureEvent $event): void
{
$request = $this->requestStack->getCurrentRequest();
$exception = $event->getException();
$this->logSecurityEvent(
LogSeverity::WARNING,
'Login failed',
[
'event_type' => self::EVENT_TYPE_LOGIN_FAILURE,
'error' => $exception->getMessage(),
'error_code' => $exception->getCode(),
'authenticator' => $event->getAuthenticator()::class,
'ip' => $request?->getClientIp() ?? 'unknown',
'user_agent' => $request?->headers->get('User-Agent') ?? 'unknown'
]
);
}
public function onLogout(LogoutEvent $event): void
{
$request = $event->getRequest();
$token = $event->getToken();
$user = $token?->getUser();
$username = $user instanceof \Stringable ? (string)$user : 'unknown';
$this->logSecurityEvent(
LogSeverity::INFO,
"User '{$username}' logged out",
[
'event_type' => self::EVENT_TYPE_LOGOUT,
'username' => $username,
'ip' => $request->getClientIp() ?? 'unknown',
'user_agent' => $request->headers->get('User-Agent') ?? 'unknown'
]
);
}
// Helper method using PHP 8.4 features
private function logSecurityEvent(LogSeverity $severity, string $message, array $context = []): void
{
// Add standardized metadata to all security logs
$context['timestamp'] = (new \DateTimeImmutable())->format('c');
$context['request_id'] = $_SERVER['HTTP_X_REQUEST_ID'] ?? uniqid('req-', true);
// Log with appropriate level based on enum using match expression
match($severity) {
LogSeverity::DEBUG => $this->securityLogger->debug($message, $context),
LogSeverity::INFO => $this->securityLogger->info($message, $context),
LogSeverity::WARNING => $this->securityLogger->warning($message, $context),
LogSeverity::ERROR => $this->securityLogger->error($message, $context),
LogSeverity::ALERT => $this->securityLogger->alert($message, $context),
LogSeverity::CRITICAL => $this->securityLogger->critical($message, $context),
};
}
}
// Monitoring for suspicious activities
namespace App\Security;
use App\Enum\LogSeverity;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use App\Repository\LoginAttemptRepository;
readonly class SecurityMonitor implements EventSubscriberInterface
{
// Constants for thresholds
private const int FAILED_LOGIN_THRESHOLD = 5;
private const int MONITORING_WINDOW_MINUTES = 10;
private const array SENSITIVE_ROUTES = [
'/admin',
'/api/users',
'/settings',
];
public function __construct(
private LoggerInterface $securityLogger,
private RequestStack $requestStack,
private LoginAttemptRepository $loginAttemptRepository
) {}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 5], // Lower priority than SecurityEventListener
];
}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
$clientIp = $request->getClientIp();
// Check for too many failed login attempts
$this->checkForBruteForce($clientIp);
// Monitor access to sensitive routes
$this->monitorSensitiveRoutes($request->getPathInfo(), $clientIp);
}
private function checkForBruteForce(string $clientIp): void
{
$timeWindow = new \DateTimeImmutable("-" . self::MONITORING_WINDOW_MINUTES . " minutes");
$failedAttempts = $this->loginAttemptRepository->countRecentFailedAttempts(
$clientIp,
$timeWindow
);
if ($failedAttempts >= self::FAILED_LOGIN_THRESHOLD) {
$this->securityLogger->alert('Possible brute force attack detected', [
'ip' => $clientIp,
'failed_attempts' => $failedAttempts,
'window_minutes' => self::MONITORING_WINDOW_MINUTES
]);
// Here you could trigger additional protections:
// - Add IP to temporary blocklist
// - Enable CAPTCHA for this IP
// - Send alert to security team
}
}
private function monitorSensitiveRoutes(string $route, string $clientIp): void
{
foreach (self::SENSITIVE_ROUTES as $sensitiveRoute) {
if (str_starts_with($route, $sensitiveRoute)) {
$this->securityLogger->info('Access to sensitive route', [
'route' => $route,
'ip' => $clientIp,
'timestamp' => (new \DateTimeImmutable())->format('c')
]);
break;
}
}
}
}
10. Server-Side Request Forgery (SSRF)
SSRF allows attackers to induce your server to make requests to unintended locations.
Vulnerable Example
namespace App\Service;
// Fetching a URL without validation
class RemoteDataService
{
public function fetchRemoteData(string $url): string
{
$data = file_get_contents($url);
return $data;
}
}
namespace App\Integration;
// Using webhook URLs without validation
class WebhookService
{
public function processWebhook(string $webhookUrl, array $data): string
{
$ch = curl_init($webhookUrl);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
return curl_exec($ch);
}
}
Secure Solution
namespace App\Service;
use App\Exception\SecurityException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
readonly class RemoteDataService
{
// Type declaration for constants
private const array ALLOWED_HOSTS = [
'api.example.com',
'cdn.example.com',
'trusted-api.com'
];
public function __construct(
private HttpClientInterface $httpClient
) {}
// URL validation and allowlisting
public function fetchRemoteData(string $url): string
{
// Parse the URL
$parsedUrl = parse_url($url);
if (!isset($parsedUrl['host'])) {
throw new SecurityException("Invalid URL format");
}
$host = $parsedUrl['host'];
// Check if the host is in our allowlist
if (!in_array($host, self::ALLOWED_HOSTS)) {
throw new SecurityException("Request to non-allowlisted host denied: $host");
}
// Make sure we're not accessing internal networks
if (filter_var($host, FILTER_VALIDATE_IP)) {
$isInternalIp = filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) === false;
if ($isInternalIp) {
throw new SecurityException("Request to internal IP address denied");
}
}
// Using Symfony HTTP Client instead of file_get_contents
$response = $this->httpClient->request('GET', $url, [
'timeout' => 5,
'max_redirects' => 2, // Limit redirects to prevent redirect-based bypasses
]);
return $response->getContent();
}
// Helper method to validate URLs before using them
public function isUrlSafe(string $url): bool
{
try {
$parsedUrl = parse_url($url);
if (!isset($parsedUrl['host'])) {
return false;
}
$host = $parsedUrl['host'];
// Check allowlist
if (!in_array($host, self::ALLOWED_HOSTS)) {
return false;
}
// Check for internal IPs
if (filter_var($host, FILTER_VALIDATE_IP)) {
return filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) !== false;
}
return true;
} catch (\Throwable) {
return false;
}
}
}
namespace App\Integration;
use App\Exception\SecurityException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
readonly class WebhookService
{
private const array ALLOWED_WEBHOOK_DOMAINS = [
'hooks.slack.com',
'api.github.com',
'hooks.example.com'
];
private const string PROTOCOL = 'https://';
private const int TIMEOUT = 5;
public function __construct(
private HttpClientInterface $httpClient
) {}
public function processWebhook(string $webhookUrl, array $data): string
{
// Validate URL is using https
if (!str_starts_with($webhookUrl, self::PROTOCOL)) {
throw new SecurityException("Only HTTPS webhooks are allowed");
}
// Parse the URL
$parsedUrl = parse_url($webhookUrl);
if (!isset($parsedUrl['host'])) {
throw new SecurityException("Invalid webhook URL format");
}
$host = $parsedUrl['host'];
// Check against allowlist
if (!in_array($host, self::ALLOWED_WEBHOOK_DOMAINS)) {
throw new SecurityException("Webhook to non-allowlisted domain denied: $host");
}
// Using Symfony's HTTP client instead of curl
$response = $this->httpClient->request('POST', $webhookUrl, [
'json' => $data,
'timeout' => self::TIMEOUT,
'max_redirects' => 0, // Don't follow redirects for webhooks
]);
return $response->getContent();
}
// Helper method to check if webhook domain is allowed
public function isWebhookDomainAllowed(string $domain): bool
{
return in_array($domain, self::ALLOWED_WEBHOOK_DOMAINS);
}
}
// Example security exception class
namespace App\Exception;
class SecurityException extends \RuntimeException
{
public function __construct(
string $message,
int $code = 403,
\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
}