Skip to content
NusaSec
Research / Disclosure

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, 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.

References

https://cwe.mitre.org/data/definitions/200.html