A entrada é hostil
Parte da seção Autenticação e segurança do Journey de Node.js da Coddy. Lição 24 de 24.
Qualquer coisa que um cliente envie pode ter sido escrita por um invasor. Um corpo de 50 MB ocupa sua memória. Uma nota contendo <script> é executada como código no navegador de quem a visualiza: isso é cross-site scripting, XSS.
Limite o que você aceita: todo analisador de corpo do Express aceita um limit, e um corpo maior gera a resposta 413 Payload Too Large antes que sua rota seja executada. Armazene o texto exatamente como foi enviado e escape-o quando colocá-lo em HTML, para que o navegador mostre os caracteres em vez de executá-los. html.js fornece escapeHtml.
app.use(express.urlencoded({ limit: '2kb' }));
escapeHtml('<img src=x onerror=steal()>');
// '<img src=x onerror=steal()>'Desafio
FácilTrate tudo o que um cliente envia como algo que pode ter sido escrito por um invasor. Recuse corpos grandes demais e faça a página de notas exibir o HTML enviado pelos usuários como texto simples, em vez de executá-lo.
- Em
server.js, defina um limite de 10 KB para o analisador de corpos JSON no início do arquivo. Um corpo maior receberá o status 413 Payload Too Large antes que qualquer rota seja executada. Uma nota com 9.000 caracteres ainda deve ser salva. - Importe
escapeHtmlde./html.js. Essa função transforma caracteres como<em<, para que o navegador os exiba em vez de executá-los. Ela funciona como a funçãoescapeHtmlque você conheceu em Express Basics. - Na página de
GET /, passe o texto de cada nota e o nome de usuário porescapeHtmlantes de inseri-los no HTML. Continue salvando as notas exatamente como foram enviadas. Escape o texto somente quando ele for inserido no HTML.
Experimente: Pressione Executar. Envie POST /signup e, em seguida, POST /login, ambos com {"username": "ada", "password": "correct horse"}. Envie POST /notes com {"text": "<script>alert('hi')</script>"}: o status será 201. Envie GET /: a lista exibirá <script>alert('hi')</script> como palavras simples, e o código-fonte da página terá <script> onde estava a tag. As verificações também enviarão uma nota com 11.000 caracteres e esperarão o status 413, então você não precisa digitar uma nota desse tamanho.
Experimente você mesmo
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());
// Cabeçalhos que instruem o navegador a tomar cuidado com cada resposta.
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'self'",
});
next();
});
// A única conta de administrador. Sua senha vem do ambiente.
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 });
});
// Tentativas de login malsucedidas por nome de usuário: { 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 });
});
// Encontra o usuário associado ao cookie de sessão ou responde com 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 com 403, a menos que o usuário autenticado tenha essa função.
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();
});
// Suas notas em uma 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 usuário lê e escreve apenas as suas próprias.
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: quantas contas existem.
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 as lições de Autenticação e segurança
1Senhas
Bytes e texto hexadecimalDerivando uma chave de senhaUm novo saltNunca armazene senhasComparando bytes da chaveVerificando uma senhaRegras de cadastro4Proprietários e Segurança
Somente suas anotaçõesFunções de administradorPratique por conta própria: Playground de Express