September 16, 2026
My First CVE: A CSRF Vulnerability in Horilla HRMS (CVE-2026-86066)
How an unsafe HTTP GET on a state-changing endpoint allowed an attacker to approve attendance records in a logged-in manager’s name…

By Anjana Manoj
5 min read
How an unsafe HTTP GET on a state-changing endpoint allowed an attacker to approve attendance records in a logged-in manager's name: zero-click, fully authenticated, and attributed to the victim in the audit trail.
Introduction
Horilla is a popular open-source Human Resource Management System (HRMS). It offers a full suite of modules covering employees, attendance, leave, payroll, and recruitment, giving organizations a flexible, self-hosted way to run their HR operations.
This post walks through CVE-2026-86066, my first CVE, a Cross-Site Request Forgery (CSRF) vulnerability I discovered in Horilla HRMS attendance-approval workflow. The root cause is a classic but easy-to-miss anti-pattern performing a state-changing database write over an HTTP GET request with no anti-CSRF protection. Because of how CSRF defenses and browser cookie policies work, that single design decision was enough to let an attacker approve attendance records on behalf of a logged-in manager, with the victim doing nothing more than opening a web page.
The vulnerability was assigned CVE-2026–86066 and tracked as GHSA-65vj-5p5g-pv5m. It falls under CWE-352 (Cross-Site Request Forgery) and it is fixed in version 2.0.0.
A quick primer on CSRF
Cross-Site Request Forgery abuses the fact that browsers automatically attach a user's session cookies to requests aimed at a site, regardless of where the request originated. If a sensitive, state-changing action can be triggered without a secret token that only the legitimate application knows, then any attacker-controlled page can forge that action in the victim's authenticated session.
The critical detail for this bug is that frameworks only CSRF-check "unsafe" HTTP methods, namely POST, PUT, PATCH, and DELETE. A GET request is treated as "safe" by convention and is never CSRF-checked. The convention assumes GET requests only read data. The moment a GET request writes data, that assumption breaks, and the built-in protection silently does nothing.
That is exactly the gap this vulnerability lived in.
The vulnerability
The affected endpoint is approve_validate_attendance_request.
python
@login_required
@manager_can_enter("attendance.change_attendance")
def approve_validate_attendance_request(request, attendance_id):
...@login_required
@manager_can_enter("attendance.change_attendance")
def approve_validate_attendance_request(request, attendance_id):
...Two things are notably absent:
- There is no
@require_http_methods(["POST"]), so the endpoint responds to GET. - There is no anti-CSRF token validation.
Despite being served over GET, the view mutates persistent state the moment it is reached. It:
- sets
attendance_validated = True - sets
is_validate_request_approved = True - clears the pending-request flags
- records
approved_by = request.user.employee_get - commits the change with
attendance.save()
Even more telling, the application itself invokes this endpoint through a plain hyperlink in individual_view.html:
html
<a href="{% url 'approve-validate-attendance-request' attendance.id %}"><a href="{% url 'approve-validate-attendance-request' attendance.id %}">That confirms the whole thing. A simple GET navigation is all it takes to approve an attendance request. The endpoint trusts the HTTP method to imply safety while actually using it to mutate data.
Root cause analysis
Putting the pieces together, the exploit chain becomes clear.
The action is reachable by GET: Because the state change happens on a safe method, Django's CSRF middleware never runs. There is nothing to bypass, because the check simply does not apply.
No token is required: An attacker does not need to leak or predict any secret. The only ingredient is the target attendance record's ID.
SameSite=Lax does not prevent this: Modern browsers default session cookies to SameSite=Lax, which blocks cookies on many cross-site requests, but it makes an exception for top-level navigations using safe methods. A forged GET that navigates the whole tab (for example, window.location = ...) is precisely that exception. The session cookie is attached automatically, so the forged request arrives fully authenticated.
The result is a zero-click attack beyond the victim opening the attacker's page. Any malicious site, email link, or embedded resource that triggers a top-level navigation to the endpoint will silently approve the request. Because the server sees the victim's session, the approval is committed with the victim's privileges and attributed to them.
Proof of concept
The exploit needs only a target attendance ID and a logged-in manager who can be lured into opening a page.
Step 1: Identify a pending request. Log in as a manager, open Attendance Requests, go to the Not verified tab, and select an employee's pending request.
Step 2: Capture the record ID. Intercept the request (for example, in Burp) and note the numeric attendance ID used by the approval endpoint.
Step 3: Craft the CSRF payload. A minimal HTML page that performs a top-level navigation to the endpoint is enough:
Step 4: Trigger it. Open the file in a browser where a manager is authenticated. The tab navigates to Horilla, the request is silently approved, and the dashboard surfaces a confirmation that the attendance request was approved.
Step 5: Confirm the result. The record disappears from the Not Verified tab and now appears under Verified, approved in the victim's name, with no interaction beyond opening the page.
In a real attack, this HTML would be hosted on an attacker-controlled site or delivered through a link, so the victim never sees any file at all, only a page that appears to do nothing.
Impact
This is more than a cosmetic workflow bug, because attendance data does not stay in the attendance module.
Payroll and overtime integrity: Attendance and time records feed payroll and overtime calculations. Fraudulent or erroneous attendance can be force-approved without the responsible manager's intent, and this can be done at scale simply by luring managers to a link. That turns an approval control into a rubber stamp.
Audit-trail corruption: Every forged approval is recorded as approved_by = <victim>. The person who never intended to approve anything is the one the system blames. That undermines the reliability of the audit trail exactly when you would most want to trust it, such as during a payroll dispute or an investigation.
The vulnerability requires a manager-level victim (someone with the attendance.change_attendance permission), but that is a low bar in practice. Managers are precisely the people who routinely click links about their team.
References
- GitHub Security Advisory: GHSA-65vj-5p5g-pv5m
- CVE: CVE-2026-86066
- CWE-352 (Cross-Site Request Forgery): https://cwe.mitre.org/data/definitions/352.html
- Project: https://github.com/horilla/horilla-hr
About the author
I am a passionate security researcher with a focus on offensive security and vulnerability research. I enjoy breaking things responsibly and helping teams fix them before attackers can.
For more research and write-ups, or to get in touch: