From a1febd6999974c2c91fdcc1ae96ca952354d7d89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Fri, 17 Jul 2026 12:48:45 +0700 Subject: [PATCH 1/2] fix: require HTTPS for Redmine URL Config.validate() now rejects REDMINE_URL values whose scheme is not HTTPS (http://, missing scheme, or any other scheme). The API key is sent in request headers, so a plain-HTTP endpoint would expose it. Scheme comparison is case-insensitive per RFC 3986. Closes #54 --- README.md | 1 + redmine_reporter/config.py | 8 +++++- tests/test_config.py | 50 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1cf982e..04a381d 100644 --- a/README.md +++ b/README.md @@ -317,3 +317,4 @@ mypy redmine_reporter - Рекомендуется хранить секреты через `${VAR}`, а не plaintext. - Используйте аккаунт с минимальными правами, достаточными для чтения time entries и задач. - Инструмент работает только в режиме чтения и не изменяет данные в Redmine. +- `REDMINE_URL` обязан использовать HTTPS: API-ключ передаётся в заголовках запроса, и без TLS он может быть перехвачен. diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index 9d3fc29..53198b8 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -398,8 +398,14 @@ class Config: @classmethod def validate(cls) -> None: - if not cls.get_redmine_url(): + url = cls.get_redmine_url() + if not url: raise ValueError("REDMINE_URL is required (set via env or .env)") + if not url.lower().startswith("https://"): + raise ValueError( + "REDMINE_URL must use HTTPS: the API key is sent in request " + "headers and requires TLS" + ) if cls.get_redmine_api_key(): return if not (cls.get_redmine_user() and cls.get_redmine_password()): diff --git a/tests/test_config.py b/tests/test_config.py index c1feb7b..a1a58d6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -126,6 +126,56 @@ def test_get_redmine_verify_custom_path(): assert Config.get_redmine_verify() == "/tmp/redmine-ca.pem" +# -- #54: REDMINE_URL обязан использовать HTTPS -- + + +class TestConfigRequiresHttpsUrl: + """Config.validate() отклоняет URL без TLS: API-ключ идёт в заголовках.""" + + @mock.patch.dict( + os.environ, + {"REDMINE_URL": "http://red.eltex.loc/", "REDMINE_API_KEY": "token"}, + clear=True, + ) + def test_http_url_rejected(self): + with pytest.raises(ValueError, match="HTTPS"): + Config.validate() + + @mock.patch.dict( + os.environ, + {"REDMINE_URL": "red.eltex.loc", "REDMINE_API_KEY": "token"}, + clear=True, + ) + def test_url_without_scheme_rejected(self): + with pytest.raises(ValueError, match="HTTPS"): + Config.validate() + + @mock.patch.dict( + os.environ, + {"REDMINE_URL": "ftp://red.eltex.loc/", "REDMINE_API_KEY": "token"}, + clear=True, + ) + def test_non_http_scheme_rejected(self): + with pytest.raises(ValueError, match="HTTPS"): + Config.validate() + + @mock.patch.dict( + os.environ, + {"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_API_KEY": "token"}, + clear=True, + ) + def test_https_url_accepted(self): + Config.validate() + + @mock.patch.dict( + os.environ, + {"REDMINE_URL": "HTTPS://red.eltex.loc/", "REDMINE_API_KEY": "token"}, + clear=True, + ) + def test_uppercase_scheme_accepted(self): + Config.validate() + + # -- #15: .env не должен переопределять переменные окружения -- From b624a8b8c2d3ce31608572e1374e3f5a24af8126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Fri, 17 Jul 2026 12:50:40 +0700 Subject: [PATCH 2/2] fix: warn when TLS verification is disabled REDMINE_VERIFY=false / verify_ssl: false silently disabled TLS certificate verification, exposing the connection to MITM attacks. Config.validate() now prints a warning to stderr when verification is disabled (exactly False; True or a CA-bundle path stays quiet). validate() runs exactly once at CLI startup, so the warning is emitted once per run and is visible in every scenario. Closes #57 --- README.md | 1 + redmine_reporter/config.py | 8 ++++++++ tests/test_config.py | 40 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/README.md b/README.md index 04a381d..95db9eb 100644 --- a/README.md +++ b/README.md @@ -318,3 +318,4 @@ mypy redmine_reporter - Используйте аккаунт с минимальными правами, достаточными для чтения time entries и задач. - Инструмент работает только в режиме чтения и не изменяет данные в Redmine. - `REDMINE_URL` обязан использовать HTTPS: API-ключ передаётся в заголовках запроса, и без TLS он может быть перехвачен. +- `REDMINE_VERIFY=false` (или `verify_ssl: false`) отключает проверку TLS-сертификата — соединение уязвимо для MITM-атак; при старте выводится предупреждение. Используйте только в доверенной сети. diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index 53198b8..b41e9d6 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -1,5 +1,6 @@ import logging import os +import sys from dataclasses import dataclass, field from datetime import date, timedelta from pathlib import Path @@ -406,6 +407,13 @@ class Config: "REDMINE_URL must use HTTPS: the API key is sent in request " "headers and requires TLS" ) + if cls.get_redmine_verify() is False: + print( + "⚠️ TLS certificate verification is disabled " + "(REDMINE_VERIFY=false / verify_ssl: false): connection is " + "vulnerable to MITM attacks", + file=sys.stderr, + ) if cls.get_redmine_api_key(): return if not (cls.get_redmine_user() and cls.get_redmine_password()): diff --git a/tests/test_config.py b/tests/test_config.py index a1a58d6..079b6aa 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -176,6 +176,46 @@ class TestConfigRequiresHttpsUrl: Config.validate() +# -- #57: предупреждение при отключённой проверке TLS -- + + +class TestWarnWhenTlsVerificationDisabled: + """verify_ssl: false должен давать видимый warning в stderr (риск MITM).""" + + _ENV = {"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_API_KEY": "token"} + + def _validate_env(self, verify: str): + return {**self._ENV, "REDMINE_VERIFY": verify} + + def test_verify_false_warns_in_stderr(self, capsys): + with mock.patch.dict(os.environ, self._validate_env("false"), clear=True): + Config.validate() + + err = capsys.readouterr().err + assert "⚠️" in err + assert "TLS" in err or "SSL" in err + + def test_verify_true_no_warning(self, capsys): + with mock.patch.dict(os.environ, self._validate_env("true"), clear=True): + Config.validate() + + assert capsys.readouterr().err == "" + + def test_verify_ca_path_no_warning(self, capsys): + with mock.patch.dict( + os.environ, self._validate_env("/tmp/redmine-ca.pem"), clear=True + ): + Config.validate() + + assert capsys.readouterr().err == "" + + def test_verify_default_no_warning(self, capsys): + with mock.patch.dict(os.environ, self._ENV, clear=True): + Config.validate() + + assert capsys.readouterr().err == "" + + # -- #15: .env не должен переопределять переменные окружения --