VYPR
Medium severity6.5NVD Advisory· Published May 20, 2026

CVE-2026-40102

CVE-2026-40102

Description

Plane is an open-source project management tool. In versions 1.3.0 and below, SavedAnalyticEndpoint passes the user-controlled segment query parameter directly to a Django F() expression without validation (unlike the regular AnalyticsEndpoint, which checks against an allowlist), causing ORM Field Reference Injection. An authenticated workspace MEMBER can send GET /api/workspaces//saved-analytic-view/<analytic_id>/ with a crafted segment value that is forwarded into build_graph_plot() and traverses foreign-key relationships (e.g. workspace__owner__password) before being projected via .values("dimension", "segment"), returning the referenced field values directly in the JSON response. This exposes sensitive data such as bcrypt password hashes, API tokens, and related users' email addresses, making it a stronger primitive than the related order_by injection where values are only leaked through ordering. This issue has been fixed in version 1.3.1.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Authenticated workspace members can extract arbitrary database field values via unvalidated segment parameter in Plane's SavedAnalyticEndpoint, leading to exposure of sensitive data.

Vulnerability

In Plane versions 1.3.0 and below, the SavedAnalyticEndpoint passes the user-controlled segment query parameter directly to a Django F() expression without validation. Unlike the regular AnalyticsEndpoint, which checks against an allowlist, this endpoint allows an attacker to supply arbitrary field references. The unvalidated segment value is forwarded into build_graph_plot(), where it is used in an annotate() call and then projected via .values("dimension", "segment"), causing ORM Field Reference Injection [1].

Exploitation

An authenticated workspace MEMBER can send a GET request to /api/workspaces//saved-analytic-view/<analytic_id>/ with a crafted segment parameter, such as workspace__owner__password. The value is captured by request.GET.get("segment", False) and passed to build_graph_plot(), which creates a Django F() reference that traverses foreign-key relationships. The response JSON includes the actual values of the referenced field under the "segment" key, grouped by dimension [1].

Impact

A successful attack allows the workspace member to extract the value of any related model field, including sensitive data such as bcrypt password hashes, API tokens, and email addresses of related users. This is a stronger primitive than the related order_by injection, where values are only leaked through ordering [1].

Mitigation

The issue is fixed in Plane version 1.3.1, released on 2026-05-20 [2]. The fix centralizes analytics field allowlists into VALID_ANALYTICS_FIELDS and VALID_YAXIS, and adds defense-in-depth validation in build_graph_plot() and extract_axis() so no caller can pass arbitrary field references to Django F() expressions. No workarounds are documented; upgrading to 1.3.1 or later is recommended [2].

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2
  • Plane/Planereferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • (no CPE)range: <=1.3.0

Patches

1
8a2579ce9ba5

fix: prevent ORM field injection via segment parameter in analytics (GHSA-93x3-ghh7-72j3) (#8864)

https://github.com/makeplane/planesriram veeraghantaApr 7, 2026Fixed in 1.3.1via llm-release-walk
2 files changed · +40 41
  • apps/api/plane/app/views/analytic/base.py+14 41 modified
    @@ -29,7 +29,7 @@
         Module,
     )
     
    -from plane.utils.analytics_plot import build_graph_plot
    +from plane.utils.analytics_plot import build_graph_plot, VALID_ANALYTICS_FIELDS, VALID_YAXIS
     from plane.utils.issue_filters import issue_filters
     from plane.app.permissions import allow_permission, ROLE
     
    @@ -41,32 +41,15 @@ def get(self, request, slug):
             y_axis = request.GET.get("y_axis", False)
             segment = request.GET.get("segment", False)
     
    -        valid_xaxis_segment = [
    -            "state_id",
    -            "state__group",
    -            "labels__id",
    -            "assignees__id",
    -            "estimate_point__value",
    -            "issue_cycle__cycle_id",
    -            "issue_module__module_id",
    -            "priority",
    -            "start_date",
    -            "target_date",
    -            "created_at",
    -            "completed_at",
    -        ]
    -
    -        valid_yaxis = ["issue_count", "estimate"]
    -
             # Check for x-axis and y-axis as thery are required parameters
    -        if not x_axis or not y_axis or x_axis not in valid_xaxis_segment or y_axis not in valid_yaxis:
    +        if not x_axis or not y_axis or x_axis not in VALID_ANALYTICS_FIELDS or y_axis not in VALID_YAXIS:
                 return Response(
                     {"error": "x-axis and y-axis dimensions are required and the values should be valid"},
                     status=status.HTTP_400_BAD_REQUEST,
                 )
     
             # If segment is present it cannot be same as x-axis
    -        if segment and (segment not in valid_xaxis_segment or x_axis == segment):
    +        if segment and (segment not in VALID_ANALYTICS_FIELDS or x_axis == segment):
                 return Response(
                     {"error": "Both segment and x axis cannot be same and segment should be valid"},
                     status=status.HTTP_400_BAD_REQUEST,
    @@ -214,13 +197,20 @@ def get(self, request, slug, analytic_id):
             x_axis = analytic_view.query_dict.get("x_axis", False)
             y_axis = analytic_view.query_dict.get("y_axis", False)
     
    -        if not x_axis or not y_axis:
    +        if not x_axis or not y_axis or x_axis not in VALID_ANALYTICS_FIELDS or y_axis not in VALID_YAXIS:
                 return Response(
    -                {"error": "x-axis and y-axis dimensions are required"},
    +                {"error": "x-axis and y-axis dimensions are required and the values should be valid"},
                     status=status.HTTP_400_BAD_REQUEST,
                 )
     
             segment = request.GET.get("segment", False)
    +
    +        if segment and (segment not in VALID_ANALYTICS_FIELDS or x_axis == segment):
    +            return Response(
    +                {"error": "Both segment and x axis cannot be same and segment should be valid"},
    +                status=status.HTTP_400_BAD_REQUEST,
    +            )
    +
             distribution = build_graph_plot(queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment)
             total_issues = queryset.count()
             return Response(
    @@ -236,32 +226,15 @@ def post(self, request, slug):
             y_axis = request.data.get("y_axis", False)
             segment = request.data.get("segment", False)
     
    -        valid_xaxis_segment = [
    -            "state_id",
    -            "state__group",
    -            "labels__id",
    -            "assignees__id",
    -            "estimate_point",
    -            "issue_cycle__cycle_id",
    -            "issue_module__module_id",
    -            "priority",
    -            "start_date",
    -            "target_date",
    -            "created_at",
    -            "completed_at",
    -        ]
    -
    -        valid_yaxis = ["issue_count", "estimate"]
    -
             # Check for x-axis and y-axis as thery are required parameters
    -        if not x_axis or not y_axis or x_axis not in valid_xaxis_segment or y_axis not in valid_yaxis:
    +        if not x_axis or not y_axis or x_axis not in VALID_ANALYTICS_FIELDS or y_axis not in VALID_YAXIS:
                 return Response(
                     {"error": "x-axis and y-axis dimensions are required and the values should be valid"},
                     status=status.HTTP_400_BAD_REQUEST,
                 )
     
             # If segment is present it cannot be same as x-axis
    -        if segment and (segment not in valid_xaxis_segment or x_axis == segment):
    +        if segment and (segment not in VALID_ANALYTICS_FIELDS or x_axis == segment):
                 return Response(
                     {"error": "Both segment and x axis cannot be same and segment should be valid"},
                     status=status.HTTP_400_BAD_REQUEST,
    
  • apps/api/plane/utils/analytics_plot.py+26 0 modified
    @@ -22,6 +22,23 @@
     # Module imports
     from plane.db.models import Issue, Project
     
    +VALID_ANALYTICS_FIELDS = [
    +    "state_id",
    +    "state__group",
    +    "labels__id",
    +    "assignees__id",
    +    "estimate_point__value",
    +    "issue_cycle__cycle_id",
    +    "issue_module__module_id",
    +    "priority",
    +    "start_date",
    +    "target_date",
    +    "created_at",
    +    "completed_at",
    +]
    +
    +VALID_YAXIS = ["issue_count", "estimate"]
    +
     
     def annotate_with_monthly_dimension(queryset, field_name, attribute):
         # Get the year and the months
    @@ -34,6 +51,8 @@ def annotate_with_monthly_dimension(queryset, field_name, attribute):
     
     
     def extract_axis(queryset, x_axis):
    +    if x_axis not in VALID_ANALYTICS_FIELDS:
    +        raise ValueError(f"Invalid x_axis value: {x_axis}")
         # Format the dimension when the axis is in date
         if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
             queryset = annotate_with_monthly_dimension(queryset, x_axis, "dimension")
    @@ -52,6 +71,13 @@ def sort_data(data, temp_axis):
     
     
     def build_graph_plot(queryset, x_axis, y_axis, segment=None):
    +    if x_axis not in VALID_ANALYTICS_FIELDS:
    +        raise ValueError(f"Invalid x_axis value: {x_axis}")
    +    if y_axis not in VALID_YAXIS:
    +        raise ValueError(f"Invalid y_axis value: {y_axis}")
    +    if segment and segment not in VALID_ANALYTICS_FIELDS:
    +        raise ValueError(f"Invalid segment value: {segment}")
    +
         # temp x_axis
         temp_axis = x_axis
         # Extract the x_axis and queryset
    

Vulnerability mechanics

Root cause

"Missing allowlist validation on the `segment` query parameter in `SavedAnalyticEndpoint` allows an attacker to pass arbitrary Django ORM field references (including foreign-key traversals) into `F()` expressions, causing ORM Field Reference Injection."

Attack vector

An authenticated workspace MEMBER sends a GET request to `/api/workspaces/<slug>/saved-analytic-view/<analytic_id>/` with a crafted `segment` query parameter. Unlike the regular `AnalyticsEndpoint`, the `SavedAnalyticEndpoint` did not validate the `segment` value against an allowlist before forwarding it to `build_graph_plot()`. The unvalidated value is used in a Django `F()` expression and projected via `.values("dimension", "segment")`, allowing the attacker to traverse foreign-key relationships (e.g., `workspace__owner__password`) and read sensitive field values directly in the JSON response [CWE-943].

Affected code

The vulnerable code is in `apps/api/plane/app/views/analytic/base.py` in the `SavedAnalyticEndpoint.get()` method, which accepted a `segment` query parameter without any allowlist validation and passed it directly to `build_graph_plot()`. The helper functions `build_graph_plot()` and `extract_axis()` in `apps/api/plane/utils/analytics_plot.py` also lacked input validation on the `segment`, `x_axis`, and `y_axis` parameters [patch_id=879039].

What the fix does

The patch centralizes the previously duplicated inline allowlists into two module-level constants (`VALID_ANALYTICS_FIELDS` and `VALID_YAXIS`) in `analytics_plot.py` [patch_id=879039]. It adds defense-in-depth validation inside `build_graph_plot()` and `extract_axis()` so that no caller can pass arbitrary field references to Django `F()` expressions. Critically, the `SavedAnalyticEndpoint.get()` method now validates the `segment` parameter against `VALID_ANALYTICS_FIELDS` before calling `build_graph_plot()`, closing the injection vector that previously existed because that endpoint lacked any segment validation.

Preconditions

  • authAttacker must be an authenticated workspace MEMBER.
  • networkAttacker must be able to send HTTP GET requests to the Plane API.
  • inputAttacker must supply a crafted segment parameter that traverses foreign-key relationships (e.g., workspace__owner__password).

Generated on May 20, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.