HexBrain

Weblate · Report #3825141

API token sent to URL dictated by an untrusted project .weblate file

Validstatus
mediumseverity
27upvotes
4 Sept 2026disclosed

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/reset on 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.
  • Realistic exploitation: a public project runs wlc push/wlc pull in CI with WLC_KEY as a secret and the URL taken from the checked-out repo (the documented pattern). An attacker opens a pull request that adds/edits .weblate to point url at their server (or, for a self-hosted runner, any reachable internal host). When CI runs wlc, the secret token is delivered to the attacker.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original