Fix JMAP: */changes methods leak ids of non-shared objects

This commit is contained in:
Maurus Decimus
2026-06-10 13:51:36 +02:00
parent 26f41f8aa7
commit 33f288d0ea
36 changed files with 1475 additions and 190 deletions

View File

@@ -1,57 +1,244 @@
import requests
import argparse
import base64
import json
import random
import ssl
import string
import urllib3
import urllib.error
import urllib.request
# Configuration Variables
HOSTNAME = '127.0.0.1' # Replace with the actual hostname
DOMAIN = 'test.org' # Replace with your domain name
USERNAME = 'admin' # Basic auth username
PASSWORD = 'secret' # Basic auth password
NUM_USERS = 1000 # Number of test user accounts to create
CORE = "urn:ietf:params:jmap:core"
STALWART = "urn:stalwart:jmap"
USING = [CORE, STALWART]
# Suppress InsecureRequestWarning
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
DEFAULT_BASE_URL = "https://127.0.0.1"
DEFAULT_NUM_USERS = 1000
DEFAULT_OUTPUT = "users.txt"
DEFAULT_PASSWORD_LENGTH = 16
DEFAULT_PREFIX = "test"
# Generate SHA512 password hash
def generate_password():
return ''.join(random.choices(string.ascii_letters + string.digits, k=10))
CREATE_RETRIES = 5
# Create Domain
def create_domain():
url = f"https://{HOSTNAME}/api/domain/{DOMAIN}"
response = requests.post(url, auth=(USERNAME, PASSWORD), verify=False)
if response.status_code == 200:
print(f"Domain '{DOMAIN}' created successfully.")
else:
print(f"Failed to create domain '{DOMAIN}'. Status Code: {response.status_code}")
print(response.text)
# Create User Accounts
def create_user_accounts():
with open('users.txt', 'w') as file:
for i in range(1, NUM_USERS + 1):
username = f"test{i}@{DOMAIN}"
password = generate_password()
data = {
"type": "individual",
"name": username,
"secrets": [password],
"emails": [username],
"description": f"Tester {i}"
class AccountError(Exception):
pass
def generate_password(length):
return "".join(random.choices(string.ascii_letters + string.digits, k=length))
def primary_account(session):
accounts = session.get("primaryAccounts") or {}
if "urn:ietf:params:jmap:mail" in accounts:
return accounts["urn:ietf:params:jmap:mail"]
all_accounts = session.get("accounts") or {}
return next(iter(all_accounts), None)
def build_opener(verify):
context = ssl.create_default_context()
if not verify:
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
handler = urllib.request.HTTPSHandler(context=context)
return urllib.request.build_opener(handler)
class JmapClient:
def __init__(self, base_url, auth_header, verify):
self.base_url = base_url.rstrip("/")
self.auth_header = auth_header
self.opener = build_opener(verify)
self.api_url = None
self.account_id = None
self._discover()
def _http(self, url, method, body=None):
headers = {"Authorization": self.auth_header}
data = None
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with self.opener.open(request, timeout=60) as response:
return response.status, response.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
except urllib.error.URLError as e:
raise SystemExit(f"Request to {url} failed: {e.reason}")
def _discover(self):
if "/.well-known/jmap" in self.base_url:
candidates = [self.base_url]
else:
candidates = [self.base_url, f"{self.base_url}/.well-known/jmap"]
last = ""
for url in candidates:
status, text = self._http(url, "GET")
if status == 200:
try:
data = json.loads(text)
except ValueError:
last = f"{url}: invalid JSON"
continue
if data.get("apiUrl") and data.get("accounts"):
self.api_url = data["apiUrl"]
self.account_id = primary_account(data)
if not self.account_id:
raise SystemExit(f"JMAP session at {url} has no accounts.")
return
last = f"{url}: status {status}"
raise SystemExit(f"Could not discover JMAP session ({last}).")
def request(self, method_calls):
body = {"using": USING, "methodCalls": method_calls}
status, text = self._http(self.api_url, "POST", body)
if status != 200:
raise SystemExit(f"JMAP request failed: status {status}: {text}")
return json.loads(text)
def call(self, method, args):
args = dict(args)
args["accountId"] = self.account_id
parsed = self.request([[method, args, "c0"]])
responses = parsed.get("methodResponses") or []
if not responses:
raise SystemExit(f"{method}: empty methodResponses")
name, payload = responses[0][0], responses[0][1]
if name == "error":
raise SystemExit(f"{method} error: {payload}")
return payload
def domain_id(self, name):
calls = [
[
"x:Domain/query",
{"accountId": self.account_id, "filter": {"name": name}},
"q",
],
[
"x:Domain/get",
{
"accountId": self.account_id,
"#ids": {
"resultOf": "q",
"name": "x:Domain/query",
"path": "/ids",
},
"properties": ["id", "name"],
},
"g",
],
]
parsed = self.request(calls)
responses = parsed.get("methodResponses") or []
if len(responses) < 2:
raise SystemExit(f"Domain lookup failed: {parsed}")
get = responses[1][1]
for entry in get.get("list") or []:
if entry.get("name") == name:
return entry.get("id")
return None
def create_account(self, localpart, domain_id, password):
create = {
"a": {
"@type": "User",
"name": localpart,
"domainId": domain_id,
"credentials": {"0": {"@type": "Password", "secret": password}},
"encryptionAtRest": {"@type": "Disabled"},
"permissions": {"@type": "Inherit"},
"roles": {"@type": "User"},
"locale": "en_US",
}
url = f"https://{HOSTNAME}/api/principal"
response = requests.post(url, json=data, auth=(USERNAME, PASSWORD), verify=False)
if response.status_code == 200:
file.write(f"{username}:{password}\n")
print(f"User account '{username}' created successfully.")
else:
print(f"Failed to create user account '{username}'. Status Code: {response.status_code}")
print(response.text)
}
response = self.call("x:Account/set", {"create": create})
not_created = response.get("notCreated") or {}
if not_created:
raise AccountError(f"{not_created.get('a', not_created)}")
created = (response.get("created") or {}).get("a") or {}
return created.get("id")
def invalidate_caches(self):
self.call(
"x:Action/set",
{"create": {"c": {"@type": "InvalidateCaches"}}},
)
def build_auth_header(args):
if args.token:
return f"Bearer {args.token}"
raw = f"{args.user}:{args.password}".encode("utf-8")
return "Basic " + base64.b64encode(raw).decode("ascii")
def parse_args():
parser = argparse.ArgumentParser(
description="Provision test accounts for the stress test using the JMAP API."
)
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
parser.add_argument(
"--domain",
required=True,
help="Existing domain name; resolved to a domain id via JMAP.",
)
parser.add_argument("--token", help="OAuth bearer token for the JMAP API.")
parser.add_argument("--user", help="Basic auth username for the JMAP API.")
parser.add_argument("--password", help="Basic auth password for the JMAP API.")
parser.add_argument("--num-users", type=int, default=DEFAULT_NUM_USERS)
parser.add_argument("--prefix", default=DEFAULT_PREFIX)
parser.add_argument("--password-length", type=int, default=DEFAULT_PASSWORD_LENGTH)
parser.add_argument("--output", default=DEFAULT_OUTPUT)
parser.add_argument(
"--verify",
action="store_true",
help="Verify TLS certificates (disabled by default for self-signed servers).",
)
args = parser.parse_args()
if not args.token and not (args.user and args.password):
parser.error("provide either --token or both --user and --password")
return args
def main():
create_domain()
create_user_accounts()
args = parse_args()
client = JmapClient(args.base_url, build_auth_header(args), args.verify)
domain_id = client.domain_id(args.domain)
if not domain_id:
raise SystemExit(f"Domain '{args.domain}' not found via JMAP.")
created = 0
failed = 0
with open(args.output, "w") as file:
for i in range(1, args.num_users + 1):
localpart = f"{args.prefix}{i}"
email = f"{localpart}@{args.domain}"
password = None
last_error = None
for _ in range(CREATE_RETRIES):
password = generate_password(args.password_length)
try:
client.create_account(localpart, domain_id, password)
last_error = None
break
except AccountError as e:
last_error = e
if last_error is not None:
failed += 1
print(f"FAIL {email}: {last_error}")
continue
file.write(f"{email}:{password}\n")
file.flush()
created += 1
print(f"OK {email}")
client.invalidate_caches()
print(f"\nCreated {created} accounts ({failed} failed). Written to {args.output}.")
if __name__ == "__main__":
main()