Menu
CoddyTech

Encabezados de seguridad

Parte de la sección Autenticación y seguridad del Journey de Node.js de Coddy. Lección 20 de 24.

Los navegadores hacen conjeturas y comparten información de forma predeterminada: pueden tratar una respuesta de texto como un script, ejecutar scripts desde cualquier lugar y, en navegadores antiguos, enviar la dirección completa de tu página a otros sitios. Unos pocos encabezados de respuesta desactivan eso:

  • X-Content-Type-Options: nosniff: confía en Content-Type, nunca hagas conjeturas.
  • Referrer-Policy: no-referrer: no les digas a otros sitios qué página los enlazó.
  • Content-Security-Policy: default-src 'self': carga scripts, estilos e imágenes solo desde este sitio.

Express también añade X-Powered-By: Express, que les dice a los atacantes qué probar. app.disable('x-powered-by') lo elimina. Un middleware establece encabezados para cada respuesta. Este establece otros dos encabezados: Strict-Transport-Security indica al navegador que use solo HTTPS durante un año, y Permissions-Policy desactiva la cámara y el micrófono.

app.use((req, res, next) => {
    res.set('Strict-Transport-Security', 'max-age=31536000');
    res.set('Permissions-Policy', 'camera=(), microphone=()');
    next();
});
challenge icon

Desafío

Fácil

Los navegadores hacen suposiciones que los atacantes pueden aprovechar. Envía tres encabezados con cada respuesta para desactivar esas suposiciones y deja de indicar a todo el mundo qué framework ejecuta tu servidor.

  1. En server.js, añade un middleware antes de todas las rutas. Este establece estos tres encabezados en cada respuesta, incluidas las respuestas de error como la 401:
    • X-Content-Type-Options: nosniff
    • Referrer-Policy: no-referrer
    • Content-Security-Policy: default-src 'self'. Solo la página en GET / necesita este encabezado, pero está bien establecerlo en cada respuesta.
  2. Express añade X-Powered-By: Express a cada respuesta. Desactívalo justo después de crear la aplicación.

Pruébalo: Pulsa «Ejecutar». Envía GET /notes sin iniciar sesión: el estado es 401. En la pestaña Headers aparecen X-Content-Type-Options: nosniff y Referrer-Policy: no-referrer, y no aparece X-Powered-By. Envía POST /signup y después POST /login, ambos con {"username": "ada", "password": "correct horse"}. Luego envía GET /: obtienes la página, con los mismos encabezados y además Content-Security-Policy: default-src 'self'.

Pruébalo tú mismo

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());

// La única cuenta de administrador. Su contraseña proviene del entorno.
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 });
});

// Busca al usuario asociado a la cookie de sesión o responde con 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();
}

// Responde con 403 a menos que el usuario que ha iniciado sesión tenga este rol.
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();
});

// Tus notas en forma de página.
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>`);
});

// Notas: cada usuario solo lee y escribe las suyas.
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();
});

// Para administradores: cuántas cuentas hay.
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');
});
quiz iconPonte a prueba

Esta lección incluye un breve cuestionario. Empieza la lección para responderlo y registrar tu progreso.

Todas las lecciones de Autenticación y seguridad

Practica por tu cuenta: Playground de Express