Skip to content

How-to : API de boîte de réception de notifications

Référence FT : FT271 (NENE2-FT/notificationlog) — Boîte de réception de notifications : création de notification avec liste blanche de types, protection IDOR par utilisateur (404 pas 403), pattern admin à défaut fermé, marquage en masse comme lu, idempotence de is_read, limitation de pagination avec liaison PDO::PARAM_INT, 31 tests / 98 assertions PASS.

Également validé dans FT222 (NENE2-FT/notificationlog) — évaluation VULN sur le même pattern.

Ce guide montre comment construire un système de boîte de réception de notifications avec des notifications push à liste blanche de types, une protection IDOR par utilisateur, et un marquage en masse comme lu avec NENE2.

Fonctionnalités

  • Création de notification réservée aux admins avec liste blanche de types
  • Protection IDOR par utilisateur : les utilisateurs voient uniquement leurs propres notifications (404 en cas d'accès non autorisé)
  • Marquage comme lu unitaire et en masse avec vérification de propriété
  • Nombre de non-lus retourné à chaque listage
  • Filtre de non-lus uniquement optionnel et pagination
  • Admin à défaut fermé

Schéma

sql
CREATE TABLE IF NOT EXISTS notifications (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL,
    type       TEXT    NOT NULL,
    title      TEXT    NOT NULL,
    body       TEXT    NOT NULL DEFAULT '',
    is_read    INTEGER NOT NULL DEFAULT 0,
    created_at TEXT    NOT NULL,
    read_at    TEXT
);

CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_id, id DESC);

Pas de table users séparée — l'API fait confiance à l'en-tête X-User-Id (remplacer par une vraie auth en production).

Endpoints

MéthodeCheminAuthDescription
POST/notificationsAdminCréer une notification pour un utilisateur
GET/users/{userId}/notificationsSoi-même / AdminLister les notifications
POST/notifications/{id}/readSoi-même / AdminMarquer une notification comme lue
POST/users/{userId}/notifications/read-allSoi-même / AdminMarquer tout comme lu

Liste blanche de types

Les chaînes de type en forme libre sont rejetées pour prévenir les attaques d'injection et d'énumération :

php
public const array ALLOWED_TYPES = [
    'system',
    'promotion',
    'social',
    'account',
    'security',
    'reminder',
];

Le gestionnaire de route valide avant tout accès DB :

php
if (!in_array($type, NotificationRepository::ALLOWED_TYPES, true)) {
    $allowed = implode(', ', NotificationRepository::ALLOWED_TYPES);
    return $this->problem(422, 'validation-failed', "type must be one of: {$allowed}.");
}

Protection IDOR

Les utilisateurs peuvent uniquement lire leurs propres notifications. Un 404 (pas 403) est retourné en cas d'accès non autorisé pour prévenir l'énumération des IDs utilisateur :

php
private function isSelfOrAdmin(ServerRequestInterface $req, int $ownerId): bool
{
    if ($this->isAdmin($req)) {
        return true;
    }
    $uid = $this->requestUserId($req);
    return $uid !== null && $uid === $ownerId;
}

Le marquage comme lu vérifie aussi la propriété avant d'agir :

php
// Gestionnaire POST /notifications/{id}/read
$notification = $this->repo->findById($id);
if ($notification === null) {
    return $this->problem(404, 'not-found', 'Notification not found.');
}
// IDOR : seul le propriétaire ou l'admin peut marquer comme lu
if (!$this->isSelfOrAdmin($req, (int) $notification['user_id'])) {
    return $this->problem(404, 'not-found', 'Notification not found.');
}

Admin à défaut fermé

php
private function isAdmin(ServerRequestInterface $req): bool
{
    if ($this->adminKey === '') {
        return false;   // à défaut fermé : pas d'admin si la clé n'est pas configurée
    }
    $key = $req->getHeaderLine('X-Admin-Key');
    return $key !== '' && hash_equals($this->adminKey, $key);
}

Pagination

limit et offset sont limités dans le repository — jamais approuvés bruts depuis le client :

php
private const int MAX_LIMIT = 100;

$limit  = max(1, min(self::MAX_LIMIT, $limit));
$offset = max(0, $offset);

La liaison entière PDO prévient l'injection SQL dans LIMIT / OFFSET :

php
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
$stmt->bindValue(':off', $offset, PDO::PARAM_INT);

Idempotence du marquage comme lu

php
/** @return 'ok'|'not_found'|'already_read' */
public function markAsRead(int $id): string
{
    $notification = $this->findById($id);
    if ($notification === null) return 'not_found';
    if ((bool) $notification['is_read']) return 'already_read';

    // ... UPDATE SET is_read = 1, read_at = :now ...
    return 'ok';
}

Le gestionnaire de route retourne 200 pour ok et already_read — rendant l'endpoint sûr à appeler plusieurs fois sans effets secondaires.

Patterns de sécurité

PatternImplémentation
Liste blanche de typesin_array($type, ALLOWED_TYPES, true) — correspondance stricte
IDOR → 404Retourner 404 (pas 403) pour cacher l'existence utilisateur/notification
Vérification de propriétéRécupérer la notification, vérifier user_id avant de marquer comme lu
Admin à défaut ferméif ($this->adminKey === '') return false;
ctype_digit()Validation de l'ID du paramètre de chemin — sûr contre le ReDoS
Limitation de paginationmax(1, min(100, $limit)) + liaison PDO::PARAM_INT
is_int() + > 0Vérification stricte user_id — rejette les floats, chaînes, négatifs

À ne pas faire

Anti-patternRisque
Accepter des chaînes type en forme libreLes types non validés polluent la boîte de réception ; impossible de filtrer par catégories significatives
Retourner 403 sur l'accès non autorisé à une notificationRévèle si la notification ou l'utilisateur existe — fuite d'information IDOR
Retourner 404 lors du marquage comme lu avant la vérification de propriétéUn attaquant apprend que la notification existe et appartient à quelqu'un
Permettre à adminKey vide de signifier "admin autorisé"À défaut ouvert ; toute requête devient admin si aucune clé n'est configurée
Faire confiance au limit brut de la chaîne de requêteUne requête avec limit=999999 cause un scan de table complet
Utiliser l'interpolation de chaîne dans LIMIT/OFFSET"LIMIT {$limit}" avec entrée non validée permet l'injection SQL

Publié sous licence MIT.