Skip to content

Cross-Project Meeting Agenda Item Injection via Unscoped Section Lookup

Moderate
oliverguenther published GHSA-hh5p-gwf8-h245 Apr 15, 2026

Package

OpenProject

Affected versions

<= 17.2.3

Patched versions

17.3.0

Description

Summary

A user with manage_agendas permission in any project can inject agenda items into meetings belonging to any other project on the instance — even projects they have no access to. No knowledge of the target project, meeting, or victim is required; the attacker can blindly spray items into every meeting on the instance by iterating sequential section IDs.

Severity

HIGH — Authorization Bypass / Cross-Project IDOR

  • CWE-639: Authorization Bypass Through User-Controlled Key
  • CWE-863: Incorrect Authorization
  • CVSS 3.1: 7.1 (AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N)

Affected Versions

Tested on development branch commit 476d1b5e777 (based on v17.2.3). The vulnerable code has been present since the move_to_section action was introduced. I have not tested older releases, but the root cause exists in any version with this code path.

Affected Components

Component File Line
Controller modules/meeting/app/controllers/meeting_agenda_items_controller.rb 293
Model callback modules/meeting/app/models/meeting_agenda_item.rb 74, 125–128
Contract (missing validation) modules/meeting/app/contracts/meeting_agenda_items/update_contract.rb

Affected endpoint:

POST /projects/:project_id/meetings/:meeting_id/agenda_items/:id/move_to_section

How It Works

Background

OpenProject stores meeting sections in a single global database table with auto-incrementing IDs. Each section belongs to a meeting, and each meeting belongs to a project:

                    ┌─────────────────────┐
                    │   sections table     │
                    │   (GLOBAL, shared)   │
                    ├─────────────────────┤
                    │ id=1  → meeting #1  │  ← belongs to Project X
                    │ id=2  → meeting #1  │  ← belongs to Project X
                    │ id=3  → meeting #7  │  ← belongs to Project Y
                    │ id=4  → meeting #9  │  ← belongs to Project Z
                    │ ...                  │
                    └─────────────────────┘

The vulnerability

When an attacker calls move_to_section, the controller looks up the target section globally (not scoped to the current meeting or project):

# meeting_agenda_items_controller.rb, line 293
def move_to_section
  meeting_section = MeetingSection.find_by(id: params[:meeting_agenda_item][:meeting_section_id])
  #                 ^^^^^^^^^^^^^^^^^^^^^^^ NOT scoped to current meeting or project
  @meeting = meeting_section.meeting unless meeting_section.backlog?
  call = update_agenda_item(meeting_section:)
  handle_agenda_item_update_on_move(call)
end

The authorization contract then checks permissions against the item's current project (the attacker's project — which passes), and a before_save callback silently reassigns the item to the target section's meeting:

# meeting_agenda_item.rb, line 74 and 125–128
before_save :update_meeting_to_match_section

def update_meeting_to_match_section
  self.meeting = meeting_section.meeting  # ← changes project AFTER authorization passed
end

Additionally, the UpdateContract is missing the section_belongs_to_meeting validation that the CreateContract has (create_contract.rb line 77–86).

Exploit flow (step by step)

 ATTACKER'S PROJECT (has access)            VICTIM'S PROJECT (no access)
 ───────────────────────────────            ────────────────────────────
 Meeting #4                                 Meeting #5
 └── Section #5                             └── Section #6
     └── Item #15 "Injected text"               └── Item #16 "Top Secret"

 1. Attacker sends:
    POST /projects/attacker-project/meetings/4/agenda_items/15/move_to_section
    Body: meeting_agenda_item[meeting_section_id]=6
          ────────────────────────────────────────┘
          Section #6 belongs to Meeting #5 in the VICTIM's project,
          but the server doesn't check that.

 2. Server checks: "Does attacker have permission in their own project?"  → YES ✓

 3. Server moves Item #15 into Section #6.
    before_save callback updates: item.meeting = Section #6's meeting = Meeting #5

 4. Item #15 is now in the victim's meeting:

 ATTACKER'S PROJECT                         VICTIM'S PROJECT
 ───────────────────────────────            ────────────────────────────
 Meeting #4                                 Meeting #5
 └── Section #5                             └── Section #6
     └── (empty)                                ├── Item #16 "Top Secret"
                                                └── Item #15 "Injected text"  ← !!

No target knowledge needed

Section IDs are sequential integers. The attacker can blindly enumerate them — the server returns HTTP 200 for valid sections (item successfully injected) and HTTP 500 for nonexistent sections (NilClass error). This is a reliable oracle.

I verified this: blindly iterating section IDs 1 through 9 successfully injected items into meetings across multiple unrelated projects, including the seeded demo-project.

Steps to Reproduce

Prerequisites

  • An OpenProject instance with the meetings module enabled on at least two projects
  • An attacker user who is a member of Project A with the default "Member" role (which includes manage_agendas)
  • The attacker has no membership in Project B
  • Both projects have at least one meeting (each meeting automatically gets a section)

Reproduction

1. Log in as the attacker and navigate to a meeting in Project A.

2. Create an agenda item in the attacker's meeting (via the UI, or note an existing item's ID).

3. Get a CSRF token from the meeting page (inspect the <meta name="csrf-token"> tag).

4. Send the exploit request:

POST /projects/project-a/meetings/{attacker_meeting_id}/agenda_items/{item_id}/move_to_section HTTP/1.1
Host: your-openproject-instance.example.com
Cookie: _open_project_session=<session_cookie>
Content-Type: application/x-www-form-urlencoded
X-CSRF-Token: <csrf_token>
Accept: text/vnd.turbo-stream.html, text/html

meeting_agenda_item[meeting_section_id]={target_section_id}

Where {target_section_id} is any section ID — even one belonging to a meeting in a project the attacker cannot access. Try sequential integers (1, 2, 3, …) if the target is unknown.

5. Verify: Log in as a member of Project B and open their meeting. The attacker's agenda item (with its title and notes) is now visible as a normal agenda item.

Expected result

The server should reject the request because the target section belongs to a different meeting/project that the attacker has no access to.

Actual result

The server returns HTTP 200 and the item is moved into the victim's meeting. The victim sees the attacker-controlled content when they view their meeting.

Impact

Dimension Impact
Integrity HIGH — Attacker can inject arbitrary content (titles, notes, work package links) into any meeting on the instance. This enables phishing, social engineering, and planting misleading information in confidential meetings.
Confidentiality LOW — The attacker can confirm the existence of meeting sections via the 200/500 oracle. The injected item's content becomes visible to the target project's members. The attacker cannot read the victim's meeting (blind injection).
Availability NONE
Scope Instance-wide — any authenticated user with manage_agendas in any project can target all meetings across the entire instance.

What an attacker CANNOT do

  • Read or view the victim's meeting content (blind, write-only injection)
  • Pull items from another project into their own (reverse direction is blocked by the permission check on model.project)
  • Exploit this without being an authenticated user with manage_agendas in at least one project

Comparison with DropService (Correct Implementation)

The DropService (used by the drop action) is not vulnerable because it correctly scopes the target section lookup to the current meeting:

# modules/meeting/app/services/meeting_agenda_items/drop_service.rb, line 134
@meeting.sections.find(new_section_id)
#        ^^^^^^^^ scoped to current meeting — cross-project references are impossible

This confirms the inconsistency: move_to_section lacks the scoping that drop already implements correctly.

Suggested Remediation

Fix 1: Scope the section lookup in the controller

# meeting_agenda_items_controller.rb — move_to_section
def move_to_section
  meeting_section = @meeting.sections.find_by(id: params[:meeting_agenda_item][:meeting_section_id])
  #                 ^^^^^^^^^^^^^^^^^ scope to current meeting
  return head(:not_found) unless meeting_section

  @meeting = meeting_section.meeting unless meeting_section.backlog?
  call = update_agenda_item(meeting_section:)
  handle_agenda_item_update_on_move(call)
end

Fix 2: Add the missing validation to UpdateContract

The CreateContract already has this validation — it should also be in UpdateContract:

# update_contract.rb
validate :meeting_section_belongs_to_meeting

def meeting_section_belongs_to_meeting
  return unless model.meeting_section_id_changed?
  return unless model.meeting_section

  if model.meeting_section.meeting_id != model.meeting_id
    errors.add :meeting_section, :invalid
  end
end

Fix 3 (defense in depth): Guard the before_save callback

The before_save :update_meeting_to_match_section callback silently overrides a validated field after authorization. Consider removing it or adding a guard:

before_save :update_meeting_to_match_section

def update_meeting_to_match_section
  return unless meeting_section
  return if meeting_section.meeting_id == meeting_id  # no-op if already consistent

  self.meeting = meeting_section.meeting
end

Environment

  • OpenProject version: Development branch, commit 476d1b5e777 (v17.2.3 + 1514 commits)
  • Ruby: 4.0.2
  • Rails: ~8.0.3
  • Tested on: Local Docker instance (docker-compose dev environment)
  • No external or production systems were tested

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N

CVE ID

CVE-2026-40896

Weaknesses

Time-of-check Time-of-use (TOCTOU) Race Condition

The product checks the state of a resource before using that resource, but the resource's state can change between the check and the use in a way that invalidates the results of the check. Learn more on MITRE.

Authorization Bypass Through User-Controlled Key

The system's authorization functionality does not prevent one user from gaining access to another user's data or record by modifying the key value identifying the data. Learn more on MITRE.

Credits