Menu
CoddyTech

La entrada es hostil

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

Cualquier cosa que envíe un cliente podría haberla escrito un atacante. Un cuerpo de 50 MB ocupa tu memoria. Una nota que contenga <script> se ejecuta como código en el navegador de quien la vea: eso es secuencias de comandos entre sitios, XSS.

Limita lo que aceptas: cada analizador de cuerpos de Express acepta 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 incluyas 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
  • Un cuerpo JSON de más de 10 KB responde con 413.
  • La página GET / escapa el texto de cada nota y tu nombre de usuario.

Añade una nota con el texto <script>alert('hi')</script> y envía GET /. Después, envía una nota de más de 10 KB.

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

// Inicios de sesión fallidos 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 de 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');
});

Todas las lecciones de Autenticación y seguridad