July 10, 2026
Reviving a Legacy App, and Immediately Breaking It On Purpose
Part 1 of my AppSec transformation series — auditing, exploiting, and rebuilding a 2022 inventory system from the ground up.
By Gertrude Nabasirye
5 min read
The setup
In 2022, I built an inventory and wholesale sales tracker for a farm supply business using Node.js, Express, and MongoDB, with session-based auth and three roles: Agent, Manager, and Director. It worked. Features shipped, the UI didn't break, and I moved on to other things.
This year, I decided to bring it back. Not to keep using it as-is, but as raw material for teaching myself application security properly. The plan: audit it, reproduce what I find against a running instance, and then rebuild it from scratch with everything I learn baked into the architecture from day one, wrapped in a full DevSecOps pipeline by the end.
Step one of any audit, though, is boring in a way nobody tells you about: the app didn't run. Dependencies from three years ago, a database connection string hardcoded straight into a config file (more on that in a second), and a runtime environment that had moved on without it. Getting a piece of "dead" code running again is its own small skill, and a necessary one, because you can't security-test something that won't boot.
The first surprise: a live credential in dead code
While tracing the database connection, I found the original config/database.js had a full MongoDB Atlas connection string (username, password, cluster address) committed directly into the file. Not an environment variable, not a placeholder. A real, working credential, sitting in source.
The good news is that the cluster had been paused for inactivity for roughly three years, the repository had been private for about two of those, and the database user had IP restrictions configured. Low realistic exposure. But "low exposure" isn't the same as "no problem". The credential itself was still live and reachable in principle, which meant it needed to be rotated regardless of how unlikely misuse was. I rotated it, then made the call to start a fresh git history for the rebuild rather than try to surgically scrub one secret out of years of commits. If you're ever in this position: deleting a secret from the current version of a file does nothing about git history. Anyone with repo access can still find it in an old commit unless you either rewrite history or start clean.
Lesson one, before I'd written a single line of the actual audit: secrets don't decay just because a service goes offline. Treat every hardcoded credential as live until you've personally rotated it, no matter how old or "obviously dead" the surrounding code looks.
The manual review
With the app finally booting against a local database, I went through the codebase by hand — no automated scanner yet, that's a later phase of this project. Just reading the code the way an attacker or an auditor would, tracing what user input touches, and what it's allowed to touch.
15 findings emerged from that phase. Three were critical. The worst of them, by a wide margin, was this:
The application had an authentication check that confirmed someone was logged in, guarding every role-restricted section of the app. What it didn't have was an authorization check confirming which role someone actually held. Authentication and authorization sound similar and get conflated constantly, but they answer two completely different questions: "do I know who you are" versus "are you allowed to be here." This app only ever asked the first question.
Layered on top of that, the endpoint responsible for creating new user accounts took the new account's role directly from the submitted form data, with no check to ensure the person submitting the form was actually permitted to assign that role.
Individually, each of these is a real finding. Together, they're a full privilege escalation chain, and I don't mean that abstractly. I mean, I could prove it.
Proving it
I seeded a single low-privilege account directly into the database (bypassing the app's own signup flow, since, correctly, there wasn't one; you shouldn't be able to self-register as an administrator in an internal tool). Logged in as that account. The lowest privilege level that the system defines.
Then, using Burp Suite to intercept and inspect the traffic rather than just clicking around blind, I:
- Seeded an account with role:
Agent, a legitimate low-privilege session. - From that session, I submitted a
POSTrequest withrole=Managerin the body creating a Manager. (ensureAuthenticatedaccepted an Agent session on a Manager route;createUsertrusted the client-supplied role.) - Logged in as the Manager, created and navigated to
GET /director/users/newa Director-exclusive route, and it rendered. (Same root cause — no role differentiation at all, not even Manager vs. Director.) - From that session, I submitted the creation form with
role=Directorand created a Director. - Logged in as a Director and landed on the real Director dashboard, org-wide Reports panel included.
Two hops. Starting from the least-trusted account type the system defines. Using nothing but the application's own legitimate-looking "create a user" form.
// Conceptually, this is what the vulnerable pattern looked like:
router.post('/create-user', ensureLoggedIn, async (req, res) => {
const { role, ...otherFields } = req.body;
// role is taken directly from client input here —
// nothing validates that the requester is allowed to assign it
await User.create({ role, ...otherFields });
});// Conceptually, this is what the vulnerable pattern looked like:
router.post('/create-user', ensureLoggedIn, async (req, res) => {
const { role, ...otherFields } = req.body;
// role is taken directly from client input here —
// nothing validates that the requester is allowed to assign it
await User.create({ role, ...otherFields });
});ensureLoggedIn proves you're someone. It says nothing about who. And the handler underneath trusts the client completely on the one field that matters most.
What this actually means
It's tempting to read "one field wasn't validated" and file it as a minor oversight. It isn't. This is the difference between a system with a permission model and one that appears to have one. Every role-gated screen, every "only managers can see this" button, every access-control decision in the UI was cosmetic, enforced by what links happened to be visible in the interface, not by anything the server actually checked. That's not defense, that's a suggestion.
It's also, I'll admit, a little humbling to find in your own code. Three years ago, I was focused entirely on "does the feature work," and it did. The app functioned exactly as intended for every user who followed the UI as designed. Security failures like this one don't show up in normal use. They only show up when someone (eventually, inevitably, someone with worse intentions than a curious former-author) stops following the UI and starts talking to the server directly.
What's next
This vulnerable version is now archived as a baseline — tagged, documented, never going back into active use. Everything from here is a ground-up rebuild: containerized, role-checked at every layer (not just authenticated), schema-validated, dependency-scanned, statically analyzed, dynamically tested against, and eventually shipped through a CI/CD pipeline that won't let a regression like this one merge silently ever again.
I'll be writing up each phase as I go — partly for visibility, mostly because future-me will want this exact archive the next time I second-guess whether a permission check is really necessary. (It is. It's always necessary.)
This is part of an ongoing series documenting a full application security transformation — from legacy audit through secure rebuild to automated DevSecOps pipeline. The repository is currently private during the rebuild; findings and progress are being published here as they happen.