2. Security Hardening for LEMP Stack Applications
๐ฏ Goal
Learn essential security practices to harden your LEMP stack application, transforming the basic educational setup into a more production-ready deployment with proper security measures.
๐ Prerequisites
Before beginning this tutorial, you should:
- Have completed Tutorial 1: Manual LEMP Stack Installation
- Have a working LEMP stack with the contact form application
- Understand basic Linux system administration
- Be familiar with web application security concepts
๐ Learning Objectives
By the end of this tutorial, you will:
- Implement MySQL security hardening with proper user privileges
- Configure Nginx security headers and SSL/TLS preparation
- Apply PHP security configurations to prevent common vulnerabilities
- Set up proper file permissions and system hardening
- Understand input validation and sanitization best practices
- Configure firewall rules for network security
- Learn about log monitoring and security auditing
๐ Why This Matters
In production environments, security hardening is crucial because:
- It protects against common web application attacks (SQL injection, XSS, CSRF)
- It prevents unauthorized access to sensitive data and system resources
- It’s required for compliance with security standards and regulations
- It builds trust with users by protecting their personal information
- It reduces the risk of data breaches and system compromises
๐ Step-by-Step Instructions
Step 1: MySQL Security Hardening
Connect to your VM and run the MySQL security script:
sudo mysql_secure_installationAnswer the prompts to secure your MySQL installation:
- Set a strong root password (if not already set)
- Remove anonymous users: Yes
- Disallow root login remotely: Yes
- Remove test database: Yes
- Reload privilege tables: Yes
Create a dedicated database user with limited privileges:
sudo mysql -u root -pIn the MySQL console, create a restricted user for the application:
-- Create dedicated database and user CREATE DATABASE IF NOT EXISTS contact_db; CREATE USER IF NOT EXISTS 'contact_app'@'localhost' IDENTIFIED BY 'StrongAppPassword123!'; -- Grant only necessary privileges GRANT SELECT, INSERT, UPDATE, DELETE ON contact_db.* TO 'contact_app'@'localhost'; FLUSH PRIVILEGES; -- Remove the overprivileged php_user DROP USER IF EXISTS 'php_user'@'localhost'; EXIT;Update your database configuration to use the new restricted user:
sudo nano /var/www/html/database_setup.phpUpdate the credentials in the file:
<?php // Secure database configuration $host = 'localhost'; $username = 'contact_app'; $password = 'StrongAppPassword123!'; $database = 'contact_db'; try { // Connect with restricted user (no database creation privileges) $pdo = new PDO("mysql:host=$host;dbname=$database", $username, $password); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // Create table if it doesn't exist (requires ALTER privileges) $sql = "CREATE TABLE IF NOT EXISTS contacts ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(100) NOT NULL, message TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )"; $pdo->exec($sql); } catch(PDOException $e) { error_log("Database error: " . $e->getMessage()); die("Database connection failed. Please try again later."); } ?>
๐ก Information
- Principle of Least Privilege: Users should only have the minimum permissions needed
- mysql_secure_installation: Built-in script that removes common security vulnerabilities
- Dedicated database users: Separate users for different applications improve security
- Error logging: Prevents sensitive database errors from being displayed to users
Step 2: PHP Security Configuration
Create a custom PHP configuration for enhanced security:
sudo nano /etc/php/8.1/fpm/conf.d/99-security.iniAdd the following security settings:
; Hide PHP version information expose_php = Off ; Disable dangerous functions disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source ; Limit file uploads file_uploads = On upload_max_filesize = 2M max_file_uploads = 3 ; Limit POST data post_max_size = 8M max_input_vars = 1000 ; Session security session.cookie_httponly = 1 session.cookie_secure = 0 session.use_strict_mode = 1 session.cookie_samesite = "Strict" ; Hide errors in production display_errors = Off log_errors = On error_log = /var/log/php_errors.logRestart PHP-FPM to apply the changes:
sudo systemctl restart php8.1-fpmUpdate your PHP application files with improved input validation. Edit the contact form processor:
sudo nano /var/www/html/on_post_contact.phpReplace the content with enhanced security:
<?php // Start session for CSRF protection session_start(); // Include database setup require_once 'database_setup.php'; // CSRF token validation if ($_SERVER["REQUEST_METHOD"] == "POST") { // Basic CSRF protection if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) { die("Security error: Invalid request token."); } // Enhanced input validation and sanitization $name = filter_input(INPUT_POST, 'name', FILTER_SANITIZE_STRING); $email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL); $message = filter_input(INPUT_POST, 'message', FILTER_SANITIZE_STRING); // Validate inputs $errors = []; if (empty($name) || strlen($name) > 100) { $errors[] = "Name is required and must be less than 100 characters."; } if (empty($email) || !$email) { $errors[] = "Valid email address is required."; } if (empty($message) || strlen($message) > 1000) { $errors[] = "Message is required and must be less than 1000 characters."; } if (empty($errors)) { try { // Use prepared statements to prevent SQL injection $sql = "INSERT INTO contacts (name, email, message) VALUES (?, ?, ?)"; $stmt = $pdo->prepare($sql); $stmt->execute([$name, $email, $message]); $success = true; // Clear the CSRF token after successful submission unset($_SESSION['csrf_token']); } catch(PDOException $e) { error_log("Database error: " . $e->getMessage()); $errors[] = "An error occurred while saving your message. Please try again."; } } } else { // Redirect if accessed directly header("Location: contact_form.html"); exit(); } ?> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Message Status</title> <link rel="stylesheet" href="style.css"> </head> <body> <div class="container"> <?php if (isset($success) && $success): ?> <h1>Thank You!</h1> <p>Your message has been sent successfully.</p> <div class="form-container"> <p><strong>Name:</strong> <?php echo htmlspecialchars($name, ENT_QUOTES, 'UTF-8'); ?></p> <p><strong>Email:</strong> <?php echo htmlspecialchars($email, ENT_QUOTES, 'UTF-8'); ?></p> <p><strong>Message:</strong> <?php echo nl2br(htmlspecialchars($message, ENT_QUOTES, 'UTF-8')); ?></p> </div> <?php else: ?> <h1>Error</h1> <?php foreach ($errors as $error): ?> <p class="error"><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></p> <?php endforeach; ?> <?php endif; ?> <div class="navigation"> <a href="contact_form.html" class="button">โ Send Another Message</a> <a href="on_get_messages.php" class="button">View All Messages</a> <a href="index.html" class="button">Home</a> </div> </div> </body> </html>Update the contact form to include CSRF protection:
sudo nano /var/www/html/contact_form.htmlConvert it to PHP to enable CSRF tokens:
sudo mv /var/www/html/contact_form.html /var/www/html/contact_form.phpUpdate the content with CSRF protection:
<?php session_start(); // Generate CSRF token if (!isset($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } ?> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Contact Form</title> <link rel="stylesheet" href="style.css"> </head> <body> <div class="container"> <h1>Contact Us</h1> <div class="form-container"> <form action="on_post_contact.php" method="POST"> <input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token']; ?>"> <label for="name">Full Name:</label> <input type="text" id="name" name="name" maxlength="100" required> <label for="email">Email Address:</label> <input type="email" id="email" name="email" maxlength="100" required> <label for="message">Message:</label> <textarea id="message" name="message" rows="5" maxlength="1000" required></textarea> <button type="submit" class="submit-button">Send Message</button> </form> </div> <div class="navigation"> <a href="index.html" class="button">โ Back to Home</a> <a href="on_get_messages.php" class="button">View Messages</a> </div> </div> </body> </html>
๐ก Information
- CSRF Tokens: Prevent Cross-Site Request Forgery attacks
- Input Validation: Ensures data meets expected format and length requirements
- Prepared Statements: Prevent SQL injection attacks by separating code from data
- ENT_QUOTES: Escapes both single and double quotes in HTML output
- filter_input(): PHP’s built-in input filtering for sanitization
Step 3: Nginx Security Headers and Configuration
Create a security-focused Nginx configuration:
sudo nano /etc/nginx/sites-available/defaultReplace with a hardened configuration:
server { listen 80; root /var/www/html; index index.php index.html index.nginx-debian.html; # Security headers 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'; img-src 'self' data:; font-src 'self';" always; # Hide Nginx version server_tokens off; # Rate limiting for contact form location = /on_post_contact.php { limit_req zone=contact burst=5 nodelay; include snippets/fastcgi-php.conf; fastcgi_pass unix:/var/run/php/php8.1-fpm.sock; } # General PHP processing location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass unix:/var/run/php/php8.1-fpm.sock; # Hide PHP errors fastcgi_intercept_errors on; } # Deny access to sensitive files location ~ /\.(ht|git|svn) { deny all; } # Deny access to backup files location ~ \.(bak|backup|old|tmp)$ { deny all; } # Basic file serving location / { try_files $uri $uri/ =404; } # Prevent access to PHP files in uploads directory (if you add file uploads) location ^~ /uploads/ { location ~ \.php$ { deny all; } } } # Rate limiting configuration http { limit_req_zone $binary_remote_addr zone=contact:10m rate=1r/m; }Test and reload the configuration:
sudo nginx -t sudo systemctl reload nginx
โ ๏ธ Common Mistakes
- The rate limiting configuration needs to be in the http block, which might require editing
/etc/nginx/nginx.conf- Security headers should be added carefully to avoid breaking functionality
Step 4: System-Level Security Hardening
Configure the firewall (UFW) to allow only necessary ports:
# Enable UFW sudo ufw enable # Allow SSH (port 22) sudo ufw allow ssh # Allow HTTP (port 80) sudo ufw allow 'Nginx HTTP' # Allow HTTPS (port 443) for future SSL setup sudo ufw allow 'Nginx HTTPS' # Check firewall status sudo ufw statusSet up proper file permissions:
# Set secure permissions for web files sudo find /var/www/html -type f -exec chmod 644 {} \; sudo find /var/www/html -type d -exec chmod 755 {} \; # Ensure proper ownership sudo chown -R www-data:www-data /var/www/html # Protect configuration files sudo chmod 600 /var/www/html/database_setup.php sudo chown www-data:www-data /var/www/html/database_setup.phpConfigure log rotation for security logs:
sudo nano /etc/logrotate.d/security-logsAdd log rotation configuration:
/var/log/php_errors.log { weekly missingok rotate 52 compress notifempty create 644 www-data www-data }
Step 5: Input Validation and Error Handling
Update the messages display script with better security:
sudo nano /var/www/html/on_get_messages.phpReplace with enhanced security measures:
<?php // Include database setup require_once 'database_setup.php'; try { // Use prepared statement even for simple queries $sql = "SELECT id, name, email, message, created_at FROM contacts ORDER BY created_at DESC LIMIT 50"; $stmt = $pdo->prepare($sql); $stmt->execute(); $messages = $stmt->fetchAll(PDO::FETCH_ASSOC); } catch(PDOException $e) { error_log("Database error: " . $e->getMessage()); $error = "Unable to load messages at this time. Please try again later."; } ?> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>All Messages</title> <link rel="stylesheet" href="style.css"> <meta name="robots" content="noindex, nofollow"> </head> <body> <div class="container"> <h1>Contact Messages</h1> <?php if (isset($error)): ?> <p class="error"><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></p> <?php elseif (empty($messages)): ?> <p>No messages found. <a href="contact_form.php">Submit the first message!</a></p> <?php else: ?> <p>Showing latest <?php echo count($messages); ?> messages:</p> <?php foreach ($messages as $msg): ?> <div class="form-container" style="margin-bottom: 20px; border-left: 4px solid #007bff;"> <h3><?php echo htmlspecialchars($msg['name'], ENT_QUOTES, 'UTF-8'); ?></h3> <p><strong>Email:</strong> <?php echo htmlspecialchars($msg['email'], ENT_QUOTES, 'UTF-8'); ?></p> <p><strong>Message:</strong> <?php echo nl2br(htmlspecialchars($msg['message'], ENT_QUOTES, 'UTF-8')); ?></p> <p><strong>Submitted:</strong> <?php echo htmlspecialchars($msg['created_at'], ENT_QUOTES, 'UTF-8'); ?></p> </div> <?php endforeach; ?> <?php endif; ?> <div class="navigation"> <a href="contact_form.php" class="button">Submit New Message</a> <a href="index.html" class="button">โ Back to Home</a> </div> </div> </body> </html>Update the main index page to point to the secure contact form:
sudo nano /var/www/html/index.htmlUpdate the navigation links:
<div class="navigation"> <a href="contact_form.php" class="button">Submit a Message</a> <a href="on_get_messages.php" class="button">View All Messages</a> </div>
Step 6: Add CSS for Error Styling
Update the CSS file to include error styling:
sudo nano /var/www/html/style.cssAdd error styling at the end of the file:
/* Error message styling */ .error { background-color: #f8d7da; color: #721c24; padding: 10px; border: 1px solid #f5c6cb; border-radius: 4px; margin: 10px 0; } /* Success message styling */ .success { background-color: #d4edda; color: #155724; padding: 10px; border: 1px solid #c3e6cb; border-radius: 4px; margin: 10px 0; }
๐งช Final Tests
Test 1: Verify Security Headers
- Use browser developer tools (F12) to check response headers:
- Look for X-Frame-Options, X-Content-Type-Options, X-XSS-Protection
- Verify server tokens are hidden (no Nginx version shown)
Test 2: Test CSRF Protection
- Try to submit the form without the CSRF token
- Should receive “Security error: Invalid request token” message
- Normal form submission should still work correctly
Test 3: Test Input Validation
- Try submitting empty fields - should show validation errors
- Try submitting very long inputs - should be rejected
- Try submitting invalid email format - should be rejected
Test 4: Test Database Security
Attempt to connect with old credentials:
mysql -u php_user -pphp123Should fail with access denied error
Verify new user has limited privileges:
mysql -u contact_app -pStrongAppPassword123!
โ Expected Results
- Security headers appear in HTTP responses
- CSRF protection prevents unauthorized form submissions
- Input validation rejects invalid data
- Database access is restricted to necessary operations only
- Error messages don’t reveal sensitive system information
๐ง Troubleshooting
If CSRF protection breaks the form:
- Check that sessions are working:
php -m | grep session - Verify session directory permissions:
ls -la /var/lib/php/sessions
If security headers cause issues:
- Check browser console for Content Security Policy violations
- Temporarily remove CSP header to isolate issues
If database connection fails:
- Verify new user exists:
sudo mysql -u root -p -e "SELECT User FROM mysql.user;" - Check user privileges:
SHOW GRANTS FOR 'contact_app'@'localhost';
๐ Optional Challenge
Want to enhance security further? Try:
- Implementing SSL/TLS with Let’s Encrypt certificates
- Adding login/authentication system with password hashing
- Implementing rate limiting for all endpoints
- Adding intrusion detection with fail2ban
- Setting up centralized logging with rsyslog
๐ Further Reading
- OWASP Web Application Security Guide - Comprehensive web security testing guide
- PHP Security Best Practices - OWASP - PHP-specific security guidelines
- Nginx Security Headers Guide - Nginx security configuration
- MySQL Security Best Practices - Official MySQL security guide
- W3Schools PHP Security - Basic PHP security concepts
Done! ๐
Excellent work! You’ve successfully hardened your LEMP stack application with essential security measures including CSRF protection, input validation, secure database practices, and system-level security! These practices form the foundation for secure web application development. ๐