diff --git a/.secrets.baseline b/.secrets.baseline index aacaffe351..28da4fae99 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1701,16 +1701,6 @@ "is_secret": false } ], - "src/backend/base/langflow/api/utils/core.py": [ - { - "type": "Secret Keyword", - "filename": "src/backend/base/langflow/api/utils/core.py", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 413, - "is_secret": false - } - ], "src/backend/base/langflow/initial_setup/starter_projects/Basic Prompt Chaining.json": [ { "type": "Hex High Entropy String", @@ -8373,5 +8363,5 @@ } ] }, - "generated_at": "2026-04-15T15:25:36Z" + "generated_at": "2026-04-21T23:47:54Z" } diff --git a/src/backend/base/langflow/api/utils/core.py b/src/backend/base/langflow/api/utils/core.py index 32d6e212db..3bcac649b6 100644 --- a/src/backend/base/langflow/api/utils/core.py +++ b/src/backend/base/langflow/api/utils/core.py @@ -398,19 +398,44 @@ def custom_params( return Params(page=page or MIN_PAGE_SIZE, size=size or MAX_PAGE_SIZE) -def extract_global_variables_from_headers(headers) -> dict[str, str]: - """Extract global variables from HTTP headers with prefix X-LANGFLOW-GLOBAL-VAR-*. +# Well-known authentication headers that can be propagated to nested MCP calls +# when ``include_auth_headers=True`` is passed. These are stored under their +# lowercase header names so that nested server configs can reference them +# directly, e.g. ``{"x-api-key": "x-api-key"}`` in the MCP server headers config. +_AUTH_HEADERS_TO_PROPAGATE = frozenset({"x-api-key", "authorization"}) + + +def extract_global_variables_from_headers(headers, *, include_auth_headers: bool = False) -> dict[str, str]: + """Extract global variables from HTTP headers. + + By default, only headers with the ``X-LANGFLOW-GLOBAL-VAR-*`` prefix are + extracted. When ``include_auth_headers=True``, the well-known authentication + headers ``x-api-key`` and ``authorization`` are additionally captured under + their lowercase names so that nested MCP server configs can reference them + directly (e.g. ``{"x-api-key": "x-api-key"}``). + + SECURITY NOTE: Only pass ``include_auth_headers=True`` from MCP call sites + (see ``api/v1/mcp_projects.py``). On non-MCP routes such as ``/run`` and + ``/workflow``, ``x-api-key`` is Langflow's own authentication key — exposing + it in ``request_variables`` would make it readable by any component that + reads the graph context. Args: - headers: HTTP headers object (e.g., from FastAPI Request.headers) + headers: HTTP headers object (e.g., from FastAPI Request.headers). + include_auth_headers: When True, also extract well-known authentication + headers (``x-api-key``, ``authorization``) under their lowercase + names. Should only be set by MCP request handlers that need to + propagate these values to nested MCP calls. Returns: - Dictionary mapping variable names (uppercase) to their values + Dictionary mapping variable names to their values. Example: - headers = {"X-LANGFLOW-GLOBAL-VAR-API-KEY": "secret", "Content-Type": "application/json"} - result = extract_global_variables_from_headers(headers) - # Returns: {"API_KEY": "secret"} + headers = {"X-LANGFLOW-GLOBAL-VAR-API-KEY": "secret", "x-api-key": "mykey"} + extract_global_variables_from_headers(headers) + # Returns: {"API-KEY": "secret"} + extract_global_variables_from_headers(headers, include_auth_headers=True) + # Returns: {"API-KEY": "secret", "x-api-key": "mykey"} """ variables: dict[str, str] = {} @@ -420,6 +445,8 @@ def extract_global_variables_from_headers(headers) -> dict[str, str]: if header_lower.startswith(LANGFLOW_GLOBAL_VAR_HEADER_PREFIX): var_name = header_lower[len(LANGFLOW_GLOBAL_VAR_HEADER_PREFIX) :].upper() variables[var_name] = header_value + elif include_auth_headers and header_lower in _AUTH_HEADERS_TO_PROPAGATE: + variables[header_lower] = header_value except Exception as exc: # noqa: BLE001 # Log the error but don't raise - we want to continue execution logger.exception("Failed to extract global variables from headers: %s", exc) diff --git a/src/backend/base/langflow/api/v1/mcp_projects.py b/src/backend/base/langflow/api/v1/mcp_projects.py index 5199d6d755..4352ef2bb1 100644 --- a/src/backend/base/langflow/api/v1/mcp_projects.py +++ b/src/backend/base/langflow/api/v1/mcp_projects.py @@ -345,7 +345,7 @@ async def handle_project_sse( user_token = current_user_ctx.set(current_user) project_token = current_project_ctx.set(project_id) - variables = extract_global_variables_from_headers(request.headers) + variables = extract_global_variables_from_headers(request.headers, include_auth_headers=True) req_vars_token = current_request_variables_ctx.set(variables or None) try: @@ -386,7 +386,7 @@ async def _handle_project_sse_messages( """Handle POST messages for a project-specific MCP server using SSE transport.""" user_token = current_user_ctx.set(current_user) project_token = current_project_ctx.set(project_id) - variables = extract_global_variables_from_headers(request.headers) + variables = extract_global_variables_from_headers(request.headers, include_auth_headers=True) req_vars_token = current_request_variables_ctx.set(variables or None) try: @@ -443,7 +443,7 @@ async def _dispatch_project_streamable_http( user_token = current_user_ctx.set(current_user) project_token = current_project_ctx.set(project_id) - variables = extract_global_variables_from_headers(request.headers) + variables = extract_global_variables_from_headers(request.headers, include_auth_headers=True) request_vars_token = current_request_variables_ctx.set(variables or None) try: diff --git a/src/backend/tests/unit/api/v1/test_mcp_utils.py b/src/backend/tests/unit/api/v1/test_mcp_utils.py index 0dba6f8728..91c9a791fe 100644 --- a/src/backend/tests/unit/api/v1/test_mcp_utils.py +++ b/src/backend/tests/unit/api/v1/test_mcp_utils.py @@ -1,6 +1,7 @@ from types import SimpleNamespace import pytest +from langflow.api.utils.core import extract_global_variables_from_headers from langflow.api.v1 import mcp_utils from lfx.interface.components import component_cache @@ -122,3 +123,80 @@ async def test_handle_list_tools_skips_blocked_custom_flows(monkeypatch): tools = await mcp_utils.handle_list_tools() assert tools == [] + + +class TestExtractGlobalVariablesFromHeaders: + """Unit tests for ``extract_global_variables_from_headers``. + + Covers the MCP auth-header propagation fix (issue #12529): ``x-api-key`` + and ``authorization`` should be captured under their lowercase names when + (and only when) ``include_auth_headers=True`` is passed. The default + behavior must remain backwards-compatible for non-MCP routes, where + ``x-api-key`` is Langflow's own auth key and must not leak into the graph + context. + """ + + def test_langflow_global_var_prefix_still_extracted(self): + """Regression guard: ``X-LANGFLOW-GLOBAL-VAR-*`` extraction is preserved.""" + headers = { + "X-LANGFLOW-GLOBAL-VAR-API-KEY": "secret-value", + "X-LANGFLOW-GLOBAL-VAR-DB-URL": "postgres://host/db", + "Content-Type": "application/json", + } + + result = extract_global_variables_from_headers(headers) + + assert result == {"API-KEY": "secret-value", "DB-URL": "postgres://host/db"} + + def test_auth_headers_not_extracted_by_default(self): + """Non-MCP call sites: ``x-api-key`` / ``authorization`` must not leak through.""" + headers = { + "x-api-key": "langflow-auth-key", + "authorization": "Bearer token", + "X-LANGFLOW-GLOBAL-VAR-MY-VAR": "value", + } + + result = extract_global_variables_from_headers(headers) + + assert "x-api-key" not in result + assert "authorization" not in result + assert result == {"MY-VAR": "value"} + + def test_auth_headers_extracted_under_lowercase_when_opted_in(self): + """MCP call sites: lowercase auth headers are captured when opted in.""" + headers = { + "x-api-key": "api-key-value", + "authorization": "Bearer jwt-token", + } + + result = extract_global_variables_from_headers(headers, include_auth_headers=True) + + assert result == {"x-api-key": "api-key-value", "authorization": "Bearer jwt-token"} + + def test_auth_header_matching_is_case_insensitive(self): + """Headers with mixed or uppercase casing still match (e.g. ``X-Api-Key``, ``AUTHORIZATION``).""" + headers = { + "X-Api-Key": "mixed-case-value", + "AUTHORIZATION": "Bearer UPPER", + } + + result = extract_global_variables_from_headers(headers, include_auth_headers=True) + + assert result == {"x-api-key": "mixed-case-value", "authorization": "Bearer UPPER"} + + def test_both_categories_extracted_together(self): + """``X-LANGFLOW-GLOBAL-VAR-*`` and auth headers coexist when opted in.""" + headers = { + "X-LANGFLOW-GLOBAL-VAR-API-KEY": "global-secret", + "x-api-key": "incoming-mcp-key", + "Authorization": "Bearer mcp-token", + "Content-Type": "application/json", + } + + result = extract_global_variables_from_headers(headers, include_auth_headers=True) + + assert result == { + "API-KEY": "global-secret", + "x-api-key": "incoming-mcp-key", + "authorization": "Bearer mcp-token", + }