Input Tidak Tepercaya
Termasuk bagian Autentikasi dan Keamanan dalam perjalanan Node.js di Coddy. Pelajaran 24 dari 24.
Apa pun yang dikirim klien mungkin ditulis oleh penyerang. Isi berukuran 50 MB menghabiskan memori kamu. Catatan yang berisi <script> berjalan sebagai kode di browser siapa pun yang melihatnya: itulah skrip lintas situs, XSS.
Batasi apa yang kamu terima: setiap parser body Express menerima limit, dan body yang lebih besar menghasilkan 413 Payload Terlalu Besar sebelum rute kamu berjalan. Simpan teks persis seperti yang dikirim, lalu escape teks itu saat kamu memasukkannya ke HTML, agar browser menampilkan karakter tersebut alih-alih menjalankannya. html.js menyediakan escapeHtml.
app.use(express.urlencoded({ limit: '2kb' }));
escapeHtml('<img src=x onerror=steal()>');
// '<img src=x onerror=steal()>'Tantangan
MudahAnggap semua yang dikirim client mungkin ditulis oleh penyerang. Tolak body yang terlalu besar, dan buat halaman catatan menampilkan HTML dari pengguna sebagai teks biasa, bukan menjalankannya.
- Di
server.js, beri parser body JSON di bagian atas batas 10 KB. Body yang lebih besar akan mendapat status 413 Payload Too Large sebelum rute mana pun dijalankan. Catatan sepanjang 9,000 karakter harus tetap tersimpan. - Impor
escapeHtmldari./html.js. Fungsi ini mengubah karakter seperti<menjadi<, sehingga browser menampilkannya, bukan menjalankannya. Fungsinya sepertiescapeHtmlyang kamu pelajari di Express Basics. - Di halaman pada
GET /, teruskan teks setiap catatan dan nama pengguna melaluiescapeHtmlsebelum memasukkannya ke HTML. Simpan catatan persis seperti saat dikirim. Lakukan escape hanya saat teks dimasukkan ke HTML.
Coba: Tekan tombol “Jalankan”. Kirim POST /signup, lalu POST /login, keduanya dengan {"username": "ada", "password": "correct horse"}. Kirim POST /notes dengan {"text": "<script>alert('hi')</script>"}: status 201. Kirim GET /: daftar menampilkan <script>alert('hi')</script> sebagai teks biasa, dan sumber halaman berisi <script> di bagian tag tadi. Pemeriksaan juga mengirim catatan sepanjang 11,000 karakter untukmu dan mengharapkan status 413, jadi kamu tidak perlu mengetiknya.
Coba sendiri
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 yang meminta browser berhati-hati terhadap setiap respons.
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'self'",
});
next();
});
// Satu-satunya akun admin. Kata sandinya berasal dari environment.
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 });
});
// Login gagal 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 });
});
// Mencari user dari cookie sesi, atau mengembalikan 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();
}
// Mengembalikan 403 jika user yang login tidak memiliki peran ini.
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();
});
// Catatanmu sebagai halaman.
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>`);
});
// Catatan: setiap user hanya membaca dan menulis catatannya sendiri.
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();
});
// Untuk admin: jumlah akun yang ada.
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');
});
Semua pelajaran di Autentikasi dan Keamanan
1Kata Sandi
Byte dan Teks HeksadesimalMembuat Kunci Kata SandiSalt BaruJangan Simpan Kata SandiMembandingkan Byte KunciMemeriksa Kata SandiAturan Pendaftaran4Pemilik dan Keamanan
Hanya CatatanmuPeran AdminLatihan sendiri: Playground Express