En-têtes de sécurité
Fait partie de la section Authentification et sécurité du Journey Node.js de Coddy. Leçon 20 sur 24.
Par défaut, les navigateurs devinent et partagent des informations : ils peuvent traiter une réponse texte comme un script, exécuter des scripts provenant de n’importe où et, dans les anciens navigateurs, envoyer l’adresse complète de votre page à d’autres sites. Quelques en-têtes de réponse désactivent ces comportements :
X-Content-Type-Options: nosniff: faites confiance àContent-Type, ne devinez jamais.Referrer-Policy: no-referrer: ne dites pas aux autres sites quelle page contient un lien vers eux.Content-Security-Policy: default-src 'self': chargez les scripts, les feuilles de style et les images uniquement depuis ce site.
Express ajoute également X-Powered-By: Express, qui indique aux attaquants quoi essayer. app.disable('x-powered-by') le supprime. Un middleware définit des en-têtes pour chaque réponse. Celui-ci définit deux autres en-têtes : Strict-Transport-Security indique au navigateur d’utiliser uniquement HTTPS pendant un an, et Permissions-Policy désactive la caméra et le microphone.
app.use((req, res, next) => {
res.set('Strict-Transport-Security', 'max-age=31536000');
res.set('Permissions-Policy', 'camera=(), microphone=()');
next();
});Défi
FacileLes navigateurs font des suppositions dont les attaquants peuvent tirer parti. Envoyez trois en-têtes avec chaque réponse pour désactiver ces suppositions, et cessez d’indiquer à tout le monde quel framework fait fonctionner votre serveur.
- Dans
server.js, ajoutez un middleware avant toutes les routes. Il définit ces trois en-têtes sur chaque réponse, y compris les réponses d’erreur comme une 401 :X-Content-Type-Options: nosniffReferrer-Policy: no-referrerContent-Security-Policy: default-src 'self'. Seule la pageGET /en a besoin, mais il est tout à fait acceptable de le définir sur chaque réponse.
- Express ajoute
X-Powered-By: Expressà chaque réponse. Désactivez-le juste après avoir créé l’application.
Essayez : Appuyez sur « Jouer ». Envoyez GET /notes sans vous connecter : le statut est 401. L’onglet Headers affiche X-Content-Type-Options: nosniff et Referrer-Policy: no-referrer, et aucun X-Powered-By. Envoyez POST /signup, puis POST /login, avec {"username": "ada", "password": "correct horse"} pour les deux. Envoyez ensuite GET / : vous obtenez la page, avec les mêmes en-têtes, auxquels s’ajoute Content-Security-Policy: default-src 'self'.
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.use(express.json());
// Le seul compte administrateur. 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 });
});
app.post('/login', async (req, res) => {
const username = String(req.body.username ?? '').trim().toLowerCase();
const user = await users.findOne({ username });
if (!user || !(await verifyPassword(String(req.body.password ?? ''), user.passwordHash))) {
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 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 administrateurs : le 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');
});
Cette leçon comprend un petit quiz. Commencez la leçon pour y répondre et suivre votre progression.
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