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(expand)+ 1 more
- (no CPE)
- (no CPE)range: <=1.14.1
Patches
155d05fe52de8fix(security): enforce tenant scoping on app trace-config endpoints (GHSA-48xc-wmw8-3jr3) (#35793)
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- github.com/langgenius/dify/pull/35793nvdIssue TrackingMitigationPatch
- huntr.com/bounties/a43076b2-fbc8-4750-9647-89a036b52f52nvdExploitThird Party Advisory
- www.vulncheck.com/advisories/dify-authorization-bypass-via-trace-configuration-endpointsnvdThird Party Advisory
News mentions
0No linked articles in our index yet.