Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion wlc/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand Down
Loading