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()>');
// '<img src=x onerror=steal()>'Desafío
FácilTrata 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.
- 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. - Importa
escapeHtmldesde./html.js. Convierte caracteres como<en<, para que el navegador los muestre en lugar de ejecutarlos. Funciona comoescapeHtml, que viste en Fundamentos de Express. - En la página de
GET /, pasa el texto de cada nota y el nombre de usuario porescapeHtmlantes 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 <script> 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
1Contraseñas
Bytes y texto hexadecimalDerivar una clave de contraseñaUna sal nuevaNunca almacenes contraseñasComparar bytes de claveVerificar una contraseñaReglas de registro4Propietarios y seguridad
Solo tus notasRoles de administraciónPractica por tu cuenta: Playground de Express