Les entrées sont hostiles
Fait partie de la section Authentification et sécurité du Journey Node.js de Coddy. Leçon 17 sur 17.
Tout ce qu’un client envoie peut avoir été rédigé par un attaquant. Un corps de 50 MB mobilise votre mémoire. Une note contenant <script> s’exécute comme du code dans le navigateur de toute personne qui la consulte : c’est une attaque de script intersites, XSS.
Limitez ce que vous acceptez : chaque analyseur de corps Express accepte une limit, et un corps plus volumineux entraîne une réponse 413 Charge utile trop volumineuse avant l’exécution de votre route. Stockez le texte exactement tel qu’il a été envoyé, puis échappez-le lorsque vous l’insérez dans du HTML, afin que le navigateur affiche les caractères au lieu de les exécuter. html.js vous fournit escapeHtml.
app.use(express.urlencoded({ limit: '2kb' }));
escapeHtml('<img src=x onerror=steal()>');
// '<img src=x onerror=steal()>'Défi
Facile- Un corps JSON de plus de 10 Ko reçoit une réponse 413.
- La page
GET /échappe le texte de chaque note ainsi que ton nom d’utilisateur.
Ajoute une note contenant le texte <script>alert('hi')</script> et envoie GET /. Ensuite, envoie une note de plus de 10 Ko.
Essayez vous-même
import express from 'express';
import { ObjectId } from 'mongodb';
import { randomBytes } from 'node:crypto';
import { parseCookies } from './cookies.js';
import { notes, sessions, users } from './db.js';
import { hashPassword, verifyPassword } from './passwords.js';
const app = express();
app.disable('x-powered-by');
app.use(express.json());
// En-têtes qui indiquent au navigateur de traiter chaque réponse avec prudence.
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'self'",
});
next();
});
// L’unique compte admin. Son mot de passe provient de l’environnement.
await users.insertOne({ username: 'admin', role: 'admin', passwordHash: await hashPassword(process.env.ADMIN_PASSWORD) });
app.post('/signup', async (req, res) => {
const username = String(req.body.username ?? '').trim().toLowerCase();
const password = String(req.body.password ?? '');
if (!username || password.length < 8) {
return res.status(400).json({ error: 'Pick a username and a password of at least 8 characters' });
}
if (await users.findOne({ username })) {
return res.status(409).json({ error: 'That username is taken' });
}
await users.insertOne({ username, role: 'member', passwordHash: await hashPassword(password) });
res.status(201).json({ username });
});
// Échecs de connexion par nom d’utilisateur : { count, since }.
const failures = new Map();
const WINDOW_MS = 60 * 1000;
app.post('/login', async (req, res) => {
const username = String(req.body.username ?? '').trim().toLowerCase();
let failed = failures.get(username);
if (failed && Date.now() - failed.since >= WINDOW_MS) {
failed = undefined;
}
if (failed && failed.count >= 5) {
res.set('Retry-After', String(Math.ceil((failed.since + WINDOW_MS - Date.now()) / 1000)));
return res.status(429).json({ error: 'Too many failed logins. Try again later.' });
}
const user = await users.findOne({ username });
if (!user || !(await verifyPassword(String(req.body.password ?? ''), user.passwordHash))) {
failures.set(username, { count: (failed?.count ?? 0) + 1, since: failed?.since ?? Date.now() });
return res.status(401).json({ error: 'Wrong username or password' });
}
const sid = randomBytes(24).toString('base64url');
await sessions.insertOne({ _id: sid, userId: user._id, createdAt: new Date() });
res.cookie('sid', sid, {
httpOnly: true,
sameSite: 'lax',
secure: true,
maxAge: 24 * 60 * 60 * 1000,
});
res.json({ username: user.username });
});
// Recherche l’utilisateur associé au cookie de session ou renvoie 401.
async function requireUser(req, res, next) {
const { sid } = parseCookies(req.get('cookie'));
const session = sid && (await sessions.findOne({ _id: sid }));
const user = session && (await users.findOne({ _id: session.userId }));
if (!user) {
return res.status(401).json({ error: 'Log in first' });
}
req.user = { id: user._id.toHexString(), username: user.username, role: user.role };
next();
}
// Renvoie 403 si l’utilisateur connecté n’a pas ce rôle.
function requireRole(role) {
return (req, res, next) => {
if (req.user.role !== role) {
return res.status(403).json({ error: 'Not allowed' });
}
next();
};
}
app.get('/me', requireUser, (req, res) => {
res.json({ username: req.user.username });
});
app.post('/logout', async (req, res) => {
const { sid } = parseCookies(req.get('cookie'));
if (sid) {
await sessions.deleteOne({ _id: sid });
}
res.clearCookie('sid');
res.status(204).end();
});
// Vos notes affichées sur une page.
app.get('/', requireUser, async (req, res) => {
const list = await notes.find({ owner: req.user.id }).toArray();
const items = list.map((note) => `<li>${note.text}</li>`).join('');
res.send(`<!doctype html><title>Vault</title><h1>${req.user.username}'s notes</h1><ul>${items}</ul>`);
});
// Notes : chaque utilisateur ne lit et n’écrit que les siennes.
app.get('/notes', requireUser, async (req, res) => {
res.json(await notes.find({ owner: req.user.id }).toArray());
});
app.post('/notes', requireUser, async (req, res) => {
const note = { owner: req.user.id, text: String(req.body.text ?? '') };
await notes.insertOne(note);
res.status(201).json(note);
});
app.get('/notes/:id', requireUser, async (req, res) => {
if (!ObjectId.isValid(req.params.id)) {
return res.status(404).json({ error: 'Note not found' });
}
const note = await notes.findOne({ _id: new ObjectId(req.params.id), owner: req.user.id });
if (!note) {
return res.status(404).json({ error: 'Note not found' });
}
res.json(note);
});
app.delete('/notes/:id', requireUser, async (req, res) => {
if (!ObjectId.isValid(req.params.id)) {
return res.status(404).json({ error: 'Note not found' });
}
const { deletedCount } = await notes.deleteOne({ _id: new ObjectId(req.params.id), owner: req.user.id });
if (!deletedCount) {
return res.status(404).json({ error: 'Note not found' });
}
res.status(204).end();
});
// Pour les admins : nombre de comptes existants.
app.get('/admin', requireUser, requireRole('admin'), async (req, res) => {
res.json({ users: await users.countDocuments() });
});
app.listen(3000, () => {
console.log('Vault is running on port 3000');
});
Toutes les leçons de Authentification et sécurité
4Propriétaires et sécurité
Seulement vos notesRôles d’administrateur