diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 1ca2fe73..396a6c54 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -164,7 +164,9 @@ Implementation and coverage: [output implementation](wlc/output.py), the installed CLI. - **Repository and server selection:** Review project configuration before relying on its endpoint and defaults; use a trusted explicit `--config` to - disable discovery. Do not commit valuable keys to project configuration. + disable discovery. A CLI or environment URL can still use a matching key from + discovered project configuration; supply a trusted key explicitly when its + identity matters. Do not commit valuable keys to project configuration. Choose a server appropriate for the data and operations sent to it. Server honesty, confidentiality, and authorization are outside wlc's guarantees. - **Transport choices:** Prefer HTTPS and narrowly scoped exceptions. Review diff --git a/tests/test_config.py b/tests/test_config.py index 9d89a9ef..590287e6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -410,6 +410,89 @@ def test_default_load_skips_project_config_directories(self) -> None: class WeblateProjectConfigTestCase(TestCase): """Test project configuration trust boundaries.""" + def test_project_keys_only_apply_to_own_url(self) -> None: + """A project key cannot replace a user key for an unrelated URL.""" + company_url = "https://company.example.com/api/" + project_url = "https://repo.example.com/api/" + with TemporaryDirectory() as tmpdirname: + root = Path(tmpdirname) + user_config = root / "user.ini" + user_config.write_text( + f"[weblate]\nurl = {company_url}\n[keys]\n{company_url} = user-key\n", + encoding="utf-8", + ) + project_config = root / ".weblate" + project_config.write_text( + f"[weblate]\nurl = {project_url}\n" + f"[keys]\n{company_url} = attacker-key\n" + f"{project_url} = project-key\n", + encoding="utf-8", + ) + config = WeblateConfig() + with ( + patch.object(WeblateConfig, "find_config", return_value=user_config), + patch.object( + WeblateConfig, "find_project_config", return_value=project_config + ), + patch.dict(os.environ, {}, clear=True), + ): + config.load() + self.assertEqual(config.get_url_key(), (project_url, "project-key")) + config.cli_url = company_url + self.assertEqual(config.get_url_key(), (company_url, "user-key")) + config.cli_url = None + with patch.dict(os.environ, {"WLC_URL": company_url}, clear=True): + self.assertEqual(config.get_url_key(), (company_url, "user-key")) + + def test_project_keys_require_project_url(self) -> None: + """A project without a URL cannot replace user keys.""" + company_url = "https://company.example.com/api/" + with TemporaryDirectory() as tmpdirname: + root = Path(tmpdirname) + user_config = root / "user.ini" + user_config.write_text( + f"[weblate]\nurl = {company_url}\n[keys]\n{company_url} = user-key\n", + encoding="utf-8", + ) + project_config = root / ".weblate" + project_config.write_text( + f"[keys]\n{company_url} = attacker-key\n", encoding="utf-8" + ) + config = WeblateConfig() + with ( + patch.object(WeblateConfig, "find_config", return_value=user_config), + patch.object( + WeblateConfig, "find_project_config", return_value=project_config + ), + patch.dict(os.environ, {}, clear=True), + ): + config.load() + self.assertEqual(config.get_url_key(), (company_url, "user-key")) + + def test_project_default_entries_are_not_api_keys(self) -> None: + """Project DEFAULT entries cannot supply inherited API keys.""" + company_url = "https://company.example.com/api/" + with TemporaryDirectory() as tmpdirname: + root = Path(tmpdirname) + project_config = root / ".weblate" + project_config.write_text( + f"[DEFAULT]\n{company_url} = attacker-key\n" + "[weblate]\nurl = https://repo.example.com/api/\n" + "[keys]\n", + encoding="utf-8", + ) + config = WeblateConfig() + with ( + patch.object(WeblateConfig, "find_config", return_value=None), + patch.object( + WeblateConfig, "find_project_config", return_value=project_config + ), + patch.dict(os.environ, {}, clear=True), + ): + config.load() + config.cli_url = company_url + self.assertEqual(config.get_url_key(), (company_url, "")) + def test_project_config_cannot_allow_insecure_http(self) -> None: """Project config can not opt users into insecure transport.""" with TemporaryDirectory() as tmpdirname: diff --git a/wlc/config.py b/wlc/config.py index d464d91f..1353cf08 100644 --- a/wlc/config.py +++ b/wlc/config.py @@ -117,6 +117,11 @@ def _read_config(self, path: Path | str, url_source: URLSource) -> list[str]: if parser.has_section(self.section): for option in ("allow_insecure_http", "allow_insecure_ssl"): parser.remove_option(self.section, option) + project_url = parser.get(self.section, "url", fallback=None) + if parser.has_section("keys"): + for key_url in parser.options("keys"): + if key_url != parser.optionxform(project_url or ""): + parser.remove_option("keys", key_url) config_data = StringIO() parser.write(config_data) config_data.seek(0) @@ -247,7 +252,7 @@ def _get_url_key_sources(self) -> tuple[str, URLSource, str, KeySource]: key = env_key key_source = "env" else: - key = cast("str", self.get("keys", url, fallback="")) + key = self._direct_section_items("keys").get(self.optionxform(url)) or "" key_source = "keys" if key else "none" if url_source == "project":