Menu
CoddyTech

Cabeçalhos de segurança

Parte da seção Autenticação e segurança do Journey de Node.js da Coddy. Lição 20 de 24.

Os navegadores adivinham e compartilham por padrão: eles podem tratar uma resposta de texto como um script, executar scripts de qualquer lugar e, em navegadores mais antigos, enviar o endereço completo da sua página para outros sites. Alguns cabeçalhos de resposta desativam esse comportamento:

  • X-Content-Type-Options: nosniff: confie no Content-Type, nunca adivinhe.
  • Referrer-Policy: no-referrer: não informe a outros sites qual página criou o link para eles.
  • Content-Security-Policy: default-src 'self': carregue scripts, estilos e imagens somente deste site.

O Express também adiciona X-Powered-By: Express, que informa aos invasores o que tentar. app.disable('x-powered-by') o remove. Um middleware define cabeçalhos para cada resposta. Este define outros dois cabeçalhos: Strict-Transport-Security instrui o navegador a usar somente HTTPS por um ano, e Permissions-Policy desativa a câmera e o microfone.

app.use((req, res, next) => {
    res.set('Strict-Transport-Security', 'max-age=31536000');
    res.set('Permissions-Policy', 'camera=(), microphone=()');
    next();
});
challenge icon

Desafio

Fácil

Os navegadores fazem suposições que invasores podem aproveitar. Envie três cabeçalhos em cada resposta para desativar essas suposições e pare de informar a todos qual framework executa seu servidor.

  1. Em server.js, adicione um middleware antes de todas as rotas. Ele define estes três cabeçalhos em cada resposta, inclusive em respostas de erro como 401:
    • X-Content-Type-Options: nosniff
    • Referrer-Policy: no-referrer
    • Content-Security-Policy: default-src 'self'. Somente a página em GET / precisa deste cabeçalho, mas defini-lo em todas as respostas não tem problema.
  2. O Express adiciona X-Powered-By: Express a todas as respostas. Desative isso logo depois de criar o app.

Teste: Pressione Executar. Envie GET /notes sem fazer login: o status é 401. A aba Headers mostra X-Content-Type-Options: nosniff e Referrer-Policy: no-referrer, e não mostra X-Powered-By. Envie POST /signup e, em seguida, POST /login, ambos com {"username": "ada", "password": "correct horse"}. Depois, envie GET /: você verá a página com os mesmos cabeçalhos, além de Content-Security-Policy: default-src 'self'.

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.use(express.json());

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

app.post('/login', async (req, res) => {
    const username = String(req.body.username ?? '').trim().toLowerCase();
    const user = await users.findOne({ username });
    if (!user || !(await verifyPassword(String(req.body.password ?? ''), user.passwordHash))) {
        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 retorna 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();
}

// Retorna 403, a menos que o usuário conectado 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 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');
});
quiz iconTeste seus conhecimentos

Esta lição inclui um quiz rápido. Comece a lição para respondê-lo e acompanhar seu progresso.

Todas as lições de Autenticação e segurança

Pratique por conta própria: Playground de Express