L’input è ostile
Fa parte della sezione Autenticazione e sicurezza del percorso Node.js di Coddy. Lezione 24 di 24.
Qualsiasi cosa inviata da un client potrebbe essere stata scritta da un aggressore. Un corpo della richiesta di 50 MB occupa la memoria. Una nota contenente <script> viene eseguita come codice nel browser di chiunque la visualizzi: si tratta di cross-site scripting, XSS.
Limita ciò che accetti: ogni parser del corpo di Express accetta un limit, e un corpo più grande riceve una risposta 413 Payload Too Large prima che venga eseguita la tua route. Memorizza il testo esattamente come è stato inviato e sottoponilo a escape quando lo inserisci nell'HTML, così il browser mostrerà i caratteri invece di eseguirli. html.js mette a disposizione escapeHtml.
app.use(express.urlencoded({ limit: '2kb' }));
escapeHtml('<img src=x onerror=steal()>');
// '<img src=x onerror=steal()>'Sfida
FacileConsidera tutto ciò che invia un client come potenzialmente scritto da un aggressore. Rifiuta i corpi delle richieste troppo grandi e fai in modo che la pagina delle note mostri l’HTML degli utenti come testo semplice, invece di eseguirlo.
- In
server.js, imposta un limite di 10 KB per il parser del corpo JSON all’inizio. Un corpo più grande riceverà quindi lo stato 413 Payload Too Large prima che venga eseguita qualsiasi route. Una nota di 9,000 caratteri deve comunque essere salvata. - Importa
escapeHtmlda./html.js. Converte caratteri come<in<, così il browser li mostra invece di eseguirli. Funziona comeescapeHtml, che hai incontrato in Express Basics. - Nella pagina all’indirizzo
GET /, passa il testo di ogni nota e il nome utente aescapeHtmlprima di inserirli nell’HTML. Continua a salvare le note esattamente come sono state inviate. Esegui l’escape solo quando il testo viene inserito nell’HTML.
Prova: premi «Esegui». Invia POST /signup, poi POST /login, entrambi con {"username": "ada", "password": "correct horse"}. Invia POST /notes con {"text": "<script>alert('hi')</script>"}: stato 201. Invia GET /: l’elenco mostra <script>alert('hi')</script> come parole normali e il sorgente della pagina contiene <script> nel punto in cui si trovava il tag. Anche i controlli inviano una nota di 11,000 caratteri per te e si aspettano lo stato 413, quindi non devi digitarne una.
Provalo tu
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());
// Intestazioni che indicano al browser di trattare con cautela ogni risposta.
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'self'",
});
next();
});
// L'unico account admin. La sua password proviene dall'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 });
});
// Accessi non riusciti per username: { 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 });
});
// Trova l'utente associato al cookie di sessione oppure risponde 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();
}
// Risponde con 403 se l'utente che ha effettuato l'accesso non ha questo ruolo.
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();
});
// Le tue note sotto forma di pagina.
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>`);
});
// Note: ogni utente legge e scrive solo le proprie.
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();
});
// Per gli amministratori: quanti account ci sono.
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');
});
Tutte le lezioni di Autenticazione e sicurezza
1Password
Byte e testo esadecimaleDerivare una chiave dalla passwordUn nuovo saltNon memorizzare mai le passwordConfrontare i byte della chiaveVerificare una passwordRegole di registrazione4Proprietari e sicurezza
Solo le tue noteRuoli di amministratoreEsercitati da solo: Playground Express