Menu
CoddyTech

Header Keamanan

Termasuk bagian Autentikasi dan Keamanan dalam perjalanan Node.js di Coddy. Pelajaran 20 dari 24.

Browser menebak dan membagikan informasi secara default: browser dapat memperlakukan respons teks sebagai skrip, menjalankan skrip dari mana saja, dan, pada browser lama, mengirimkan alamat halaman lengkapmu ke situs lain. Beberapa header respons dapat menonaktifkan perilaku itu:

  • X-Content-Type-Options: nosniff: percayai Content-Type, jangan pernah menebak.
  • Referrer-Policy: no-referrer: jangan beri tahu situs lain halaman mana yang menautkan ke sana.
  • Content-Security-Policy: default-src 'self': muat skrip, style, dan gambar hanya dari situs ini.

Express juga menambahkan X-Powered-By: Express, yang memberi tahu penyerang apa yang perlu dicoba. app.disable('x-powered-by') menghapusnya. Satu middleware mengatur header untuk setiap respons. Middleware ini mengatur dua header lainnya: Strict-Transport-Security memberi tahu browser agar hanya menggunakan HTTPS selama setahun, dan Permissions-Policy menonaktifkan kamera dan mikrofon.

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

Tantangan

Mudah

Browser membuat tebakan yang bisa dimanfaatkan penyerang. Kirim tiga header bersama setiap respons untuk menonaktifkan tebakan tersebut, dan berhenti memberi tahu semua orang framework apa yang menjalankan servermu.

  1. Di server.js, tambahkan middleware sebelum semua route. Middleware ini menetapkan tiga header berikut pada setiap respons, termasuk respons error seperti 401:
    • X-Content-Type-Options: nosniff
    • Referrer-Policy: no-referrer
    • Content-Security-Policy: default-src 'self'. Hanya halaman di GET / yang memerlukan header ini, tetapi tidak masalah jika kamu menetapkannya pada setiap respons.
  2. Express menambahkan X-Powered-By: Express pada setiap respons. Nonaktifkan ini tepat setelah kamu membuat app.

Coba: Tekan Jalankan. Kirim GET /notes tanpa login: statusnya 401. Tab Headers menampilkan X-Content-Type-Options: nosniff dan Referrer-Policy: no-referrer, serta tidak menampilkan X-Powered-By. Kirim POST /signup, lalu POST /login, keduanya dengan {"username": "ada", "password": "correct horse"}. Lalu kirim GET /: kamu mendapatkan halamannya, dengan header yang sama ditambah Content-Security-Policy: default-src 'self'.

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

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

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

// 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 kecuali user yang masuk 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');
});
quiz iconUji dirimu

Pelajaran ini berisi kuis singkat. Mulai pelajarannya untuk menjawab kuis dan melacak progresmu.

Semua pelajaran di Autentikasi dan Keamanan

Latihan sendiri: Playground Express