Fuite d’état serverless : garder les données utilisateur par requête
Comment la réutilisation des runtimes serverless peut exposer des données périmées, et isoler identité et état par requête.
💡 Explication simple (ELI5)
Traitez l’authentification, l’identité du tenant et les données de requête comme un badge visiteur : elles appartiennent à une visite et ne doivent pas rester sur un plan de travail partagé. Certains runtimes serverless réutilisent un environnement ou un isolate pour des requêtes ultérieures. Si le code conserve le contexte d’un utilisateur dans un état mutable au niveau du module et qu’une autre requête le lit, l’application peut franchir sa propre frontière entre utilisateurs ou tenants.
La réutilisation est un comportement d’implémentation, pas la garantie que l’utilisateur suivant atteindra le même runtime ni que celui-ci restera actif pendant une durée fixe. Une fuite exige à la fois un état d’exécution partagé et un chemin de code qui conserve les données de la requête ou lit un état périmé. Gardez l’identité dans la requête et transmettez-la explicitement aux opérations qui en ont besoin.
Concepts clés et termes
- Execution environment reuse
- Réutilisation de l’environnement : la plateforme peut garder un runtime ou isolate disponible pour d’autres invocations, mais peut aussi l’arrêter à tout moment.
- Module-level state
- État au niveau du module : valeur initialisée hors du handler. Elle peut subsister entre invocations dans ce runtime et ne doit pas contenir de données propres à un utilisateur ou tenant.
- AsyncLocalStorage
- AsyncLocalStorage : API Node.js qui propage un store dans un contexte asynchrone. Elle suit le contexte ; elle n’authentifie ni n’autorise un utilisateur.
- Cross-request data exposure
- Exposition inter-requêtes : une requête ultérieure lit des données conservées par une précédente dans le même contexte réutilisé. La réutilisation seule ne prouve pas une fuite.
Flux d’exposition des données, étape par étape
Une requête crée ses propres données
La requête A s’authentifie et résout son contexte utilisateur ou tenant. Le défaut apparaît si le code écrit cette identité dans une variable mutable au niveau du module au lieu de la garder dans le handler.
Le runtime peut être réutilisé
Après la réponse, la plateforme peut garder l’environnement actif. AWS documente cette réutilisation ; Cloudflare Workers peut réutiliser un isolate, mais le routage et la durée de vie ne définissent pas une séquence fixe entre utilisateurs.
Une requête ultérieure atteint le même runtime
Si la requête B est traitée par le même processus ou isolate, la valeur de module laissée par A reste disponible jusqu’à son remplacement ou sa suppression. Un retour anticipé ou une authentification manquante peut rendre l’ancien état accessible.
L’application renvoie des données sous la mauvaise identité
Si le handler utilise cette valeur périmée pour une requête de base de données ou une réponse, B peut recevoir des données associées à A. C’est un défaut conditionnel d’isolation applicative, pas une propriété inévitable du serverless.
Code source : exemple défectueux et modèle sûr
// UNSAFE: request identity is mutable process-wide state.
import { NextResponse } from "next/server";
let cachedUserContext: { userId: string; role: string } | null = null;
export async function POST(req: Request) {
const token = req.headers.get("authorization");
if (token) {
cachedUserContext = await decodeUser(token);
}
// If token is missing, stale state can still be used.
return NextResponse.json({ actor: cachedUserContext?.userId });
}
// SAFE PATTERN: keep identity local and pass it to the service.
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const user = await authenticate(req);
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const account = await loadAccountForUser(user.id);
return NextResponse.json({ actor: user.id, account });
}
// The service enforces ownership in its query:
async function loadAccountForUser(userId: string) {
return db.account.findFirst({ where: { ownerId: userId } });
}
Liste de durcissement technique
- Gardez authentification, identité utilisateur, ID de tenant et données mutables de requête dans le handler ou un contexte explicitement propre à la requête ; ne les stockez pas dans des variables globales de module.
- Préférez des paramètres explicites pour transmettre l’identité authentifiée aux services et à l’accès aux données. Sous Node.js, utilisez
AsyncLocalStorage.run()uniquement si un contexte asynchrone implicite est nécessaire ; il propage le contexte sans autoriser l’opération. - Ne confondez pas un singleton de client de base de données avec un état utilisateur. Réutilisez les clients si nécessaire, mais transmettez l’utilisateur/tenant authentifié à chaque requête et imposez la propriété dans son prédicat.
- Utilisez
React cache()uniquement pour la mémoïsation par requête documentée dans React Server Components ; pas comme cache d’authentification général inter-requêtes. - Ajoutez un test d’intégration entrelaçant les requêtes d’utilisateurs distincts et une requête non authentifiée ; vérifiez que chaque réponse et opération de base reste liée à son identité.
- Consultez la documentation du cycle de vie de la plateforme. Testez en supposant qu’un environnement peut être réutilisé, sans supposer quelle requête sera envoyée à quelle instance.