Menu
CoddyTech

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()>');
// '&lt;img src=x onerror=steal()&gt;'
challenge icon

Desafio

Fácil

Trate 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.

  1. 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.
  2. Importe escapeHtml de ./html.js. Essa função transforma caracteres como < em &lt;, para que o navegador os exiba em vez de executá-los. Ela funciona como a função escapeHtml que você conheceu em Express Basics.
  3. Na página de GET /, passe o texto de cada nota e o nome de usuário por escapeHtml antes 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á &lt;script&gt; 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

Pratique por conta própria: Playground de Express