Kodokon kodokon.com

Web security: XSS, CSRF and injections

Take apart XSS, CSRF and SQL injection to understand exactly what htmlspecialchars, tokens and prepared statements actually block.

12 min · 3 questions

Open this lesson in Kodokon

The three great families of web vulnerabilities - XSS, CSRF and injections - all share the same root cause: data supplied by the user ends up interpreted as code (HTML, SQL or a legitimate request). Let's start with XSS: if you display a value from $_GET without escaping, an attacker can inject a <script> tag and run JavaScript in your other visitors' browsers - stealing sessions, performing actions without their knowledge. With ?comment=<script>alert(1)</script>, the first output below runs the script; the second neutralizes it. Two flags matter here: ENT_QUOTES also converts single quotes (otherwise an attribute delimited by single quotes stays injectable), and ENT_SUBSTITUTE replaces invalid UTF-8 sequences instead of returning an empty string. Since PHP 8.1, both flags are finally the default, but spelling them out documents your intent and protects code that runs on older versions.

PHP
<?php
declare(strict_types=1);

$comment = $_GET['comment'] ?? '';

echo '<div>' . $comment . '</div>';

function e(string $value): string
{
    return htmlspecialchars(
        $value,
        ENT_QUOTES | ENT_SUBSTITUTE,
        'UTF-8'
    );
}

echo '<div>' . e($comment) . '</div>';
The first output is vulnerable, the second is properly escaped.

CSRF exploits one simple fact: the browser automatically attaches session cookies to any request toward your domain, even one triggered from a third-party site. A hostile form can therefore submit /account/delete using the victim's session. The classic defense is the synchronizer token: a random secret stored in the session and required in every mutating request. A third-party site can neither read it nor guess it, so it cannot supply it.

HTML
<?php
session_start();

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

$sent = $_POST['csrf'] ?? '';
$valid = is_string($sent)
    && hash_equals($_SESSION['csrf'], $sent);

$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
if ($isPost && !$valid) {
    http_response_code(403);
    exit('Invalid CSRF token');
}
?>
<form method="post">
  <input type="hidden" name="csrf"
    value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
  <button>Delete my account</button>
</form>
Token generated once per session, verified before any mutating action.

SQL injection follows the same pattern: concatenating a user value into a query amounts to letting the user write SQL - ' OR 1=1 -- turns a filter into the whole table. The solution is not escaping but the prepared statement: the query text (with its :email placeholders) and the values travel separately to the engine, which therefore can never confuse data with code. An expert detail: with MySQL, PDO emulates prepared statements by default, escaping on the client side; set PDO::ATTR_EMULATE_PREPARES to false for genuinely native prepared statements.

PHP
<?php
declare(strict_types=1);

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(
    PDO::ATTR_ERRMODE,
    PDO::ERRMODE_EXCEPTION
);
$pdo->exec(
    'CREATE TABLE users (
        id INTEGER PRIMARY KEY,
        email TEXT NOT NULL
    )'
);

$email = $_GET['email'] ?? '';

$stmt = $pdo->prepare(
    'SELECT id FROM users WHERE email = :email'
);
$stmt->execute(['email' => $email]);

var_dump($stmt->fetch(PDO::FETCH_ASSOC));
The value never joins the SQL text: injection is impossible.

Knowledge check

Make sure you remember the key points of this lesson.

  1. Why pass the ENT_QUOTES flag to htmlspecialchars?
    • To also convert single quotes, without which an attribute delimited by single quotes stays injectable
    • To force the string to be converted to UTF-8
    • To strip script tags out of the content
    • To speed up the escaping of long strings
  2. Why compare the CSRF token with hash_equals rather than ===?
    • hash_equals compares in constant time, which prevents guessing the token byte by byte by measuring response times
    • hash_equals automatically recomputes the token's hash before comparing
    • === fails as soon as the two strings have different encodings
  3. What makes a prepared statement immune to SQL injection?
    • It automatically escapes the quotes contained in the values
    • The query and the values are sent separately to the engine, which never reinterprets the values as SQL
    • It checks that each value matches the type of the target column
    • It restricts execution to a single SQL statement