Eingaben sind feindselig
Teil des Abschnitts Authentifizierung und Sicherheit der Node.js-Journey von Coddy. Lektion 24 von 24.
Alles, was ein Client sendet, könnte von einem Angreifer stammen. Ein 50 MB großer Body belegt deinen Speicher. Eine Notiz mit <script> wird im Browser aller Personen, die sie ansehen, als Code ausgeführt: Das ist Cross-Site-Scripting, XSS.
Begrenze, was du akzeptierst: Jeder Express-Body-Parser nimmt ein limit entgegen, und bei einem größeren Body wird 413 Payload Too Large zurückgegeben, bevor deine Route ausgeführt wird. Speichere den Text genau so, wie er gesendet wurde, und maskiere ihn, wenn du ihn in HTML einfügst, damit der Browser die Zeichen anzeigt, statt sie auszuführen. html.js stellt dir escapeHtml zur Verfügung.
app.use(express.urlencoded({ limit: '2kb' }));
escapeHtml('<img src=x onerror=steal()>');
// '<img src=x onerror=steal()>'Aufgabe
EinfachBehandle alles, was ein Client sendet, als möglicherweise von einem Angreifer verfasst. Weise zu große Anfragen zurück und sorge dafür, dass die Notizenseite HTML von Benutzern als reinen Text anzeigt, statt es auszuführen.
- Gib in
server.jsdem JSON-Body-Parser am Anfang ein Limit von 10 KB. Ein größerer Body erhält dann den Status 413 Payload Too Large, bevor irgendeine Route ausgeführt wird. Eine Notiz mit 9,000 Zeichen muss weiterhin gespeichert werden. - Importiere
escapeHtmlaus./html.js. Die Funktion wandelt Zeichen wie<in<um, sodass der Browser sie anzeigt, statt sie auszuführen. Sie funktioniert wie dasescapeHtml, das du in Express Basics kennengelernt hast. - Übergib auf der Seite unter
GET /den Text jeder Notiz und den Benutzernamen anescapeHtml, bevor sie in das HTML eingefügt werden. Speichere Notizen weiterhin genau so, wie sie gesendet wurden. Escapiere nur, wenn Text in HTML eingefügt wird.
Probiere es aus: Drücke auf „Run“. Sende POST /signup und dann POST /login, jeweils mit {"username": "ada", "password": "correct horse"}. Sende POST /notes mit {"text": "<script>alert('hi')</script>"}: Status 201. Sende GET /: Die Liste zeigt <script>alert('hi')</script> als reinen Text an, und der Seitenquelltext enthält anstelle des Tags <script>. Die Prüfungen senden außerdem für dich eine Notiz mit 11,000 Zeichen und erwarten den Status 413, du musst also keine selbst eintippen.
Probier es selbst
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());
// Header, die den Browser anweisen, bei jeder Antwort vorsichtig zu sein.
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'self'",
});
next();
});
// Das einzige Admin-Konto. Sein Passwort stammt aus der Umgebung.
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 });
});
// Fehlgeschlagene Anmeldungen pro Benutzername: { 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 });
});
// Findet den Benutzer anhand des Session-Cookies oder antwortet mit 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();
}
// Antwortet mit 403, wenn der angemeldete Benutzer diese Rolle nicht hat.
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();
});
// Deine Notizen als Seite.
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>`);
});
// Notizen: Jeder Benutzer liest und schreibt nur seine eigenen.
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();
});
// Für Admins: Wie viele Konten es gibt.
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');
});
Alle Lektionen in Authentifizierung und Sicherheit
1Passwörter
Bytes und Hex-TextEinen Passwortschlüssel ableitenEin neues SaltPasswörter niemals speichernSchlüssel-Bytes vergleichenEin Passwort überprüfenRegeln für die Registrierung4Eigentümer und Sicherheit
Nur deine NotizenAdmin-RollenÜbe selbstständig: Express-Playground