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

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 923a096ee5d Validate connection ports in core model (#74461)
923a096ee5d is described below

commit 923a096ee5d7046a68d3d5e7c9893501252983ce
Author: Muhammad Maaz <[email protected]>
AuthorDate: Mon Oct 19 10:50:30 2026 +0100

    Validate connection ports in core model (#74461)
    
    * Validate connection ports in core model
    
    * Fix connection port CLI parsing
    
    * Reject zero port when URI is also provided
    
    * Test URI with explicit zero port
    
    * Format URI port regression test
    
    * Accept string ports in the Connection constructor and on assignment
    
    Rejecting them would break callers that pass the port as a string (env 
vars, config values, custom secrets backends); from_json already converts them.
    
    Generated-by: Claude Opus 5
    
    ---------
    
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 airflow-core/src/airflow/cli/cli_config.py        |  2 +-
 airflow-core/src/airflow/models/connection.py     | 44 ++++++++++++++++++-----
 airflow-core/tests/unit/always/test_connection.py |  8 +++++
 airflow-core/tests/unit/models/test_connection.py | 29 +++++++++++++++
 4 files changed, 74 insertions(+), 9 deletions(-)

diff --git a/airflow-core/src/airflow/cli/cli_config.py 
b/airflow-core/src/airflow/cli/cli_config.py
index 76e35eca7bb..961a295ddee 100644
--- a/airflow-core/src/airflow/cli/cli_config.py
+++ b/airflow-core/src/airflow/cli/cli_config.py
@@ -890,7 +890,7 @@ ARG_CONN_PASSWORD = Arg(
 ARG_CONN_SCHEMA = Arg(
     ("--conn-schema",), help="Connection schema, optional when adding a 
connection", type=str
 )
-ARG_CONN_PORT = Arg(("--conn-port",), help="Connection port, optional when 
adding a connection", type=str)
+ARG_CONN_PORT = Arg(("--conn-port",), help="Connection port, optional when 
adding a connection", type=int)
 ARG_CONN_EXTRA = Arg(
     ("--conn-extra",), help="Connection `Extra` field, optional when adding a 
connection", type=str
 )
diff --git a/airflow-core/src/airflow/models/connection.py 
b/airflow-core/src/airflow/models/connection.py
index 4424316c225..52573a750e9 100644
--- a/airflow-core/src/airflow/models/connection.py
+++ b/airflow-core/src/airflow/models/connection.py
@@ -28,7 +28,7 @@ from typing import Any
 from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit
 
 from sqlalchemy import ForeignKey, Integer, String, Text, select
-from sqlalchemy.orm import Mapped, mapped_column, reconstructor
+from sqlalchemy.orm import Mapped, mapped_column, reconstructor, validates
 
 from airflow._shared.module_loading import import_string
 from airflow._shared.secrets_backend.base import call_secrets_backend_method
@@ -60,6 +60,8 @@ log = logging.getLogger(__name__)
 #
 # You can try the regex here: https://regex101.com/r/69033B/1
 RE_SANITIZE_CONN_ID = re.compile(r"^[\w#!()\-.:/\\]{1,}$")
+PORT_MIN = 0
+PORT_MAX = 65535
 # the conn ID max len should be 250
 CONN_ID_MAX_LEN: int = 250
 
@@ -171,7 +173,7 @@ class Connection(Base, FernetFieldsMixin, LoggingMixin):
         self.description = description
         if extra and not isinstance(extra, str):
             extra = json.dumps(extra)
-        if uri and (conn_type or host or login or password or schema or port 
or extra):
+        if uri and (conn_type or host or login or password or schema or port 
is not None or extra):
             raise AirflowException(
                 "You must create an object using the URI or individual values "
                 "(conn_type, host, login, password, schema, port or extra)."
@@ -219,6 +221,35 @@ class Connection(Base, FernetFieldsMixin, LoggingMixin):
             raise ValueError(f"Encountered non-JSON in `extra` field for 
connection {conn_id!r}.")
         return None
 
+    @staticmethod
+    def _validate_port(port: int | None) -> int | None:
+        if port is None:
+            return None
+        if type(port) is not int:
+            raise ValueError(f"Expected integer value for `port`, but got 
{port!r} instead.")
+        if not PORT_MIN <= port <= PORT_MAX:
+            raise ValueError(
+                f"Expected value for `port` in the range 
{PORT_MIN}-{PORT_MAX}, but got {port!r} instead."
+            )
+        return port
+
+    @classmethod
+    def _coerce_port(cls, port) -> int | None:
+        if port is None or port == "":
+            return None
+        if isinstance(port, str):
+            try:
+                port = int(port)
+            except ValueError:
+                raise ValueError(f"Expected integer value for `port`, but got 
{port!r} instead.") from None
+        return cls._validate_port(port)
+
+    @validates("port")
+    def _validate_port_assignment(self, _, port: int | str | None) -> int | 
None:
+        # Callers commonly pass the port as a string (env vars, config, custom 
secrets backends);
+        # accept those like ``from_json`` does rather than breaking them.
+        return self._coerce_port(port)
+
     @reconstructor
     def on_db_load(self):
         if self.password:
@@ -328,7 +359,7 @@ class Connection(Base, FernetFieldsMixin, LoggingMixin):
         if host_to_use:
             host_block += quote(host_to_use, safe="")
 
-        if self.port:
+        if self.port is not None:
             if host_block == "" and authority_block == "":
                 host_block += f"@:{self.port}"
             else:
@@ -615,11 +646,8 @@ class Connection(Base, FernetFieldsMixin, LoggingMixin):
         if conn_type:
             kwargs["conn_type"] = cls._normalize_conn_type(conn_type)
         port = kwargs.pop("port", None)
-        if port:
-            try:
-                kwargs["port"] = int(port)
-            except ValueError:
-                raise ValueError(f"Expected integer value for `port`, but got 
{port!r} instead.")
+        if port is not None:
+            kwargs["port"] = cls._coerce_port(port)
         return Connection(conn_id=conn_id, **kwargs)
 
     def as_json(self) -> str:
diff --git a/airflow-core/tests/unit/always/test_connection.py 
b/airflow-core/tests/unit/always/test_connection.py
index 8b56791d7c5..3267861c445 100644
--- a/airflow-core/tests/unit/always/test_connection.py
+++ b/airflow-core/tests/unit/always/test_connection.py
@@ -620,8 +620,11 @@ class TestConnection:
     @pytest.mark.parametrize(
         ("val", "expected"),
         [
+            ('{"port": 0}', 0),
             ('{"port": 1}', 1),
             ('{"port": "1"}', 1),
+            ('{"port": "0"}', 0),
+            ('{"port": ""}', None),
             ('{"port": null}', None),
         ],
     )
@@ -629,6 +632,11 @@ class TestConnection:
         """Two conn_type normalizations are applied: replace - with _ and 
postgresql with postgres"""
         assert Connection.from_json(val).port == expected
 
+    @pytest.mark.parametrize("val", ['{"port": -1}', '{"port": 65536}', 
'{"port": 1.5}', '{"port": "1.5"}'])
+    def test_from_json_rejects_invalid_port(self, val):
+        with pytest.raises(ValueError, match="port"):
+            Connection.from_json(val)
+
     @pytest.mark.parametrize(
         ("val", "expected"),
         [
diff --git a/airflow-core/tests/unit/models/test_connection.py 
b/airflow-core/tests/unit/models/test_connection.py
index e7f6cbf6cb1..3e83a20cf2c 100644
--- a/airflow-core/tests/unit/models/test_connection.py
+++ b/airflow-core/tests/unit/models/test_connection.py
@@ -401,6 +401,35 @@ class TestConnection:
             captured_log for captured_log in captured_logs if 
captured_log["log_level"] == "warning"
         ] == []
 
+    def test_rejects_uri_with_zero_port_argument(self):
+        with pytest.raises(
+            AirflowException, match="You must create an object using the URI 
or individual values"
+        ):
+            Connection(uri="http://example.com";, port=0)
+
+    @pytest.mark.parametrize("port", [0, 65535, None])
+    def test_allows_valid_port_boundaries(self, port):
+        conn = Connection(conn_id="test-port", conn_type="http", port=port)
+        assert conn.port == port
+
+    @pytest.mark.parametrize(("port", "expected"), [("123", 123), ("0", 0), 
("", None)])
+    def test_converts_string_port_values(self, port, expected):
+        conn = Connection(conn_id="test-port", conn_type="http", port=port)
+        assert conn.port == expected
+
+    @pytest.mark.parametrize("port", [-1, 65536, 1.5, "abc", "65536"])
+    def test_rejects_invalid_direct_port_values(self, port):
+        with pytest.raises(ValueError, match="port"):
+            Connection(conn_id="test-port", conn_type="http", port=port)
+
+    def test_rejects_invalid_port_assignment(self):
+        conn = Connection(conn_id="test-port", conn_type="http", port=5432)
+
+        with pytest.raises(ValueError, match="port"):
+            conn.port = 65536
+
+        assert conn.port == 5432
+
     @pytest.mark.parametrize(
         ("connection", "expected_conn_id"),
         [

Reply via email to