CVE case study
CVE-2026-67529: OpenProject Entry APIs Exposed Hidden Work Package Subjects
OpenProject's global Time Entries and Cost Entries APIs rendered a linked Work Package's subject and sequential ID even when the caller lacked view_work_packages. Version 17.6.0 applies visibility checks before rendering those links.
- Severity
- Medium (4.3)
- Scoring
- CVSS 3.1
- Weakness
- CWE-200 · CWE-862
- Affected
- OpenProject 17.5.1 and earlier
- Remediation state
- Upgrade to OpenProject 17.6.0 or later
- Advisory published
- 8 Jul 2026
Official vectorCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N
Why it matters
OpenProject separates permission to view time or cost entries from permission to view the Work Package linked to an entry. That is a useful authorization boundary: a project member may need to review effort or cost records without being allowed to read the underlying work item.
Before 17.6.0, the collection responses crossed that boundary. A caller with view_time_entries or view_cost_entries, but without view_work_packages, could receive the hidden Work Package's subject in a link title and its sequential ID in the link URL. The direct Work Package API returned 404 to the same caller.
The disclosure was narrow but meaningful. Work Package subjects can contain customer names, internal project labels, incident titles, or other operational context. The issue did not expose the full Work Package, its description, comments, attachments, or arbitrary project data.
How I found it
I found this issue through variant analysis of GHSA-g387-6rm2-xw88, an earlier OpenProject disclosure involving a Meeting Agenda Item linked to a private Work Package. Its fix moved that relationship from an ungated association to a visibility-aware rendering path.
I searched the remaining API representers for linked Work Packages that still used the older pattern. The Time Entry and Cost Entry representers were the two reachable outliers: both could serialize a Work Package through their entry response without first asking whether that Work Package was visible to the current user.
I then traced the permission model instead of assuming that entry visibility covered the child object. view_time_entries and view_cost_entries are independent project permissions with no dependency on view_work_packages. The entry collections could therefore be legitimate while the linked Work Package remained private.
The decisive control was the direct Work Package endpoint. It uses WorkPackage.visible and returns 404 for the same low-privileged user. The collection response still exposed the hidden object's subject and sequential ID through its links, demonstrating a serializer-level authorization mismatch.
I reported the finding on 19 June 2026. OpenProject reproduced it, implemented the repair for 17.6.0, and added regression tests. I retested commit 9ad1bf0 on the release/17.6 branch and confirmed that both the Time Entry and Cost Entry responses now return the undisclosed representation for a hidden Work Package.
Root cause
The Time Entry and Cost Entry collections authorize access to each entry through the independent view_time_entries or view_cost_entries project permission. Their visibility scopes do not imply view_work_packages on a linked Work Package.
The affected representers rendered that linked object with the ungated association path. Its title came from WorkPackage#name, which aliases the subject, while its URL contained the sequential Work Package ID. No visible?(current_user) decision ran before those values were serialized.
The direct Work Package endpoint followed the intended policy. It resolved the object through WorkPackage.visible and returned 404 when the caller lacked view_work_packages. The mismatch was therefore not in authentication or collection access; it was at the linked-resource serialization boundary.
Source-to-sink trace
- 01Collection permission
TimeEntry.visible / CostScopes#with_visible_entries_onThe caller may view an entry through view_time_entries or view_cost_entries. Neither permission grants visibility to the linked Work Package.
- 02Linked private object
TimeEntry#entity / CostEntry#entityThe visible entry references a Work Package for which the same caller lacks view_work_packages.
- 03Serializer gap
time_entry_representer.rb:103 / cost_entry_representer.rb:48 in v17.5.1The affected representers render the Work Package link without applying entity.visible?(current_user).
- 04Identity disclosure
_links.entity / _links.workPackageThe link title carries the Work Package subject and the href carries its sequential numeric ID.
- 05Policy control
GET /api/v3/work_packages/{id}The direct endpoint resolves through WorkPackage.visible and returns 404 for the same user, proving that the linked object is meant to remain hidden.
- 06Visibility-aware fix
OpenProject fix commit 9ad1bf0Version 17.6.0 checks the linked entity's visibility, emits the undisclosed URN for a hidden Work Package, and suppresses its embed and display ID.
Safe proof of concept
Prerequisites
- Git and Python 3.
- Network access only to clone the public OpenProject repository. The source comparison and permission model run locally after the clone completes.
- No OpenProject account, API token, production hostname, or real Work Package data.
Step-by-step reproduction
- Clone the public OpenProject repository into a temporary directory.
- Compare the Time Entry and Cost Entry representers and their entity factories between tag v17.5.1 and fix commit 9ad1bf0.
- Confirm that the affected paths can render the linked Work Package without a visibility decision and that the fixed paths call entity.visible?(current_user).
- Inspect the patch's request specs. Confirm that a user who can view an entry but not its Work Package receives an undisclosed link, no embedded Work Package, and no display ID.
- Run the synthetic permission model below with entry visibility allowed and Work Package visibility denied.
- Confirm that the affected model renders the invented subject and ID while the fixed model returns the undisclosed URN. Then enable Work Package visibility and confirm the legitimate link remains available.
Compare the public affected and fixed rendering paths
LAB_DIR=$(mktemp -d "\${TMPDIR:-/tmp}/openproject-cve.XXXXXX")
git clone --filter=blob:none https://github.com/opf/openproject.git "$LAB_DIR/openproject"
git -C "$LAB_DIR/openproject" diff v17.5.1 9ad1bf0 -- \
modules/costs/lib/api/v3/time_entries/time_entry_representer.rb \
modules/costs/lib/api/v3/time_entries/entity_representer_factory.rb \
modules/costs/lib/api/v3/cost_entries/cost_entry_representer.rb \
modules/costs/lib/api/v3/cost_entries/entity_representer_factory.rb \
modules/costs/spec/requests/api/time_entry_resource_spec.rb \
modules/costs/spec/requests/api/cost_entries/cost_entry_resource_spec.rbSynthetic permission-boundary model
UNDISCLOSED = "urn:openproject-org:api:v3:undisclosed"
work_package = {
"id": 1042,
"subject": "SYNTHETIC_PRIVATE_WORK_PACKAGE",
}
def direct_work_package(view_work_packages):
return 200 if view_work_packages else 404
def affected_entry_link(view_entry, view_work_packages):
if not view_entry:
return None
return {
"href": f"/api/v3/work_packages/{work_package['id']}",
"title": work_package["subject"],
}
def fixed_entry_link(view_entry, view_work_packages):
if not view_entry:
return None
if not view_work_packages:
return {"href": UNDISCLOSED, "title": "Undisclosed"}
return affected_entry_link(view_entry, view_work_packages)
for path in ("time_entries", "cost_entries"):
print(path)
print("direct:", direct_work_package(False))
print("17.5.1:", affected_entry_link(True, False))
print("17.6.0:", fixed_entry_link(True, False))
print("authorized:", fixed_entry_link(True, True))Expected synthetic result
time_entries
direct: 404
17.5.1: {'href': '/api/v3/work_packages/1042', 'title': 'SYNTHETIC_PRIVATE_WORK_PACKAGE'}
17.6.0: {'href': 'urn:openproject-org:api:v3:undisclosed', 'title': 'Undisclosed'}
authorized: {'href': '/api/v3/work_packages/1042', 'title': 'SYNTHETIC_PRIVATE_WORK_PACKAGE'}
cost_entries
direct: 404
17.5.1: {'href': '/api/v3/work_packages/1042', 'title': 'SYNTHETIC_PRIVATE_WORK_PACKAGE'}
17.6.0: {'href': 'urn:openproject-org:api:v3:undisclosed', 'title': 'Undisclosed'}
authorized: {'href': '/api/v3/work_packages/1042', 'title': 'SYNTHETIC_PRIVATE_WORK_PACKAGE'}Expected evidence
- The public source diff shows that the fixed entity and deprecated Work Package links now check the Work Package with entity.visible?(current_user).
- With entry visibility allowed and Work Package visibility denied, the affected model reveals only the invented subject and sequential ID while the fixed model returns the undisclosed URN.
- The direct Work Package control returns 404 in both versions for the same denied visibility decision.
- Granting Work Package visibility remains a positive control: version 17.6.0 returns the normal synthetic link and title.
- The fix's request tests cover both Time Entry and Cost Entry responses and assert that hidden Work Package embeds are absent.
Remove the entry-view permission and the collection path should be denied before any linked object is considered. Grant view_work_packages and version 17.6.0 should render the normal Work Package link and title, confirming that the patch hides only unauthorized links.
On OpenProject 17.6.0 or fix commit 9ad1bf0, repeat the denied Work Package scenario for both entry types. The entry may remain visible, but its entity and deprecated workPackage links must use the undisclosed URN and title, displayId must be absent, and neither entity nor workPackage may be embedded.
Impact
An authenticated project member needed permission to view the relevant time or cost entries while lacking permission to view their linked Work Packages. In that specific role configuration, the collection response disclosed each referenced private Work Package's subject and sequential ID.
The official assessment is Medium at 4.3, with low confidentiality impact and no integrity or availability impact. The finding does not establish unauthenticated access, account takeover, modification, deletion, or disclosure of complete Work Package records.
The vendor advisory also describes a secondary moved-Work-Package scenario in which a retained entry can extend the identity disclosure across a project boundary. That is an additional reach condition, not a claim that every affected deployment exposed cross-project data.
Fix and retest
Upgrade to OpenProject 17.6.0 or later. The shipped fix checks entity.visible?(current_user) before rendering a Work Package through both the current entity link and the deprecated workPackage link.
When the Work Package is hidden, the fixed response uses OpenProject's undisclosed URN and localized undisclosed title. It also suppresses embedded Work Package data and the display ID where applicable. Visible Work Packages continue to render normally.
I retested the release/17.6 fix at commit 9ad1bf0 and confirmed that both Time Entry and Cost Entry surfaces were closed. The patch also adds request-level and representer-level regression coverage for the missing-permission case, including a positive Cost Entry control for an authorized viewer.
Engineering lesson
Permission to serialize a parent resource never proves permission to serialize its linked resources. Every link title, identifier, embed, and convenience field needs the linked object's own visibility decision.
A fixed vulnerability is also a map for variant analysis. Searching sibling representers for the older ungated pattern exposed two reachable outliers that ordinary endpoint-by-endpoint testing could easily miss.
References
- OpenProject advisory GHSA-v3j7-vqwv-5w5q and Finder credit opens in a new tab
- Central CVE-2026-67529 record opens in a new tab
- OpenProject fix pull request 24091 opens in a new tab
- Fix commit 9ad1bf0 opens in a new tab
- OpenProject 17.6.0 release opens in a new tab
- Affected Time Entry representer in 17.5.1 opens in a new tab
- Affected Cost Entry representer in 17.5.1 opens in a new tab
- Earlier visibility-fix sibling GHSA-g387-6rm2-xw88 opens in a new tab