October 31, 2026
The Secure Code Review Challenge — Solution #8: Employee Directory (One Setting Away from Admin…
Employee Directory does almost everything right. Invitation codes are random, hashed and single-use. JWTs have a pinned algorithm. Every…

By Mohamed AboElKheir
16 min read
Employee Directory does almost everything right. Invitation codes are random, hashed and single-use. JWTs have a pinned algorithm. Every admin route checks the role, and the role is re-read from the database on every request. Salaries are masked, profile updates go through allowlists, forms carry CSRF tokens and NoSQL operators are stripped from every request.
It still lets any employee run code in the admin's browser. All it takes is a <script> tag in their profile Name and one line in the template config: autoescape: false.
This post walks through the full review: how the app works, where user input goes, which defenses hold, which one is missing, a working exploit, and the fix. Every code snippet you need is included, so you don't have to open the repo to follow along. Each snippet links to its exact lines on GitHub if you want more context.
🎥 Prefer video? The full walkthrough is on YouTube:
What is the Secure Code Review Challenge?
It's a free, biweekly series. Each challenge is a complete, realistic application with its own database, backend and UI, and one vulnerability planted on purpose, based on a real-world CVE or writeup. You review it the way you'd review a real codebase, then compare your reasoning with the published solution.
Every solution follows the same 7-step methodology:
- 🗺️ Understand the application: architecture and main user stories
- 🚪 Identify entry points: where user input comes in
- 🎯 Identify dangerous sinks: where input could change behavior (queries, file paths, HTML, …)
- 🧩 Build a threat model: business-logic risks (authN/authZ) and source-to-sink risks (injection)
- 🔍 Review mitigations: which threats are actually closed, and which aren't
- 🧪 Exploit: prove the unmitigated one is real
- 🛠️ Fix: a primary fix plus defense-in-depth
📌 Repo: https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge 📌 Challenge #8 source: https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/008-employee-directory
⚠️ Spoiler warning: if you want to try Employee Directory yourself first, stop here and come back later.
1. 🗺️ Understanding the application
Employee Directory is an internal company phonebook. Employees look each other up, and admins manage the records.
- Backend: Express.js (Node)
- Frontend: server-rendered HTML pages, built with the Nunjucks template engine
- Database: MongoDB, through Mongoose
- Auth: a JWT, returned as a bearer token by the API and kept in an
httpOnlycookie by the web UI - Roles:
employeeandadmin
The README sets out the rules the whole design protects:
"Employees browse everyone's public fields, see salary only on their own record, and can edit the public fields of their own record — never salary. Admins see and edit every field of every record (including salary), delete records, invite new hires, and promote other users to admin."j
So there are two things worth stealing: the salary field, and the admin role that unlocks it.
A good way to understand a codebase is to read it as a set of stories. We'll start with what happens when the app starts, then follow the main use cases: an admin invites a new hire, the new hire registers, they edit their profile, and everyone browses the directory.
🚀 Story 1: what happens when the app starts
docker-compose.yml starts two containers: a stock mongo:7 and the app, which gets its secrets from .env:
app:
build: .
container_name: directory_app
ports:
- "3000:3000"
environment:
MONGODB_URI: mongodb://mongo:27017/employee_directory
env_file:
- .envapp:
build: .
container_name: directory_app
ports:
- "3000:3000"
environment:
MONGODB_URI: mongodb://mongo:27017/employee_directory
env_file:
- .envThe Dockerfile installs production dependencies and runs node app.js as an unprivileged user. In app.js, the server refuses to start without a JWT secret:
if (!process.env.JWT_SECRET) {
console.error('JWT_SECRET is required');
process.exit(1);
}if (!process.env.JWT_SECRET) {
console.error('JWT_SECRET is required');
process.exit(1);
}Next it configures the template engine. Every page you see in the browser is a Nunjucks template in views/, and the engine fills in values like {{ emp.name }}. Read this config carefully. It matters later:
nunjucks.configure(viewsPath, {
autoescape: false,
express: app,
noCache: process.env.NODE_ENV !== 'production'
});nunjucks.configure(viewsPath, {
autoescape: false,
express: app,
noCache: process.env.NODE_ENV !== 'production'
});Then it registers the global middleware. Two of them are security controls we'll come back to: mongoSanitize and a CSRF check.
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(mongoSanitize()); // strips keys containing $ or . → blocks NoSQL operator injection
app.use(optionalAuth); // sets req.user if a valid token is present
...
app.use(csrfMiddleware); // csurf on every non-exempt state-changing requestapp.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(mongoSanitize()); // strips keys containing $ or . → blocks NoSQL operator injection
app.use(optionalAuth); // sets req.user if a valid token is present
...
app.use(csrfMiddleware); // csurf on every non-exempt state-changing requestFinally it mounts four routers: the server-rendered pages at /, plus the auth, employees and admin JSON APIs:
app.use('/', pagesRoutes);
app.use('/api/auth', authRoutes);
app.use('/api/employees', employeesRoutes);
app.use('/api/admin', adminRoutes);app.use('/', pagesRoutes);
app.use('/api/auth', authRoutes);
app.use('/api/employees', employeesRoutes);
app.use('/api/admin', adminRoutes);On first boot, with an empty database, the app creates a bootstrap admin with a random password and prints it to the logs:
const password = crypto.randomBytes(12).toString('base64url');
const passwordHash = await bcrypt.hash(password, 10);
const user = await store.createUser('admin@localhost', passwordHash, 'admin', null);const password = crypto.randomBytes(12).toString('base64url');
const passwordHash = await bcrypt.hash(password, 10);
const user = await store.createUser('admin@localhost', passwordHash, 'admin', null);🔄 Story 2: the admin invites a new hire
The admin fills in an email on /admin and clicks Create invitation. The API version of that route sits behind two middlewares for the whole router:
router.use(requireAuth, requireAdmin);router.use(requireAuth, requireAdmin);The store voids any older codes for that email, generates 16 random bytes and stores only their bcrypt hash. The plaintext is returned once and never saved:
async createInvitation(email) {
const normalized = email.toLowerCase().trim();
await Invitation.updateMany(
{ email: normalized, used: false },
{ $set: { used: true } }
);
const plaintext = crypto.randomBytes(16).toString('hex');
const codeHash = await bcrypt.hash(plaintext, 10);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await Invitation.create({ email: normalized, codeHash, expiresAt, used: false });
return { email: normalized, invitationCode: plaintext, expiresAt };
}async createInvitation(email) {
const normalized = email.toLowerCase().trim();
await Invitation.updateMany(
{ email: normalized, used: false },
{ $set: { used: true } }
);
const plaintext = crypto.randomBytes(16).toString('hex');
const codeHash = await bcrypt.hash(plaintext, 10);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await Invitation.create({ email: normalized, codeHash, expiresAt, used: false });
return { email: normalized, invitationCode: plaintext, expiresAt };
}src/store/directoryStore.js#L224-L240
🔄 Story 3: the new hire registers and logs in
POST /api/auth/register takes email, password and invitationCode. It rejects duplicate emails, checks and consumes the invitation, then creates the user with a role that is hard-coded to employee:
const okInvite = await db.validateAndConsumeInvitation(email, invitationCode);
if (!okInvite) { ... return res.status(400).json({ error: 'Invalid or expired invitation' }); }
const passwordHash = await bcrypt.hash(password, 10);
const user = await db.createUser(email, passwordHash, 'employee', null);const okInvite = await db.validateAndConsumeInvitation(email, invitationCode);
if (!okInvite) { ... return res.status(400).json({ error: 'Invalid or expired invitation' }); }
const passwordHash = await bcrypt.hash(password, 10);
const user = await db.createUser(email, passwordHash, 'employee', null);The invitation check treats the code like a password. It finds unused, unexpired invitations for that email, compares with bcrypt, and marks the match as used:
const invitations = await Invitation.find({
email: normalized,
used: false,
expiresAt: { $gt: new Date() }
}).sort({ expiresAt: -1 });
for (const inv of invitations) {
const match = await bcrypt.compare(plaintextCode, inv.codeHash);
if (match) {
inv.used = true;
await inv.save();
return true;
}
}
return false;const invitations = await Invitation.find({
email: normalized,
used: false,
expiresAt: { $gt: new Date() }
}).sort({ expiresAt: -1 });
for (const inv of invitations) {
const match = await bcrypt.compare(plaintextCode, inv.codeHash);
if (match) {
inv.used = true;
await inv.save();
return true;
}
}
return false;src/store/directoryStore.js#L252-L269
Login compares the bcrypt hash and signs an HS256 JWT. The web form also stores it in an httpOnly, SameSite=Lax cookie, so JavaScript on the page can't read it:
const cookieOptions = {
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000,
path: '/',
sameSite: 'lax'
};const cookieOptions = {
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000,
path: '/',
sameSite: 'lax'
};On every later request, requireAuth verifies the token with the algorithm pinned, then reloads the user from the database. So req.user.role is always the current role in MongoDB, not whatever the token says:
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });
const user = await db.findUserById(decoded.userId);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = {
id: user.id,
email: user.email,
role: user.role,
employeeId: user.employeeId
};const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });
const user = await db.findUserById(decoded.userId);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = {
id: user.id,
email: user.email,
role: user.role,
employeeId: user.employeeId
};src/middleware/auth.js#L36-L47
🔄 Story 4: an employee updates their own profile
The My profile form posts to POST /profile (the API equivalent is PATCH /api/employees/me). The handler finds the caller's record from req.user.id, never from an ID in the request, and copies only an allowlist of public fields:
const emp = await db.findEmployeeByUserId(req.user.id);
...
const allowed = ['name', 'title', 'manager', 'deskNumber', 'location', 'phone'];
const patch = {};
for (const key of allowed) {
if (Object.prototype.hasOwnProperty.call(req.body, key)) {
patch[key] = req.body[key];
}
}
await db.updateEmployeeById(emp.id, patch);const emp = await db.findEmployeeByUserId(req.user.id);
...
const allowed = ['name', 'title', 'manager', 'deskNumber', 'location', 'phone'];
const patch = {};
for (const key of allowed) {
if (Object.prototype.hasOwnProperty.call(req.body, key)) {
patch[key] = req.body[key];
}
}
await db.updateEmployeeById(emp.id, patch);The allowlist controls which fields you can write. Nothing checks what you write into them: no length limit, no character rules. Whatever you type into Name is saved as-is.
🔄 Story 5: everyone browses the directory
GET / asks the store for the directory, filtered for the viewer. Admins and the record's owner get the full row. Everyone else gets a masked copy without salary or email:
async getDirectoryForViewer(viewerUserId, viewerRole) {
const employees = await this.listEmployees();
const viewerIdStr = viewerUserId ? String(viewerUserId) : null;
return employees.map((emp) => {
if (viewerRole === 'admin') {
return { ...emp };
}
if (emp.userId && emp.userId === viewerIdStr) {
return { ...emp };
}
return this.maskEmployee(emp);
});
}async getDirectoryForViewer(viewerUserId, viewerRole) {
const employees = await this.listEmployees();
const viewerIdStr = viewerUserId ? String(viewerUserId) : null;
return employees.map((emp) => {
if (viewerRole === 'admin') {
return { ...emp };
}
if (emp.userId && emp.userId === viewerIdStr) {
return { ...emp };
}
return this.maskEmployee(emp);
});
}src/store/directoryStore.js#L210-L222
The page then prints every field into a table cell:
{% for emp in employees %}
<tr>
<td>{{ emp.name }}</td>
<td>{{ emp.title }}</td>
<td>{{ emp.manager }}</td>
<td>{{ emp.deskNumber }}</td>
<td>{{ emp.location }}</td>
<td>{{ emp.phone }}</td>{% for emp in employees %}
<tr>
<td>{{ emp.name }}</td>
<td>{{ emp.title }}</td>
<td>{{ emp.manager }}</td>
<td>{{ emp.deskNumber }}</td>
<td>{{ emp.location }}</td>
<td>{{ emp.phone }}</td>The admin page (GET /admin) goes further. It lists every employee as an editable form, with each stored value inside an <input value="...">, next to the Make admin buttons:
<label>Name <input type="text" name="name" value="{{ emp.name }}"></label>
...
<form method="post" action="/admin/promote/{{ u.id }}" style="display:inline;">
<input type="hidden" name="_csrf" value="{{ csrfToken() }}">
<button type="submit">Make admin</button>
</form><label>Name <input type="text" name="name" value="{{ emp.name }}"></label>
...
<form method="post" action="/admin/promote/{{ u.id }}" style="display:inline;">
<input type="hidden" name="_csrf" value="{{ csrfToken() }}">
<button type="submit">Make admin</button>
</form>views/admin.html#L45 · views/admin.html#L70-L73
🧠 The key observation from step 1
Put stories 4 and 5 side by side:
any employee ──writes──▶ name, title, location, … ──stored as typed──▶ MongoDB
│
admin's browser ◀──renders {{ emp.name }}── directory.html / admin.html ◀─┘
(the page that holds the "Make admin" buttons)any employee ──writes──▶ name, title, location, … ──stored as typed──▶ MongoDB
│
admin's browser ◀──renders {{ emp.name }}── directory.html / admin.html ◀─┘
(the page that holds the "Make admin" buttons)Text written by the least privileged user is rendered in the page of the most privileged user. That's a trust boundary. Everything depends on the template engine turning that text into harmless characters on the way out.
2. 🚪 Entry points
POST /api/auth/register: no auth (needs a valid invitation), takesemail,password,invitationCodePOST /api/auth/login: no auth, takesemail,passwordPOST /api/auth/logout: no authGET /api/employees: any user, no inputPATCH /api/employees/me: any user, takesname,title,manager,deskNumber,location,phonePOST /api/admin/invitations: admin, takesemailGET /api/admin/invitations,GET /api/admin/employees,GET /api/admin/users: admin, no inputPATCH /api/admin/employees/:id: admin, takes:idand the whole bodyDELETE /api/admin/employees/:id: admin, takes:idPATCH /api/admin/users/:id: admin, takes:id,roleGET /login,GET /register: no auth, take?error=GET /: any user, renders stored dataGET /profile,POST /profile: any user (+ CSRF on the POST), the public fieldsGET /admin: admin, renders stored dataPOST /admin/employee/:id,POST /admin/promote/:id,POST /admin/invite: admin + CSRF,:idand form fieldsGET /api/csrf-token,GET /health: no auth
3. 🎯 Dangerous sinks
- S1: HTML rendering with Nunjucks. The templates print stored employee fields and the
?error=query parameter. If user input can become markup, you get XSS (CWE-79). - S2: MongoDB queries and updates built from input → NoSQL operator injection. It comes in two flavors:
S2a, query filters: email at login and register, invitationCode, :id. An operator like {"$ne": null} would change which documents match.
S2b, the update document: updateEmployeeById(id, patch). An operator like $unset or $rename would change what the update does.
- S3: redirects.
res.redirect(...)is used all overauth.jsandpages.js→ open redirect.
4 & 5. 🧩 Threat model & 🔍 mitigation review
🔓 Business logic
Authentication ✅. The app won't start without JWT_SECRET. jwt.verify pins HS256, which blocks alg: none and algorithm confusion (jsonwebtoken docs). Tokens expire after 24h, and the user is reloaded from the database on every request. Every admin API route has requireAuth, and every page that needs a user has requireUser.
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });
const user = await db.findUserById(decoded.userId);const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });
const user = await db.findUserById(decoded.userId);src/middleware/auth.js#L36-L37 · secret required, app.js#L20-L23 · 24h expiry, auth.js#L12-L19
requireUser, used by the web pages, only lets a request through once optionalAuth has verified the token and set req.user:
function requireUser(req, res, next) {
if (!req.user) {
return res.redirect('/login');
}
next();
}function requireUser(req, res, next) {
if (!req.user) {
return res.redirect('/login');
}
next();
}Invitation-only registration ✅. Can someone register without an invitation, reuse one, or use someone else's? No. Codes are 128-bit random and only their bcrypt hash is stored, so even a database leak doesn't reveal a usable code. Each code is tied to one email and expires after 7 days, and creating a new one voids the old ones:
await Invitation.updateMany(
{ email: normalized, used: false },
{ $set: { used: true } }
);
const plaintext = crypto.randomBytes(16).toString('hex');
const codeHash = await bcrypt.hash(plaintext, 10);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);await Invitation.updateMany(
{ email: normalized, used: false },
{ $set: { used: true } }
);
const plaintext = crypto.randomBytes(16).toString('hex');
const codeHash = await bcrypt.hash(plaintext, 10);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);src/store/directoryStore.js#L226-L232
At registration, only an unused, unexpired code for that email is accepted, and the match is marked used so it works once:
const invitations = await Invitation.find({
email: normalized,
used: false,
expiresAt: { $gt: new Date() }
}).sort({ expiresAt: -1 });
...
inv.used = true;
await inv.save();const invitations = await Invitation.find({
email: normalized,
used: false,
expiresAt: { $gt: new Date() }
}).sort({ expiresAt: -1 });
...
inv.used = true;
await inv.save();src/store/directoryStore.js#L254-L264
And the new account's role is hard-coded, so you can't register yourself as an admin. A second registration with the same email is blocked by the pre-checks and by the unique index on email:
const user = await db.createUser(email, passwordHash, 'employee', null);const user = await db.createUser(email, passwordHash, 'employee', null);src/routes/auth.js#L76 · duplicate checks, auth.js#L50-L64 · unique index, directoryStore.js#L6
Vertical privilege escalation (employee → admin) ✅. The admin API router applies both middlewares to every route, and requireAdmin checks the role that requireAuth just loaded from the database:
router.use(requireAuth, requireAdmin);router.use(requireAuth, requireAdmin);const requireAdmin = async (req, res, next) => {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
};const requireAdmin = async (req, res, next) => {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
};src/middleware/auth.js#L57-L62
The /admin* pages don't use that router, so each handler checks the role itself (pages.js#L85, #L106, #L128, #L140):
router.post('/admin/promote/:id', requireUser, async (req, res, next) => {
try {
if (req.user.role !== 'admin') {
return res.status(403).send('Forbidden');
}
await db.updateUserRole(req.params.id, 'admin');
res.redirect('/admin');router.post('/admin/promote/:id', requireUser, async (req, res, next) => {
try {
if (req.user.role !== 'admin') {
return res.status(403).send('Forbidden');
}
await db.updateUserRole(req.params.id, 'admin');
res.redirect('/admin');Keep that last detail in mind: the check is "is this request from an admin?", and it is.
Horizontal access to salaries ✅. getDirectoryForViewer (src/store/directoryStore.js#L210-L222, shown in story 5) returns maskEmployee for other people's rows. It builds a new object from the public fields only, so salary and email can't leak by accident:
maskEmployee(emp) {
return {
id: emp.id,
name: emp.name,
title: emp.title,
manager: emp.manager,
deskNumber: emp.deskNumber,
location: emp.location,
phone: emp.phone
};
}maskEmployee(emp) {
return {
id: emp.id,
name: emp.name,
title: emp.title,
manager: emp.manager,
deskNumber: emp.deskNumber,
location: emp.location,
phone: emp.phone
};
}src/store/directoryStore.js#L198-L208
The self-edit routes look up the record from req.user.id (src/routes/pages.js#L64, src/routes/employees.js#L30), never from an ID in the request, and the API rejects salary outright:
if (Object.prototype.hasOwnProperty.call(req.body, 'salary')) {
return res.status(403).json({ error: 'Cannot update salary through this endpoint' });
}if (Object.prototype.hasOwnProperty.call(req.body, 'salary')) {
return res.status(403).json({ error: 'Cannot update salary through this endpoint' });
}src/routes/employees.js#L35-L37
CSRF ✅. csurf checks a _csrf token on every state-changing request that isn't exempt. Requests with an Authorization: Bearer header skip it, which is safe: a page on another site can't make the browser add that header. Removing the _csrf field from an invite request in Burp Repeater gets Invalid or missing CSRF token. The cookie is also SameSite=Lax.
function csrfMiddleware(req, res, next) {
const auth = req.headers.authorization;
const hasBearer = auth && String(auth).startsWith('Bearer ');
if (hasBearer && !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
if (req.method === 'POST' && CSRF_EXEMPT_AUTH_POST.has(req.path)) {
return next();
}
return csrfProtection(req, res, next);
}function csrfMiddleware(req, res, next) {
const auth = req.headers.authorization;
const hasBearer = auth && String(auth).startsWith('Bearer ');
if (hasBearer && !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
if (req.method === 'POST' && CSRF_EXEMPT_AUTH_POST.has(req.path)) {
return next();
}
return csrfProtection(req, res, next);
}One caveat for later: a CSRF token only stops requests from another origin. A script running on this origin can read the token like any form on the page does.
Mass assignment ✅. Can an employee write fields they shouldn't, like salary, email or userId? No. There are two allowlists. The employee routes copy only the six public fields (src/routes/pages.js#L68-L74, src/routes/employees.js#L39-L45, shown in story 4). The store then copies only its own fixed list of keys, so unknown fields like role or _id never reach MongoDB:
const allowed = [
'name', 'title', 'manager', 'deskNumber', 'location', 'phone',
'salary', 'userId', 'email'
];const allowed = [
'name', 'title', 'manager', 'deskNumber', 'location', 'phone',
'salary', 'userId', 'email'
];src/store/directoryStore.js#L160-L170
salary, userId and email are on the store's list, but only the admin API passes the raw body to it, and that route sits behind requireAdmin:
router.patch('/employees/:id', async (req, res) => {
try {
const updated = await db.updateEmployeeById(req.params.id, req.body);router.patch('/employees/:id', async (req, res) => {
try {
const updated = await db.updateEmployeeById(req.params.id, req.body);This matches the OWASP Mass Assignment cheat sheet's advice to allowlist the bindable fields.
💉 Source-to-sink
S2a, NoSQL injection in filters ✅. The classic attack sends an object instead of a string, for example {"email": {"$ne": null}}. express-mongo-sanitize strips $-prefixed and dotted keys from the body, query and params before any handler runs:
app.use(mongoSanitize());app.use(mongoSanitize());On top of that, findUserByEmail calls email.toLowerCase(), which throws on an object, and findUserById goes through Mongoose, which casts the ID to an ObjectId and rejects anything else:
async findUserByEmail(email) {
const doc = await User.findOne({ email: email.toLowerCase().trim() });
return toUserDto(doc);
}
async findUserById(id) {
const doc = await User.findById(id);
return toUserDto(doc);
}async findUserByEmail(email) {
const doc = await User.findOne({ email: email.toLowerCase().trim() });
return toUserDto(doc);
}
async findUserById(id) {
const doc = await User.findById(id);
return toUserDto(doc);
}src/store/directoryStore.js#L86-L94
S2b, NoSQL injection in updates ✅. updateEmployeeById builds a brand-new update object from a fixed list of keys, so a top-level $unset or $rename is never copied in, and mongo-sanitize strips nested $ keys too:
const update = {};
for (const key of allowed) {
if (Object.prototype.hasOwnProperty.call(patch, key)) {
...
update[key] = patch[key];
}
}
const doc = await Employee.findByIdAndUpdate(id, update, { new: true, runValidators: true });const update = {};
for (const key of allowed) {
if (Object.prototype.hasOwnProperty.call(patch, key)) {
...
update[key] = patch[key];
}
}
const doc = await Employee.findByIdAndUpdate(id, update, { new: true, runValidators: true });src/store/directoryStore.js#L171-L186
S3, open redirect ✅. Every res.redirect target is a hard-coded relative path like /login or /profile?saved=1. User data only goes into the query string, and only after encodeURIComponent, so it can't change where the browser goes:
return res.redirect('/register?error=' + encodeURIComponent('All fields are required'));
...
res.redirect('/profile?saved=1');return res.redirect('/register?error=' + encodeURIComponent('All fields are required'));
...
res.redirect('/profile?saved=1');src/routes/auth.js#L45 · src/routes/pages.js#L76
S1, XSS: this is where it breaks ❌. Go back to the template config from story 1:
nunjucks.configure(viewsPath, {
autoescape: false,nunjucks.configure(viewsPath, {
autoescape: false,Autoescaping is the control that turns < into <, " into " and so on whenever a value is printed with {{ }}. It's exactly what the OWASP XSS Prevention cheat sheet calls output encoding. Nunjucks turns it on by default, so this line switches it off for every template at once.
Combine that with story 4 (Name is stored exactly as typed) and story 5 (Name is printed into the directory table and the admin page), and a profile field becomes HTML in the admin's browser.
💡 Reviewer's lesson: when you see a template engine, a UI framework or a markdown renderer, check its escaping setting before you read a single template. One global flag can undo every
{{ value }}in the app, and it's a one-line change that's easy to miss in review.
6. 🧪 Exploitation
Why a plain <script> works
Where the value lands decides what the payload has to look like:
- The Directory,
<td>{{ emp.name }}</td>: the value is element text, so a plain<script>…</script>becomes a real script tag. No breakout is needed. - The admin page,
<input value="{{ emp.name }}">: the value is inside a quoted attribute, so a payload needs a leading "> to close the attribute and the tag first. - Your own profile page: the same attribute context, which is why the payload doesn't fire while you're typing it.
PoC
1. The employee puts a script in their Name. On My profile, they set Name to test2<script>alert('XSS')</script> and click Save. The server stores it as typed.
2. The admin logs in and opens the Directory. Admins land on /admin after login (top right: admin@localhost). From there they click Directory, an ordinary part of their day.
3. The script runs in the admin's browser. The alert comes from localhost:3000, the app's own origin, so the attacker's code is running inside the admin's session.
What a real payload can do
An alert is only the proof. A real payload can do anything the admin's page can do:
- Read the admin's CSRF token from
GET /api/csrf-token, or from the hidden_csrffields on/admin. - Send admin requests as the admin. The session cookie is
httpOnly, so the script can't read it, but it doesn't need to: the browser attaches it to every request the script sends. - Promote the attacker. With the token and the cookie, a
POST /admin/promote/<attacker-id>passesrequireUser, the role check and the CSRF check, because it really does come from the admin's browser. - Upgrade the attacker's existing session.
requireAuthreloads the role from the database, so the attacker's own token becomes an admin token immediately.
Impact: any employee becomes an admin the next time an admin opens the Directory. From there they can read and change every salary, delete records, create invitations and promote others. The same payload also runs for every employee who opens the directory.
Classification: Stored Cross-Site Scripting, CWE-79, caused by disabled output encoding, CWE-116. It turns into privilege escalation because the victim is an admin.
7. 🛠️ The fix
Primary fix: turn output encoding back on. Delete the line or set it to true:
nunjucks.configure(viewsPath, {
- autoescape: false,
+ autoescape: true,
express: app,
noCache: process.env.NODE_ENV !== 'production'
});nunjucks.configure(viewsPath, {
- autoescape: false,
+ autoescape: true,
express: app,
noCache: process.env.NODE_ENV !== 'production'
});Here is the real admin.html rendering the same kind of payload, before and after:
autoescape:false -> <input type="text" name="location" value=""><script>alert(1)</script>">
autoescape:true -> <input type="text" name="location" value=""><script>alert(1)</script>">autoescape:false -> <input type="text" name="location" value=""><script>alert(1)</script>">
autoescape:true -> <input type="text" name="location" value=""><script>alert(1)</script>">Nunjucks' escaping encodes & " ' < >, which covers both places this app prints values: element text and double-quoted attributes. None of the templates intentionally output HTML, so nothing needs | safe.
Defense-in-depth (do these too):
- Add a Content Security Policy. None of the templates use inline
<script>, so a policy likescript-src 'self'; object-src 'none'; base-uri 'none'(for example with helmet) blocks this payload without breaking the UI. - Validate profile fields when they come in. Enforce lengths and per-field formats (phone, desk number), and reject < and > in plain-text fields. This is a safety net, not the fix: output encoding is still required. If you ever need rich text, use an allowlist sanitizer like DOMPurify instead of
| safe. - Make privilege changes harder to ride. Require re-authentication or a confirmation step for Make admin, and audit-log role changes.
- Guard the config in CI. No public Semgrep ruleset flags
autoescape: falsefor Nunjucks, but a custom rule takes a few lines:
rules:
- id: nunjucks-autoescape-disabled
languages: [javascript, typescript]
severity: ERROR
message: Nunjucks is configured with autoescape disabled, so every {{ value }} is written into the HTML unencoded.
pattern-either:
- pattern: "$NUNJUCKS.configure(..., {..., autoescape: false, ...})"
- pattern: "new $NUNJUCKS.Environment(..., {..., autoescape: false, ...})" rules:
- id: nunjucks-autoescape-disabled
languages: [javascript, typescript]
severity: ERROR
message: Nunjucks is configured with autoescape disabled, so every {{ value }} is written into the HTML unencoded.
pattern-either:
- pattern: "$NUNJUCKS.configure(..., {..., autoescape: false, ...})"
- pattern: "new $NUNJUCKS.Environment(..., {..., autoescape: false, ...})"nunjucks-autoescape-disabled.yam
It flags the root cause directly:
$ semgrep scan --metrics=off \
--config solutions/008-employee-directory/nunjucks-autoescape-disabled.yaml \
challenges/008-employee-directory
challenges/008-employee-directory/app.js
❯❯❱ solutions.008-employee-directory.nunjucks-autoescape-disabled
56┆ nunjucks.configure(viewsPath, {
57┆ autoescape: false,
58┆ express: app,
59┆ noCache: process.env.NODE_ENV !== 'production'
60┆ });$ semgrep scan --metrics=off \
--config solutions/008-employee-directory/nunjucks-autoescape-disabled.yaml \
challenges/008-employee-directory
challenges/008-employee-directory/app.js
❯❯❱ solutions.008-employee-directory.nunjucks-autoescape-disabled
56┆ nunjucks.configure(viewsPath, {
57┆ autoescape: false,
58┆ express: app,
59┆ noCache: process.env.NODE_ENV !== 'production'
60┆ });The rule points at the config, not at each {{ emp.name }}, which is the right place: one flag turns off encoding for every template. A taint rule wouldn't find this anyway, because the payload is saved to MongoDB in one request and rendered in another.
🌍 Real-world examples
- Stored XSS to Privilege Escalation to Admin Takeover to Data Breach — Ahmad Halabi (Aug 2025). A bug bounty write-up on a HackerOne financial program. An employee could only see the loan they were invited to, but the loan's purchase description was rendered without encoding. The researcher stored a
<script src=…>payload there and invited the manager to review the loan. When the manager opened it, the script read the manager's CSRF token and replayed the role-change request, making the employee a manager. Almost exactly this challenge's chain. - From a Sanitized Name Field to One-Click Account Takeover — zere.es (2026). The server stripped HTML from a name field, but the front-end decoded HTML entities and wrote the value with
innerHTML, so entity-encoded markup survived and ran. The author chained it with login CSRF and OAuth-code theft for one-click takeover. A different sink with the same lesson: filtering input doesn't help when the output isn't encoded for its context.
📚 More resources
- CWE-79: Cross-site Scripting and CWE-116: Improper Encoding or Escaping of Output
- OWASP Cross Site Scripting Prevention Cheat Sheet
- Nunjucks: Autoescaping and
[configure](https://mozilla.github.io/nunjucks/api.html#configure)options - PortSwigger Web Security Academy: Stored XSS
- PortSwigger lab: Exploiting XSS to bypass CSRF defenses
- MDN: Content Security Policy
🎯 Takeaway
Employee Directory's server-side authorization is sound: role checks, salary masking, allowlists and CSRF tokens all hold, and httpOnly keeps the session cookie out of reach. One template setting makes them irrelevant. They all still run, but once attacker script runs on your origin in an admin's browser, every check sees a legitimate admin request.
Whenever something renders in the browser, whether it's React, Angular, a template engine or a markdown renderer, check that user input goes through HTML escaping on the way out. Keep autoescape on, and treat any field a low-privileged user writes and a high-privileged user reads as a trust boundary.
🔔 What's next
Challenge #9: ProjectHub is live now. Can you find the bug before the solution drops?
New challenges land every two weeks, and each drop includes the previous challenge's solution. Watch the repo (Watch → Custom → Releases) to get notified, and subscribe to AppSec Untangled on YouTube for the video walkthroughs.
If you found the bug yourself, or took a different route to it, tell me in the comments 👇