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"),
[