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
Summary
A user with
manage_agendaspermission 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
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 themove_to_sectionaction was introduced. I have not tested older releases, but the root cause exists in any version with this code path.Affected Components
modules/meeting/app/controllers/meeting_agenda_items_controller.rbmodules/meeting/app/models/meeting_agenda_item.rbmodules/meeting/app/contracts/meeting_agenda_items/update_contract.rbAffected endpoint:
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:
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):The authorization contract then checks permissions against the item's current project (the attacker's project — which passes), and a
before_savecallback silently reassigns the item to the target section's meeting:Additionally, the
UpdateContractis missing thesection_belongs_to_meetingvalidation that theCreateContracthas (create_contract.rb line 77–86).Exploit flow (step by step)
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
manage_agendas)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:
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
manage_agendasin any project can target all meetings across the entire instance.What an attacker CANNOT do
model.project)manage_agendasin at least one projectComparison with DropService (Correct Implementation)
The
DropService(used by thedropaction) is not vulnerable because it correctly scopes the target section lookup to the current meeting:This confirms the inconsistency:
move_to_sectionlacks the scoping thatdropalready implements correctly.Suggested Remediation
Fix 1: Scope the section lookup in the controller
Fix 2: Add the missing validation to UpdateContract
The
CreateContractalready has this validation — it should also be inUpdateContract:Fix 3 (defense in depth): Guard the before_save callback
The
before_save :update_meeting_to_match_sectioncallback silently overrides a validated field after authorization. Consider removing it or adding a guard:Environment
476d1b5e777(v17.2.3 + 1514 commits)