This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-3-test by this push:
     new a033b7a6c61 [v3-3-test] Refuse a separator-bearing secret id before 
the team scoped lookup (#70902) (#71041)
a033b7a6c61 is described below

commit a033b7a6c613c79f495b9acce5221265fc7c9649
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Aug 4 02:46:58 2026 +0200

    [v3-3-test] Refuse a separator-bearing secret id before the team scoped 
lookup (#70902) (#71041)
    
    * Refuse a separator-bearing secret id before the team scoped lookup
    
    The team scoped lookup builds PREFIX + _<TEAM>___ + <ID>, so an id that 
itself
    contains ___ makes that string ambiguous. A caller in team_a asking for the 
bare
    id prod___dbconn builds AIRFLOW_CONN__TEAM_A___PROD___DBCONN, 
byte-identical to
    what team team_a___prod builds for its own id dbconn -- and that lookup 
hits, so
    the guard that ran only ahead of the team agnostic fall-through was never
    reached.
    
    Move the check ahead of both lookups and widen it from "spells out a team
    namespace" to "contains the separator". The narrower form had to reason 
about
    which team an id might name, which is unanswerable while a team name may 
itself
    contain the separator; the broader form does not, and it no longer depends 
on
    stored team names being valid.
    
    Costs an id that itself contains ___, which is now unreachable in either 
scope
    including for its owning team. That is deliberate and tested: the string 
such an
    id builds is one another team's name could build, and nothing in it says 
which
    reading was meant.
    
    This matches what the provider secrets backends already do.
    
    * Condense the collision rationale to one site
    
    Both lookups carried the same five-line explanation verbatim.
    (cherry picked from commit ed87a1a024a55aaa494e6a718cb876f0cbe47b44)
    
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 .../src/airflow/secrets/environment_variables.py   | 46 ++++++----------------
 .../always/test_secrets_environment_variables.py   | 44 +++++++++++++++++++++
 2 files changed, 57 insertions(+), 33 deletions(-)

diff --git a/airflow-core/src/airflow/secrets/environment_variables.py 
b/airflow-core/src/airflow/secrets/environment_variables.py
index 1a50d58138a..2432c084f5d 100644
--- a/airflow-core/src/airflow/secrets/environment_variables.py
+++ b/airflow-core/src/airflow/secrets/environment_variables.py
@@ -20,27 +20,32 @@
 from __future__ import annotations
 
 import os
-import re
 
 from airflow.secrets import BaseSecretsBackend
 
 CONN_ENV_PREFIX = "AIRFLOW_CONN_"
 VAR_ENV_PREFIX = "AIRFLOW_VAR_"
 
+# Separates the team name from the secret id in a team namespaced environment 
variable
+# name: AIRFLOW_CONN__<TEAM>___<ID>.
+TEAM_SEP = "___"
+
 
 class EnvironmentVariablesBackend(BaseSecretsBackend):
     """Retrieves Connection object and Variable from environment variable."""
 
     def get_conn_value(self, conn_id: str, team_name: str | None = None) -> 
str | None:
+        if TEAM_SEP in conn_id:
+            # An id containing the separator could collide with another team's 
namespace
+            # even on the scoped lookup below, so it must be refused before 
either runs.
+            return None
+
         if team_name and (
             team_var := 
os.environ.get(f"{CONN_ENV_PREFIX}_{team_name.upper()}___" + conn_id.upper())
         ):
             # Format to set a team specific connection: 
AIRFLOW_CONN__<TEAM_ID>___<CONN_ID>
             return team_var
 
-        if self._names_a_team_namespace(conn_id):
-            return None
-
         return os.environ.get(CONN_ENV_PREFIX + conn_id.upper())
 
     def get_variable(self, key: str, team_name: str | None = None) -> str | 
None:
@@ -51,39 +56,14 @@ class EnvironmentVariablesBackend(BaseSecretsBackend):
         :param team_name: Team name associated to the task trying to access 
the variable (if any)
         :return: Variable Value
         """
+        if TEAM_SEP in key:
+            # Same collision risk as get_conn_value, see its code comment.
+            return None
+
         if team_name and (
             team_var := 
os.environ.get(f"{VAR_ENV_PREFIX}_{team_name.upper()}___" + key.upper())
         ):
             # Format to set a team specific variable: 
AIRFLOW_VAR__<TEAM_ID>___<VAR_KEY>
             return team_var
 
-        if self._names_a_team_namespace(key):
-            return None
-
         return os.environ.get(VAR_ENV_PREFIX + key.upper())
-
-    @staticmethod
-    def _names_a_team_namespace(secret_id: str) -> bool:
-        """
-        Whether ``secret_id`` spells out a team namespaced environment 
variable name.
-
-        A team specific secret lives in the ``_<TEAM_NAME>___<SECRET_ID>`` 
namespace of the
-        environment. An id of that shape therefore makes the team agnostic 
lookup -- which
-        prepends only ``AIRFLOW_CONN_`` / ``AIRFLOW_VAR_`` -- land inside some 
team's namespace,
-        so that lookup is refused for such an id.
-
-        **The id is never attributed to a particular team**, because it cannot 
be: a team name
-        may itself contain the ``___`` separator, so ``_a___b___c`` is both 
team ``a`` with id
-        ``b___c`` and team ``a___b`` with id ``c``, and nothing in the string 
distinguishes them.
-        Only the caller's own namespace is ever constructed, never parsed.
-
-        This is why the check is not "does the id belong to some team other 
than the caller's".
-        Comparing the id against the prefix the caller's team builds looks 
equivalent and is not:
-        for a caller in team ``a`` the id ``_a___b___c`` starts with 
``_A___``, yet the variable
-        it resolves, ``AIRFLOW_CONN__A___B___C``, is team ``a___b``'s. 
Treating a prefix match as
-        ownership hands one team the secrets of every team whose name extends 
it. Callers reach
-        their own team's secrets with the bare id plus their team scope -- 
handled above, and safe
-        because that path can only ever build the caller's own namespace -- so 
nothing legitimate
-        needs a namespaced id here.
-        """
-        return re.fullmatch(r"_.+___.+", secret_id) is not None
diff --git 
a/airflow-core/tests/unit/always/test_secrets_environment_variables.py 
b/airflow-core/tests/unit/always/test_secrets_environment_variables.py
index 18fb5d2444e..9f8e8379efc 100644
--- a/airflow-core/tests/unit/always/test_secrets_environment_variables.py
+++ b/airflow-core/tests/unit/always/test_secrets_environment_variables.py
@@ -21,6 +21,7 @@ import pytest
 
 from airflow.secrets.environment_variables import (
     CONN_ENV_PREFIX,
+    TEAM_SEP,
     VAR_ENV_PREFIX,
     EnvironmentVariablesBackend,
 )
@@ -136,6 +137,49 @@ class TestEnvironmentVariablesBackendTeamScope:
 
         assert lookup(env_prefix, method, SECRET_ID, team_name) == GLOBAL_VALUE
 
+    @pytest.mark.parametrize(("env_prefix", "method"), LOOKUPS)
+    def test_a_bare_id_containing_the_separator_is_not_resolved(self, 
monkeypatch, env_prefix, method):
+        """The scoped lookup can build another team's variable name from a 
*bare* id.
+
+        Caller in ``team_a`` supplies ``prod___dbconn``. The team scoped 
lookup builds
+        ``<PREFIX>_TEAM_A___PROD___DBCONN`` -- byte-identical to what team 
``team_a___prod``
+        builds for its own id ``dbconn``. That lookup **hits**, so refusing 
only the team
+        agnostic fall-through never sees this: the fall-through is never 
reached.
+        """
+        target_team, target_id = "team_a___prod", "dbconn"
+        monkeypatch.setenv(team_env_var(env_prefix, target_team, target_id), 
TEAM_VALUE)
+
+        assert lookup(env_prefix, method, f"prod{TEAM_SEP}{target_id}", 
"team_a") is None
+
+    @pytest.mark.parametrize(("env_prefix", "method"), LOOKUPS)
+    @pytest.mark.parametrize("team_name", [None, *TEAM_NAMES])
+    def test_an_id_containing_the_separator_is_refused_in_every_scope(
+        self, monkeypatch, env_prefix, method, team_name
+    ):
+        """One rule, applied the same way with or without a team scope.
+
+        An id carrying the separator cannot be resolved unambiguously in 
either direction, so
+        there is no scope in which it is safe to look up.
+        """
+        monkeypatch.setenv(env_prefix + f"A{TEAM_SEP}B".upper(), GLOBAL_VALUE)
+
+        assert lookup(env_prefix, method, f"a{TEAM_SEP}b", team_name) is None
+
+    @pytest.mark.parametrize(("env_prefix", "method"), LOOKUPS)
+    @pytest.mark.parametrize("team_name", TEAM_NAMES)
+    def test_the_owning_team_cannot_reach_a_separator_bearing_id_either(
+        self, monkeypatch, env_prefix, method, team_name
+    ):
+        """Documents the cost of the rule, so a future change does not 
reintroduce the hole.
+
+        A team's *own* secret whose id contains the separator is unreachable 
too. That is
+        deliberate: the string it builds is the same one another team's name 
could build, and
+        nothing in it says which reading is intended.
+        """
+        monkeypatch.setenv(team_env_var(env_prefix, team_name, 
f"prod{TEAM_SEP}dbconn"), TEAM_VALUE)
+
+        assert lookup(env_prefix, method, f"prod{TEAM_SEP}dbconn", team_name) 
is None
+
     @pytest.mark.parametrize(("env_prefix", "method"), LOOKUPS)
     @pytest.mark.parametrize("team_name", [None, *TEAM_NAMES])
     def test_unset_secret_is_not_resolved(self, monkeypatch, env_prefix, 
method, team_name):

Reply via email to