The staff back office at /admin is properly locked: log in as a normal
customer and you get bounced. But the developers protected the page they were thinking
about and forgot the one next to it. An admin export endpoint sits
wide open, and it hands the whole customer list to anyone who asks.
New to this? What is forced browsing (broken access control)?
Forced browsing is just requesting a URL directly instead of following a link to it. It becomes a vulnerability when a page or endpoint that should be protected is not, and simply typing its address is enough to reach it.
This is broken access control at the function level: the app guards the obvious admin page but leaves a related admin function unguarded. Guessable paths and unlinked “hidden” pages are found in seconds by anyone who looks.
No account needed, and no tools either: your browser is enough. A directory
brute-forcer only makes the guessing faster, so it is kept as an optional hard-mode
step at the end. Make sure the shop is running at
http://localhost:3000.
1 · Find it
Try to reach the back office without logging in and you are turned away:
GET /admin -> 302, redirected to /login So the obvious door is locked. Now go looking for others. Admin areas rarely have just one endpoint, and a back office almost always has an export. Just guess it: type this straight into your browser’s address bar:
http://localhost:3000/admin/export It answers 200 OK, with no login. That is the whole flaw, found with nothing but a
hunch and the address bar.
Hard mode · let a tool find it. Instead of guessing, point a directory
brute-forcer at the site with a wordlist and it will turn up unlinked paths like
/admin/export for you:
ffuf -u http://localhost:3000/FUZZ -w wordlist.txt 2 · Exploit it
That endpoint answers with no login at all. It returns the full customer list, emails and admin flags included:
{
"generated_at": "...",
"customers": [
{ "id": 1, "email": "ada@example.com", "is_admin": 0, "credit_pence": 0 },
{ "id": 6, "email": "owner@example.com", "is_admin": 1, "credit_pence": 0 },
...
]
} You now have every customer’s email and, helpfully, which of them is an administrator, without ever proving you are staff.
Why it matters: access control has to be checked on every route, not just the front one. Exports, APIs, print views and “internal” tools are exactly where the check gets forgotten.
3 · Understand it
The two handlers sit side by side. One asks for an admin session; the other never got the memo:
// locked
app.get('/admin', requireAdmin, (req, res) => { ... });
// wide open
app.get('/admin/export', (req, res) => {
res.json({ customers: allCustomers() });
}); Nothing marks the export as sensitive. Access control that lives on individual routes is only as good as the developer’s memory.
4 · Fix it
Put the same guard on the export, and prefer a model where admin areas are protected by default rather than one route at a time:
app.get('/admin/export',
(req, res) => { ... }); // guard the whole prefix
app.use('/admin', requireAdmin);
app.get('/admin/export',
(req, res) => { ... });
Now every path under /admin, present and future, needs a staff session before
it does anything.
The general rule: deny by default. Protect areas, not individual pages, so a new endpoint is safe the moment it exists. Never rely on a URL being unlinked or hard to guess. That is not access control, it is hope.