VYPR
High severity7.4NVD Advisory· Published May 18, 2026· Updated May 19, 2026

CVE-2026-41947

CVE-2026-41947

Description

Dify version 1.14.1 and prior contains an authorization bypass vulnerability that allows authenticated editor users to set and enable trace configurations for any application regardless of tenant ownership. Attackers can exploit missing tenant ownership checks in the trace configuration endpoints to redirect all messages and responses from victim applications to attacker-controlled LLM trace providers. NOTE: Dify Cloud allows unauthenticated free self-registration, making account creation trivially accessible to any attacker.

AI Insight

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

Authenticated editor users in Dify ≤1.14.1 can bypass tenant ownership checks to redirect victim app traces to attacker-controlled providers.

Vulnerability

Dify version 1.14.1 and prior contains an authorization bypass vulnerability in the trace configuration endpoints (/console/api/apps/<app_id>/trace-config). The endpoints (GET, POST, PATCH, DELETE) only verify that the caller is authenticated but do not check that the target app_id belongs to the caller's tenant [1][2]. This allows any authenticated editor user to read, modify, or delete the tracing configuration of any application across tenants.

Exploitation

An attacker can register a free account on Dify Cloud (which allows unauthenticated self-registration) [2]. Once authenticated, the attacker can craft requests to the trace-config endpoints for any known app_id belonging to another tenant. By modifying the trace configuration, the attacker can redirect all LLM messages and responses from the victim's application to an attacker-controlled trace provider (e.g., Langfuse) [1][2]. No additional privileges or user interaction are required beyond authentication.

Impact

Successful exploitation allows the attacker to exfiltrate all LLM conversation data (prompts and responses) from victim applications to an external server under their control. This constitutes a high-severity information disclosure and integrity violation, as the attacker can also modify trace settings to disrupt monitoring or inject malicious trace data [2]. The vulnerability affects all applications in the Dify instance, not just those owned by the attacker.

Mitigation

The fix is implemented in pull request #35793, which applies the @get_app_model decorator to enforce tenant scoping on all four trace-config endpoints [1]. The fix was merged on May 5, 2026, and is expected to be included in a future release (after 1.14.1). Users should upgrade to the patched version once available. As a workaround, administrators can restrict network access to the console API or implement additional authentication proxies. No CISA KEV listing has been published as of the advisory date.

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
  • Langgenius/Difyreferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • (no CPE)range: <=1.14.1

Patches

1
55d05fe52de8

fix(security): enforce tenant scoping on app trace-config endpoints (GHSA-48xc-wmw8-3jr3) (#35793)

https://github.com/langgenius/difyTim RenMay 14, 2026via nvd-ref
4 files changed · +66 23
  • api/controllers/console/app/app.py+6 5 modified
    @@ -3,7 +3,6 @@
     import uuid
     from datetime import datetime
     from typing import Any, Literal
    -from uuid import UUID
     
     from flask import request
     from flask_restx import Resource
    @@ -850,10 +849,11 @@ class AppTraceApi(Resource):
         @setup_required
         @login_required
         @account_initialization_required
    -    def get(self, app_id: UUID):
    +    @get_app_model
    +    def get(self, app_model):
             """Get app trace"""
             with session_factory.create_session() as session:
    -            app_trace_config = OpsTraceManager.get_app_tracing_config(str(app_id), session)
    +            app_trace_config = OpsTraceManager.get_app_tracing_config(app_model.id, session)
     
             return app_trace_config
     
    @@ -867,12 +867,13 @@ def get(self, app_id: UUID):
         @login_required
         @account_initialization_required
         @edit_permission_required
    -    def post(self, app_id: UUID):
    +    @get_app_model
    +    def post(self, app_model):
             # add app trace
             args = AppTracePayload.model_validate(console_ns.payload)
     
             OpsTraceManager.update_app_tracing_config(
    -            app_id=str(app_id),
    +            app_id=app_model.id,
                 enabled=args.enabled,
                 tracing_provider=args.tracing_provider,
             )
    
  • api/controllers/console/app/ops_trace.py+17 10 modified
    @@ -1,5 +1,4 @@
     from typing import Any
    -from uuid import UUID
     
     from flask import request
     from flask_restx import Resource, fields
    @@ -9,8 +8,10 @@
     from controllers.common.schema import register_schema_models
     from controllers.console import console_ns
     from controllers.console.app.error import TracingConfigCheckError, TracingConfigIsExist, TracingConfigNotExist
    +from controllers.console.app.wraps import get_app_model
     from controllers.console.wraps import account_initialization_required, setup_required
     from libs.login import login_required
    +from models import App
     from services.ops_service import OpsService
     
     
    @@ -43,11 +44,14 @@ class TraceAppConfigApi(Resource):
         @setup_required
         @login_required
         @account_initialization_required
    -    def get(self, app_id: UUID):
    -        args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True))
    +    @get_app_model
    +    def get(self, app_model: App):
    +        args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True))  # type: ignore
     
             try:
    -            trace_config = OpsService.get_tracing_app_config(app_id=str(app_id), tracing_provider=args.tracing_provider)
    +            trace_config = OpsService.get_tracing_app_config(
    +                app_id=app_model.id, tracing_provider=args.tracing_provider
    +            )
                 if not trace_config:
                     return {"has_not_configured": True}
                 return trace_config
    @@ -65,13 +69,14 @@ def get(self, app_id: UUID):
         @setup_required
         @login_required
         @account_initialization_required
    -    def post(self, app_id: UUID):
    +    @get_app_model
    +    def post(self, app_model: App):
             """Create a new trace app configuration"""
             args = TraceConfigPayload.model_validate(console_ns.payload)
     
             try:
                 result = OpsService.create_tracing_app_config(
    -                app_id=str(app_id), tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
    +                app_id=app_model.id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
                 )
                 if not result:
                     raise TracingConfigIsExist()
    @@ -90,13 +95,14 @@ def post(self, app_id: UUID):
         @setup_required
         @login_required
         @account_initialization_required
    -    def patch(self, app_id: UUID):
    +    @get_app_model
    +    def patch(self, app_model: App):
             """Update an existing trace app configuration"""
             args = TraceConfigPayload.model_validate(console_ns.payload)
     
             try:
                 result = OpsService.update_tracing_app_config(
    -                app_id=str(app_id), tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
    +                app_id=app_model.id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
                 )
                 if not result:
                     raise TracingConfigNotExist()
    @@ -113,12 +119,13 @@ def patch(self, app_id: UUID):
         @setup_required
         @login_required
         @account_initialization_required
    -    def delete(self, app_id: UUID):
    +    @get_app_model
    +    def delete(self, app_model: App):
             """Delete an existing trace app configuration"""
             args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True))
     
             try:
    -            result = OpsService.delete_tracing_app_config(app_id=str(app_id), tracing_provider=args.tracing_provider)
    +            result = OpsService.delete_tracing_app_config(app_id=app_model.id, tracing_provider=args.tracing_provider)
                 if not result:
                     raise TracingConfigNotExist()
                 return {"result": "success"}, 204
    
  • api/tests/test_containers_integration_tests/controllers/console/app/test_app_apis.py+40 3 modified
    @@ -8,7 +8,9 @@
     
     import pytest
     from flask import Flask
    +from flask.testing import FlaskClient
     from pydantic import ValidationError
    +from sqlalchemy.orm import Session
     from werkzeug.exceptions import BadRequest, NotFound
     
     from controllers.console import console_ns
    @@ -57,6 +59,12 @@
     from controllers.console.app.workflow_draft_variable import WorkflowDraftVariableUpdatePayload
     from controllers.console.app.workflow_statistic import WorkflowStatisticQuery
     from controllers.console.app.workflow_trigger import Parser, ParserEnable
    +from models.model import AppMode
    +from tests.test_containers_integration_tests.controllers.console.helpers import (
    +    authenticate_console_client,
    +    create_console_account_and_tenant,
    +    create_console_app,
    +)
     
     
     def _unwrap(func):
    @@ -270,6 +278,35 @@ class TestOpsTraceEndpoints:
         def app(self, flask_app_with_containers: Flask):
             return flask_app_with_containers
     
    +    @pytest.mark.parametrize(
    +        "path_template",
    +        [
    +            "/console/api/apps/{app_id}/trace-config?tracing_provider=langfuse",
    +            "/console/api/apps/{app_id}/trace",
    +        ],
    +    )
    +    def test_trace_endpoints_hide_apps_from_other_tenants(
    +        self,
    +        db_session_with_containers: Session,
    +        test_client_with_containers: FlaskClient,
    +        path_template: str,
    +    ):
    +        account, _tenant = create_console_account_and_tenant(db_session_with_containers)
    +        foreign_account, foreign_tenant = create_console_account_and_tenant(db_session_with_containers)
    +        foreign_app = create_console_app(
    +            db_session_with_containers,
    +            tenant_id=foreign_tenant.id,
    +            account_id=foreign_account.id,
    +            mode=AppMode.CHAT,
    +        )
    +
    +        response = test_client_with_containers.get(
    +            path_template.format(app_id=foreign_app.id),
    +            headers=authenticate_console_client(test_client_with_containers, account),
    +        )
    +
    +        assert response.status_code == 404
    +
         def test_ops_trace_query_basic(self):
             query = TraceProviderQuery(tracing_provider="langfuse")
             assert query.tracing_provider == "langfuse"
    @@ -289,7 +326,7 @@ def test_trace_app_config_get_empty(self, app: Flask, monkeypatch: pytest.Monkey
             )
     
             with app.test_request_context("/?tracing_provider=langfuse"):
    -            result = method(app_id="app-1")
    +            result = method(app_model=MagicMock(id="app-1"))
     
             assert result == {"has_not_configured": True}
     
    @@ -308,7 +345,7 @@ def test_trace_app_config_post_invalid(self, app: Flask, monkeypatch: pytest.Mon
                 json={"tracing_provider": "langfuse", "tracing_config": {"api_key": "k"}},
             ):
                 with pytest.raises(BadRequest):
    -                method(app_id="app-1")
    +                method(app_model=MagicMock(id="app-1"))
     
         def test_trace_app_config_delete_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch):
             api = ops_trace_module.TraceAppConfigApi()
    @@ -322,7 +359,7 @@ def test_trace_app_config_delete_not_found(self, app: Flask, monkeypatch: pytest
     
             with app.test_request_context("/?tracing_provider=langfuse"):
                 with pytest.raises(BadRequest):
    -                method(app_id="app-1")
    +                method(app_model=MagicMock(id="app-1"))
     
     
     class TestSiteEndpoints:
    
  • api/tests/test_containers_integration_tests/controllers/console/app/test_chat_conversation_status_count_api.py+3 5 modified
    @@ -6,17 +6,17 @@
     from flask.testing import FlaskClient
     from sqlalchemy.orm import Session
     
    -from configs import dify_config
     from constants import HEADER_NAME_CSRF_TOKEN
     from graphon.enums import WorkflowExecutionStatus
     from libs.datetime_utils import naive_utc_now
     from libs.token import _real_cookie_name, generate_csrf_token
    -from models import Account, DifySetup, Tenant, TenantAccountJoin
    +from models import Account, Tenant, TenantAccountJoin
     from models.account import AccountStatus, TenantAccountRole, TenantStatus
     from models.enums import ConversationFromSource, CreatorUserRole
     from models.model import App, AppMode, Conversation, Message
     from models.workflow import WorkflowRun
     from services.account_service import AccountService
    +from tests.test_containers_integration_tests.controllers.console.helpers import ensure_dify_setup
     
     
     def _create_account_and_tenant(db_session: Session) -> tuple[Account, Tenant]:
    @@ -47,9 +47,7 @@ def _create_account_and_tenant(db_session: Session) -> tuple[Account, Tenant]:
         account.timezone = "UTC"
         db_session.commit()
     
    -    dify_setup = DifySetup(version=dify_config.project.version)
    -    db_session.add(dify_setup)
    -    db_session.commit()
    +    ensure_dify_setup(db_session)
     
         return account, tenant
     
    

Vulnerability mechanics

Root cause

"Missing tenant-ownership validation in trace-configuration endpoints allows an authenticated user to access or modify another tenant's application trace settings by supplying an arbitrary app_id."

Attack vector

An attacker first registers a free account on Dify Cloud (unauthenticated self-registration is permitted). After logging in, the attacker sends GET, POST, PATCH, or DELETE requests to endpoints such as `/console/api/apps/{app_id}/trace-config` or `/console/api/apps/{app_id}/trace`, substituting a victim application's app_id. Because the endpoints previously accepted a raw `app_id` UUID parameter without verifying that the app belongs to the attacker's tenant [CWE-639], the server returns or modifies the victim's trace configuration. The attacker can then set a trace provider (e.g., Langfuse) under their control, causing all LLM messages and responses from the victim's app to be exfiltrated to the attacker's external tracing service.

Affected code

The vulnerable code is in `api/controllers/console/app/ops_trace.py` (class `TraceAppConfigApi`, methods `get`, `post`, `patch`, `delete`) and `api/controllers/console/app/app.py` (class `AppTraceApi`, methods `get`, `post`). These methods accepted a raw `app_id: UUID` parameter and passed it directly to `OpsService` methods without verifying that the app belongs to the requesting user's tenant.

What the fix does

The patch replaces the direct `app_id: UUID` parameter with the `@get_app_model` decorator on all five trace-configuration handler methods (GET, POST, PATCH, DELETE in `TraceAppConfigApi` and GET, POST in `AppTraceApi`) [patch_id=424436]. The `@get_app_model` decorator looks up the `App` record using the route's app_id and automatically enforces that the app belongs to the current user's tenant, returning a 404 if it does not. The handler methods then receive an `app_model` object instead of a raw UUID, so the tenant check happens before any database operation. The integration test `test_trace_endpoints_hide_apps_from_other_tenants` confirms that requests for a foreign tenant's app now return HTTP 404.

Preconditions

  • authAttacker must have a valid authenticated session on the Dify instance (free self-registration is available on Dify Cloud).
  • inputAttacker must know or enumerate a victim application's app_id UUID.
  • configThe victim application must have trace configuration enabled or be configurable.

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

References

3

News mentions

0

No linked articles in our index yet.