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

  1. 01
    Collection permissionTimeEntry.visible / CostScopes#with_visible_entries_on

    The caller may view an entry through view_time_entries or view_cost_entries. Neither permission grants visibility to the linked Work Package.

  2. 02
    Linked private objectTimeEntry#entity / CostEntry#entity

    The visible entry references a Work Package for which the same caller lacks view_work_packages.

  3. 03
    Serializer gaptime_entry_representer.rb:103 / cost_entry_representer.rb:48 in v17.5.1

    The affected representers render the Work Package link without applying entity.visible?(current_user).

  4. 04
    Identity disclosure_links.entity / _links.workPackage

    The link title carries the Work Package subject and the href carries its sequential numeric ID.

  5. 05
    Policy controlGET /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.

  6. 06
    Visibility-aware fixOpenProject fix commit 9ad1bf0

    Version 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

  1. Clone the public OpenProject repository into a temporary directory.
  2. Compare the Time Entry and Cost Entry representers and their entity factories between tag v17.5.1 and fix commit 9ad1bf0.
  3. 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).
  4. 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.
  5. Run the synthetic permission model below with entry visibility allowed and Work Package visibility denied.
  6. 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.rb

Synthetic 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.
Negative control

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.

Fixed-version re-test

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

Follow this thread

Related research, source, and writing.

These paths share a published research boundary, project source, article series, or authorized lab context with this record.

Back to article start