DEV Community

Cover image for The Secure Code Review Challenge — Solution #8: Employee Directory (One Setting Away from Admin Takeover)
Mohamed AboElKheir
Mohamed AboElKheir

Posted on Edited on

The Secure Code Review Challenge — Solution #8: Employee Directory (One Setting Away from Admin Takeover)

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:

  1. 🗺️ Understand the application: architecture and main user stories
  2. 🚪 Identify entry points: where user input comes in
  3. 🎯 Identify dangerous sinks: where input could change behavior (queries, file paths, HTML, …)
  4. 🧩 Build a threat model: business-logic risks (authN/authZ) and source-to-sink risks (injection)
  5. 🔍 Review mitigations: which threats are actually closed, and which aren't
  6. 🧪 Exploit: prove the unmitigated one is real
  7. 🛠️ 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 httpOnly cookie by the web UI
  • Roles: employee and admin

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."

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:
      - .env
Enter fullscreen mode Exit fullscreen mode

docker-compose.yml#L17-L30

The 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);
}
Enter fullscreen mode Exit fullscreen mode

app.js#L20-L23

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'
});
Enter fullscreen mode Exit fullscreen mode

app.js#L56-L60

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 request
Enter fullscreen mode Exit fullscreen mode

app.js#L63-L79

Finally 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);
Enter fullscreen mode Exit fullscreen mode

app.js#L85-L88

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);
Enter fullscreen mode Exit fullscreen mode

src/bootstrap.js#L9-L11

🔄 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);
Enter fullscreen mode Exit fullscreen mode

src/routes/admin.js#L11

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 };
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

src/routes/auth.js#L66-L76

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;
Enter fullscreen mode Exit fullscreen mode

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'
};
Enter fullscreen mode Exit fullscreen mode

src/routes/auth.js#L21-L26

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
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

src/routes/pages.js#L64-L75

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);
  });
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

views/directory.html#L19-L26

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>
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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), takes email, password, invitationCode
  • POST /api/auth/login: no auth, takes email, password
  • POST /api/auth/logout: no auth
  • GET /api/employees: any user, no input
  • PATCH /api/employees/me: any user, takes name, title, manager, deskNumber, location, phone
  • POST /api/admin/invitations: admin, takes email
  • GET /api/admin/invitations, GET /api/admin/employees, GET /api/admin/users: admin, no input
  • PATCH /api/admin/employees/:id: admin, takes :id and the whole body
  • DELETE /api/admin/employees/:id: admin, takes :id
  • PATCH /api/admin/users/:id: admin, takes :id, role
  • GET /login, GET /register: no auth, take ?error=
  • GET /: any user, renders stored data
  • GET /profile, POST /profile: any user (+ CSRF on the POST), the public fields
  • GET /admin: admin, renders stored data
  • POST /admin/employee/:id, POST /admin/promote/:id, POST /admin/invite: admin + CSRF, :id and form fields
  • GET /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 over auth.js and pages.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);
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

src/routes/pages.js#L10-L15

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);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

src/routes/admin.js#L11

const requireAdmin = async (req, res, next) => {
  if (!req.user || req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Admin access required' });
  }
  next();
};
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

src/routes/pages.js#L126-L132

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
  };
}
Enter fullscreen mode Exit fullscreen mode

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' });
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

app.js#L36-L46

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'
];
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

src/routes/admin.js#L47-L49

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());
Enter fullscreen mode Exit fullscreen mode

app.js#L66

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);
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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,
Enter fullscreen mode Exit fullscreen mode

app.js#L56-L57

Autoescaping is the control that turns < into &lt;, " into &quot; 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.

The employee stores a script tag in the Name field of their profile

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.

The admin is logged in on /admin and clicks Directory

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.

An alert box saying XSS pops up on localhost:3000 for the admin

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 _csrf fields 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> passes requireUser, the role check and the CSRF check, because it really does come from the admin's browser.
  • Upgrade the attacker's existing session. requireAuth reloads 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'
 });
Enter fullscreen mode Exit fullscreen mode

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="&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;">
Enter fullscreen mode Exit fullscreen mode

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):

  1. Add a Content Security Policy. None of the templates use inline <script>, so a policy like script-src 'self'; object-src 'none'; base-uri 'none' (for example with helmet) blocks this payload without breaking the UI.
  2. 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.
  3. Make privilege changes harder to ride. Require re-authentication or a confirmation step for Make admin, and audit-log role changes.
  4. Guard the config in CI. No public Semgrep ruleset flags autoescape: false for 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, ...})"
Enter fullscreen mode Exit fullscreen mode

nunjucks-autoescape-disabled.yaml

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┆ });
Enter fullscreen mode Exit fullscreen mode

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


🎯 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?

👉 https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/009-projecthub

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 👇

Top comments (0)