Toute entrée est hostile
Fait partie de la section Authentification et sécurité du Journey Node.js de Coddy. Leçon 24 sur 24.
Tout ce qu’un client envoie peut avoir été écrit par un attaquant. Un corps de 50 MB monopolise 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 faille de script intersite, XSS.
Limitez ce que vous acceptez : chaque analyseur de corps Express prend une limit, et un corps plus volumineux entraîne une réponse 413 Payload Too Large avant l’exécution de votre route. Stockez le texte exactement tel qu’il a été envoyé, et é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
FacileConsidère tout ce qu’un client envoie comme pouvant avoir été écrit par un attaquant. Refuse les corps trop volumineux et fais en sorte que la page des notes affiche le HTML envoyé par les utilisateurs sous forme de texte brut au lieu de l’exécuter.
- Dans
server.js, définis une limite de 10 Ko pour l’analyseur de corps JSON, en haut du fichier. Un corps plus volumineux reçoit alors le statut 413 (charge utile trop volumineuse) avant l’exécution de toute route. Une note de 9 000 caractères doit tout de même être enregistrée. - Importe
escapeHtmldepuis./html.js. Cette fonction transforme les caractères comme<en<, afin que le navigateur les affiche au lieu de les exécuter. Elle fonctionne comme la fonctionescapeHtmlque tu as découverte dans les bases d’Express. - Dans la page à l’adresse
GET /, fais passer le texte de chaque note et le nom d’utilisateur parescapeHtmlavant de les insérer dans le HTML. Continue à enregistrer les notes exactement comme elles ont été envoyées. N’échappe le texte que lorsqu’il est inséré dans le HTML.
À essayer : Appuie sur « Jouer ». Envoie POST /signup, puis POST /login, tous deux avec {"username": "ada", "password": "correct horse"}. Envoie POST /notes avec {"text": "<script>alert('hi')</script>"} : le statut est 201. Envoie GET / : la liste affiche <script>alert('hi')</script> sous forme de texte brut, et le code source de la page contient <script> à l’endroit où se trouvait la balise. Les vérifications envoient aussi une note de 11 000 caractères et attendent le statut 413 ; tu n’as donc pas besoin d’en saisir une.
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 indiquant au navigateur de traiter chaque réponse avec précaution.
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'self'",
});
next();
});
// Le seul 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 });
});
// Connexions échouées 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 });
});
// Trouve 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 sous forme de 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é
1Mots de passe
Octets et texte hexadécimalDériver une clé de mot de passeUn nouveau selNe stockez jamais les mots de passeComparer les octets de cléVérifier un mot de passeRègles d’inscription4Propriétaires et sécurité
Vos notes uniquementRôles d’administrationEntraînez-vous par vous-même : Playground Express