wlc Leaks API Token to Attacker-Controlled Weblate URL via Untrusted Project Config Discovery
wlc discovers project configuration by walking the current working directory and
every parent directory for .weblate / .weblate.ini / weblate.ini
(WeblateConfig.find_project_config). That file sets the API url. The API key,
however, is resolved independently and is not bound to a trusted URL source:
with $WLC_KEY set (the project's own documented CI pattern) the secret is sent to
whatever URL the discovered config names. If the .weblate is attacker-controlled
— a malicious pull request to a public repo, a cloned/untrusted repo, a planted
file in any ancestor directory — running any wlc command ships the secret API
token to an attacker-chosen server. This is the same "token must not egress to an
unexpected origin" property that normalize_request_url defends for server-
returned URLs, left open for the configured URL.
Technical Detail
Root cause
# wlc/config.py:67-94 @ 7c01e2f
@staticmethod
def find_project_config() -> str | None:
"""Find the nearest project configuration file."""
cwd = os.path.abspath(".")
prev = None
while cwd != prev: # walk CWD and every ancestor
for name in (".weblate", ".weblate.ini", "weblate.ini"):
conf_name = os.path.join(cwd, name)
if os.path.isfile(conf_name):
return conf_name # untrusted file controls [weblate] url
prev = cwd
cwd = os.path.dirname(cwd)
return None
def load(self, path=None) -> None:
if path:
...
else:
if config := self.find_config(): # user/global config
self.read(config)
if config := self.find_project_config(): # project config OVERRIDES url
self.read(config)
...# wlc/config.py:101-113 @ 7c01e2f
def get_url_key(self) -> tuple[str, str]:
url = (
self.cli_url
or os.environ.get("WLC_URL", "")
or cast("str", self.get(self.section, "url")) # <- from .weblate
)
key = (
self.cli_key
or os.environ.get("WLC_KEY", "") # <- the secret, origin-unbound
or cast("str", self.get("keys", url, fallback=""))
)
return url, keyThe [keys] lookup is origin-scoped (self.get("keys", url)), reflecting the
maintainers' intent that a key belongs to a specific URL (CVE-2026-22251 hardening).
But $WLC_KEY short-circuits that scoping: it is returned for any url,
including a URL that came from an untrusted .weblate. The token is then attached
in invoke_request (Authorization: Token {self.key}, client.py:240-241) and sent
to that URL. normalize_request_url does not help: it only constrains URLs the
server returns relative to the configured base; the configured base itself is the
attacker's value.