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:

๐Ÿ“š Learning Objectives

By the end of this tutorial, you will:

๐Ÿ” Why This Matters

In production environments, security hardening is crucial because:

๐Ÿ“ Step-by-Step Instructions

Step 1: MySQL Security Hardening

  1. Connect to your VM and run the MySQL security script:

    sudo mysql_secure_installation
    
  2. Answer 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
  3. Create a dedicated database user with limited privileges:

    sudo mysql -u root -p
    
  4. In 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;
    
  5. Update your database configuration to use the new restricted user:

    sudo nano /var/www/html/database_setup.php
    
  6. Update 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

  1. Create a custom PHP configuration for enhanced security:

    sudo nano /etc/php/8.1/fpm/conf.d/99-security.ini
    
  2. Add 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.log
    
  3. Restart PHP-FPM to apply the changes:

    sudo systemctl restart php8.1-fpm
    
  4. Update your PHP application files with improved input validation. Edit the contact form processor:

    sudo nano /var/www/html/on_post_contact.php
    
  5. Replace 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>
    
  6. Update the contact form to include CSRF protection:

    sudo nano /var/www/html/contact_form.html
    
  7. Convert it to PHP to enable CSRF tokens:

    sudo mv /var/www/html/contact_form.html /var/www/html/contact_form.php
    
  8. Update 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

  1. Create a security-focused Nginx configuration:

    sudo nano /etc/nginx/sites-available/default
    
  2. Replace 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;
    }
    
  3. 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

  1. 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 status
    
  2. Set 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.php
    
  3. Configure log rotation for security logs:

    sudo nano /etc/logrotate.d/security-logs
    
  4. Add 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

  1. Update the messages display script with better security:

    sudo nano /var/www/html/on_get_messages.php
    
  2. Replace 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>
    
  3. Update the main index page to point to the secure contact form:

    sudo nano /var/www/html/index.html
    
  4. Update 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

  1. Update the CSS file to include error styling:

    sudo nano /var/www/html/style.css
    
  2. Add 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

  1. 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

  1. Try to submit the form without the CSRF token
  2. Should receive “Security error: Invalid request token” message
  3. Normal form submission should still work correctly

Test 3: Test Input Validation

  1. Try submitting empty fields - should show validation errors
  2. Try submitting very long inputs - should be rejected
  3. Try submitting invalid email format - should be rejected

Test 4: Test Database Security

  1. Attempt to connect with old credentials:

    mysql -u php_user -pphp123
    

    Should fail with access denied error

  2. Verify new user has limited privileges:

    mysql -u contact_app -pStrongAppPassword123!
    

โœ… Expected Results

๐Ÿ”ง Troubleshooting

If CSRF protection breaks the form:

If security headers cause issues:

If database connection fails:

๐Ÿš€ Optional Challenge

Want to enhance security further? Try:

๐Ÿ“š Further Reading

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. ๐Ÿš€