Menu
CoddyTech

La entrada es hostil

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

Cualquier cosa que envíe un cliente puede haber sido escrita por un atacante. Un cuerpo de 50 MB ocupa tu memoria. Una nota que contiene <script> se ejecuta como código en el navegador de quien la vea: eso es secuencias de comandos en sitios cruzados, XSS.

Limita lo que aceptas: todos los analizadores de cuerpos de Express aceptan un limit, y un cuerpo más grande responde con 413 Payload Too Large antes de que se ejecute tu ruta. Almacena el texto exactamente como se envió y escápalo cuando lo insertes en HTML, para que el navegador muestre los caracteres en lugar de ejecutarlos. html.js te proporciona escapeHtml.

app.use(express.urlencoded({ limit: '2kb' }));

escapeHtml('<img src=x onerror=steal()>');
// '&lt;img src=x onerror=steal()&gt;'
challenge icon

Desafío

Fácil

Trata todo lo que envía un cliente como si pudiera haberlo escrito un atacante. Rechaza los cuerpos demasiado grandes y haz que la página de notas muestre como texto sin formato el HTML de los usuarios, en lugar de ejecutarlo.

  1. En server.js, establece en 10 KB el límite del analizador de cuerpos JSON al principio. Un cuerpo más grande recibe entonces el estado 413 Payload Too Large antes de que se ejecute ninguna ruta. Aun así, se debe guardar una nota de 9,000 caracteres.
  2. Importa escapeHtml desde ./html.js. Convierte caracteres como < en &lt;, para que el navegador los muestre en lugar de ejecutarlos. Funciona como escapeHtml, que viste en Fundamentos de Express.
  3. En la página de GET /, pasa el texto de cada nota y el nombre de usuario por escapeHtml antes de insertarlos en el HTML. Guarda las notas exactamente como se recibieron. Escápalas solo cuando el texto se inserte en HTML.

Pruébalo: Pulsa «Ejecutar». Envía POST /signup y después POST /login, ambos con {"username": "ada", "password": "correct horse"}. Envía POST /notes con {"text": "<script>alert('hi')</script>"}: estado 201. Envía GET /: la lista muestra <script>alert('hi')</script> como palabras sin formato, y el código fuente de la página contiene &lt;script&gt; donde estaba la etiqueta. Las comprobaciones también envían por ti una nota de 11,000 caracteres y esperan el estado 413, así que no tienes que escribirla.

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.disable('x-powered-by');
app.use(express.json());

// Cabeceras que indican al navegador que trate cada respuesta con precaución.
app.use((req, res, next) => {
    res.set({
        'X-Content-Type-Options': 'nosniff',
        'Referrer-Policy': 'no-referrer',
        'Content-Security-Policy': "default-src 'self'",
    });
    next();
});

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

// Intentos fallidos de inicio de sesión por nombre de usuario: { 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 });
});

// 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 una 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');
});

Todas las lecciones de Autenticación y seguridad

Practica por tu cuenta: Playground de Express