0

XSS — Cross-Site Scripting, htmlspecialchars, Content-Security-Policy

Intermediate5 min read·php-16-002
securityinterview

Concept

Cross-Site Scripting (XSS) allows attackers to inject JavaScript into web pages viewed by other users. The injected script runs with the victim's session — it can steal cookies, tokens, make API calls as the victim, or redirect to phishing pages.

Three types:

  1. Stored XSS: Malicious input saved to database and rendered to other users (comments, profiles, forum posts).
  2. Reflected XSS: Input from the URL/form immediately reflected in the response without storage (search queries, error messages).
  3. DOM-based XSS: JavaScript reads attacker-controlled data and writes it to the DOM without server-side involvement.

The fix: output encoding. When rendering user data in HTML, encode HTML entities: <script> becomes &lt;script&gt;. The browser renders the text, not the script.

htmlspecialchars(string $string, int $flags = ENT_QUOTES|ENT_SUBSTITUTE, string $encoding = 'UTF-8'): Converts <, >, &, ", ' to their HTML entity equivalents. Always pass ENT_QUOTES to also escape single quotes (important for attributes).

Context matters:

  • HTML body: htmlspecialchars($value).
  • HTML attribute: htmlspecialchars($value, ENT_QUOTES).
  • JavaScript context: JSON-encode: json_encode($value, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP).
  • URL context: urlencode($value) or rawurlencode($value).
  • CSS context: Avoid dynamic CSS values; if unavoidable, allow only [a-zA-Z0-9].

Laravel Blade: {{ $value }} automatically calls htmlspecialchars(). {!! $value !!} bypasses escaping — use only for trusted HTML (e.g., from a markdown renderer).

Code Example

php
<?php
declare(strict_types=1);

$userInput = '<script>document.cookie="stolen="+document.cookie</script>';
$username = 'O\'Brien <Alice>';

// VULNERABLE — raw output
echo "Hello, $userInput"; // script executes in browser!

// SAFE — HTML-encode output
echo "Hello, " . htmlspecialchars($userInput, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
// Output: Hello, &lt;script&gt;...&lt;/script&gt; — safe text, not executed

// SAFE — attribute context
echo '<input value="' . htmlspecialchars($username, ENT_QUOTES) . '">';
// Output: <input value="O&#039;Brien &lt;Alice&gt;">

// SAFE — URL context
echo '<a href="/profile?name=' . urlencode($username) . '">Profile</a>';

// SAFE — JavaScript context (embed PHP data in JS)
$data = ['name' => $username, 'role' => 'admin'];
echo '<script>var userData = ' . json_encode($data, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) . ';</script>';

// Blade templates — auto-escaped
// {{ $userComment }} → htmlspecialchars applied automatically
// {!! $userComment !!} → RAW — only use for trusted content like:
// {!! Markdown::parse($description) !!}  // trusted rendered HTML

// Content Security Policy header — defense in depth
// Even if XSS occurs, CSP blocks inline script execution
header("Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'");

// Laravel middleware — set security headers
// Use: composer require bepsvpt/secure-headers
// Or set manually in AppServiceProvider::boot():
use Illuminate\Http\Response;
Response::macro('withSecurityHeaders', function() {
    return $this
        ->header('X-Content-Type-Options', 'nosniff')
        ->header('X-Frame-Options', 'SAMEORIGIN')
        ->header('Referrer-Policy', 'strict-origin-when-cross-origin');
});