Compare commits

..
Author SHA1 Message Date
jcoffey-dev 29d3a5f779 Merge pull request 'Release 2026.9.29.2' (#125) from release/2026.9.29.2-pr into main
ci / fork-checks (push) Successful in 48s
publish / version (push) Successful in 58s
ci / build (push) Successful in 29m10s
publish / publish-amd64 (push) Successful in 32m14s
publish / release (push) Successful in 9s
publish / publish-arm64 (push) Successful in 40m42s
publish / binaries (push) Successful in 51s
publish / announce (push) Successful in 22s
2026-09-29 17:27:07 +00:00
jcoffey-dev f1f112fc38 Release 2026.9.29.2
ci / fork-checks (pull_request) Successful in 52s
ci / build (pull_request) Successful in 16m40s
2026-09-29 10:10:08 -07:00
jcoffey-dev 96be849976 Merge pull request 'Document why the client registration override is setup-only' (#124) from fix/client-override-recovery-only into main
ci / fork-checks (push) Successful in 34s
ci / build (push) Canceled after 22m54s
2026-09-29 17:04:07 +00:00
jcoffey-dev a5c8927dbc Merge pull request 'Take a token, never a password, outside DAV' (#122) from feature/http-basic-dav-only into main
ci / fork-checks (push) Canceled after 7s
ci / build (push) Canceled after 7s
2026-09-29 17:04:01 +00:00
jcoffey-dev ad09eeeefb Contract and end-to-end check for the client registration override
ci / fork-checks (pull_request) Successful in 47s
ci / build (pull_request) Successful in 4m38s
Documents under C-5 why oAuthClientOverride counts only in bootstrap
and recovery mode, and adds tests/e2e/client_override.py: the
recovery administrator keeps the override in both modes; after setup,
an administrator gets no code for an unregistered client or a
redirect URI its client didn't register, and a device code approved
for an unregistered client can't be exchanged. The script fails
against a build without the change (3 of 8) and passes with it.
2026-09-29 09:46:31 -07:00
jcoffey-dev 7e06a3b1f6 Merge pull request 'Release 2026.9.29.1' (#123) from release/2026.9.29.1-pr into main
ci / fork-checks (push) Successful in 48s
publish / version (push) Successful in 11s
ci / build (push) Successful in 30m5s
publish / publish-amd64 (push) Successful in 32m52s
publish / release (push) Successful in 10s
publish / publish-arm64 (push) Successful in 43m6s
publish / binaries (push) Successful in 36s
publish / announce (push) Successful in 10s
2026-09-29 15:40:42 +00:00
jcoffey-dev e147206e82 Release 2026.9.29.1
ci / fork-checks (pull_request) Successful in 50s
ci / build (pull_request) Successful in 13m48s
2026-09-29 08:25:13 -07:00
jcoffey-dev ca6484c356 Honor the client registration override only in setup and recovery
The recovery administrator signs in before any OAuth client is
registered, so it needs to skip the registration check. Outside
bootstrap and recovery mode, every account now signs in through a
registered client and one of its redirect URIs.
2026-09-29 08:25:13 -07:00
jcoffey-dev faf3d1e056 Take a token, never a password, outside DAV
ci / fork-checks (pull_request) Successful in 17s
ci / build (pull_request) Successful in 7m41s
Anyone could host a copy of a front end on a server of their own,
collect a person's password there, and replay it as HTTP Basic against
JMAP or the API. Cross-origin rules don't stop that, since a server
isn't a browser, and neither does client registration, since Basic
never goes through OAuth (contract C-23).

JMAP (session, API, upload, download, event source, WebSocket), /api,
/auth/introspect, /auth/userinfo and authenticated /auth/register now
refuse an Authorization: Basic header before looking at the password,
with a 401 whose only challenge is Bearer. A wrong password gets the
same answer as the right one. CalDAV and CardDAV keep Basic, and their
401s still offer it. The sign-in page's /api/auth takes the password in
its body and is unaffected, as is the token endpoint's client
authentication.

Bootstrap and recovery mode accept Basic everywhere, as they keep
permissive CORS. INBUXA_HTTP_BASIC_AUTH=all puts it back everywhere;
dav is the default, and any other value logs a warning and keeps it.
Test builds accept Basic everywhere, since the integration suites sign
in with passwords, and legacy_protocols.py sets the variable.

Tested: unit tests for the paths, and tests/e2e/http_basic_auth.py
against the debug build, 26 checks, including both front ends' sign-in
path and a refused unregistered redirect.
2026-09-29 07:02:05 -07:00
5 changed files with 259 additions and 14 deletions
+11 -7
View File
@@ -270,13 +270,17 @@ impl ClientRegistrationHandler for Server {
false
};
// Check if the account is allowed to override client registration
if self
.access_token(account_id)
.await
.caused_by(trc::location!())?
.build()
.has_permission(Permission::OAuthClientOverride)
// Check if the account is allowed to override client registration.
// inbuxa: only while setting up or recovering, when the recovery
// administrator signs in before any client is registered (contract C-5)
let registry = self.registry();
if (registry.is_bootstrap_mode() || registry.is_recovery_mode())
&& self
.access_token(account_id)
.await
.caused_by(trc::location!())?
.build()
.has_permission(Permission::OAuthClientOverride)
{
return Ok(None);
}
+1 -1
View File
@@ -81,7 +81,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
#[macro_export]
macro_rules! brand_version {
() => {
"2026.9.29"
"2026.9.29.2"
};
}
+15 -3
View File
@@ -98,6 +98,20 @@ Each has an ID, and tests name the IDs they check.
deliberate differences from upstream (see "Security note"). An operator who
wants open dynamic registration for third-party apps can turn it back on;
C-9's consent page still names every non-first-party client.
**`oAuthClientOverride` only in bootstrap and recovery mode** (2026-09-29).
Upstream lets an account holding it skip the client and redirect URI checks
on the sign-in page, at the code exchange and in the device flow.
Administrators hold it, so a link naming a made-up client and an attacker's
redirect URI handed an administrator's code, and then a token, to the
attacker: registration protected everyone except the accounts most worth
phishing. Now the permission counts only in bootstrap and recovery mode,
where the recovery administrator signs in before any client is registered.
An administrator otherwise signs in like anyone else, through a registered
client and one of its redirect URIs. INBUXA's production server was checked
first: its front ends' clients are registered with the redirect URIs they
use (C-6). Released in 2026.9.29.1. Checked by `tests/e2e/client_override.py` against the debug
build, with the same script failing against the build before the change.
- **C-6.** Two first-party clients are registered as `x:OAuthClient` whenever
`x:FrontEnds` is set or changed:
- **`inbuxa-admin`**: a public client (no secret), authorization code with
@@ -277,9 +291,7 @@ Each has an ID, and tests name the IDs they check.
with passwords. Checked by `tests/e2e/http_basic_auth.py` against the debug
build, 26 checks: everything above, both front ends' sign-in path, a wrong
password answered exactly as the right one, and a redirect URI the webmail
didn't register refused for an ordinary account. Accounts holding
`OAuthClientOverride` (administrators) skip client and redirect checks on
the sign-in endpoint, as upstream does; that's C-9's gap, not this one's.
didn't register refused.
Observed before the change, in INBUXA's production logs from 2026-09-20 to 2026-09-29:
every HTTPS password sign-in was the operator's own, apart from
ihasmail-inbuxa's password sign-in on 2026-09-22, before it moved to OAuth.
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""Local end-to-end check that an administrator gets no OAuth client bypass
outside bootstrap and recovery mode (contract C-5).
Run it with `python3 tests/e2e/client_override.py` after
`cargo build -p inbuxa`. Needs Docker. Working state goes under target/e2e.
Administrators hold OAuthClientOverride. Upstream lets it skip the client and
redirect URI checks everywhere, so a link naming a made-up client and an
attacker's redirect URI would hand an administrator's code to the attacker.
This boots the debug binary and checks that:
- in bootstrap mode, the recovery administrator still signs in through an
unregistered client, as the setup wizard needs;
- after setup, an administrator gets no code for an unregistered client, nor
for a registered one with a redirect URI it didn't register, while the
registered client and URI still work end to end;
- a device code an administrator approves for an unregistered client can't be
exchanged for a token;
- in recovery mode, the bypass is back for the recovery administrator.
Passwords are generated into files under target/e2e and never printed.
Everything is removed afterwards unless KEEP=1.
"""
import base64, hashlib, json, os, secrets, shutil, subprocess, sys, time, urllib.error, urllib.parse, urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DIR = f"{ROOT}/target/e2e"
NAME = "inbuxa-client-override"
PORT = 18195
HTTP = f"http://127.0.0.1:{PORT}"
ADMIN_URL = "http://admin.override.test"
REDIRECT = f"{ADMIN_URL}/oauth/callback"
EVIL = "https://evil.example/cb"
# Another build to check, such as one from before the change.
BINARY = os.environ.get("INBUXA_BINARY", f"{ROOT}/target/debug/inbuxa")
failures = []
def check(cond, what):
print(("ok " if cond else "FAIL ") + what)
if not cond:
failures.append(what)
def secret_file(name, value=None):
path = f"{DIR}/secrets/{name}"
if value is None:
value = secrets.token_urlsafe(24)
with open(path, "w") as f:
f.write(value)
os.chmod(path, 0o600)
return value
def docker(*args, check_rc=True):
return subprocess.run(["docker", *args], capture_output=True, text=True, check=check_rc)
def start(env=None):
env_file = f"{DIR}/secrets/override-env"
with open(env_file, "w") as f:
for key, value in (env or {}).items():
f.write(f"{key}={value}\n")
os.chmod(env_file, 0o600)
docker("run", "-d", "--name", NAME, "--user", f"{os.getuid()}:{os.getgid()}",
"--entrypoint", "/usr/local/bin/inbuxa",
"-v", f"{BINARY}:/usr/local/bin/inbuxa:ro",
"-v", f"{DIR}/etc-override:/etc/inbuxa", "-v", f"{DIR}/data-override:/var/lib/inbuxa",
"-p", f"127.0.0.1:{PORT}:8080",
# A debug build's workers need more than the default stack.
"-e", "RUST_MIN_STACK=16777216",
# Registers inbuxa-admin, the one client this server knows (C-6).
"-e", f"INBUXA_ADMIN_URL={ADMIN_URL}",
"--env-file", env_file,
"stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json")
for _ in range(120):
try:
urllib.request.urlopen(f"{HTTP}/.well-known/jmap", timeout=2)
except urllib.error.HTTPError:
return
except Exception:
time.sleep(1)
continue
return
sys.exit("server didn't come up: " + docker("logs", "--tail", "40", NAME, check_rc=False).stderr)
def stop():
docker("rm", "-f", NAME, check_rc=False)
def restart(env=None):
stop()
start(env)
def request(path, method="GET", body=None, content_type=None, authorization=None):
req = urllib.request.Request(f"{HTTP}{path}", data=body, method=method)
if content_type:
req.add_header("Content-Type", content_type)
if authorization:
req.add_header("Authorization", authorization)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as err:
return err.code, err.read()
def jmap(user, password, calls):
body = json.dumps({"using": ["urn:ietf:params:jmap:core", "urn:inbuxa:jmap:registry"],
"methodCalls": calls}).encode()
auth = "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()
status, raw = request("/jmap/", "POST", body, "application/json", auth)
if status != 200:
sys.exit(f"JMAP call failed: {status}")
return json.loads(raw)["methodResponses"]
def pkce():
verifier = secrets.token_urlsafe(48)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
return verifier, challenge
def sign_in(user, password, client_id, redirect_uri, challenge):
"""The sign-in page's request: what an authorization link leads to."""
status, raw = request("/api/auth", "POST", json.dumps({
"type": "authCode", "accountName": user, "accountSecret": password,
"clientId": client_id, "redirectUri": redirect_uri,
"codeChallenge": challenge, "codeChallengeMethod": "S256"}).encode(), "application/json")
return json.loads(raw) if status == 200 else {"type": status}
def exchange(client_id, code, redirect_uri, verifier):
status, raw = request("/auth/token", "POST", urllib.parse.urlencode({
"grant_type": "authorization_code", "client_id": client_id, "code": code,
"redirect_uri": redirect_uri, "code_verifier": verifier}).encode(),
"application/x-www-form-urlencoded")
return status, json.loads(raw or b"{}")
def phished(user, password, client_id, redirect_uri):
"""Whether a link naming this client and redirect URI ends in a token."""
verifier, challenge = pkce()
answer = sign_in(user, password, client_id, redirect_uri, challenge)
if answer.get("type") != "authenticated":
return False, answer.get("type")
status, body = exchange(client_id, answer["client_code"], redirect_uri, verifier)
return status == 200 and "access_token" in body, f"code issued, exchange {status}"
def main():
stop()
for sub in ("etc-override", "data-override"):
shutil.rmtree(f"{DIR}/{sub}", ignore_errors=True)
for sub in ("etc-override", "data-override", "secrets"):
os.makedirs(f"{DIR}/{sub}", exist_ok=True)
os.chmod(f"{DIR}/secrets", 0o700)
# Bootstrap mode: the recovery administrator keeps the bypass.
recovery = secret_file("override-recovery")
start({"INBUXA_RECOVERY_ADMIN": f"admin:{recovery}"})
got, how = phished("admin", recovery, "setup-wizard", EVIL)
check(got, f"bootstrap mode: the recovery administrator signs in through an unregistered client ({how})")
got = jmap("admin", recovery, [["x:Bootstrap/get", {"ids": None}, "0"]])
singleton = got[0][1]["list"][0]["id"]
res = jmap("admin", recovery, [["x:Bootstrap/set", {"update": {singleton: {
"serverHostname": "mail.override.test", "defaultDomain": "override.test",
"requestTlsCertificate": False}}}, "0"]])
updated = res[0][1].get("updated", {}).get(singleton)
check(bool(updated), "bootstrap completed")
if not updated:
sys.exit(json.dumps(res))
admin, admin_pw = updated["username"], secret_file("override-admin", updated["secret"])
# After setup: no bypass for an administrator.
restart()
got, how = phished(admin, admin_pw, "inbuxa-admin", REDIRECT)
check(got, f"the registered client and redirect URI still sign an administrator in ({how})")
got, how = phished(admin, admin_pw, "evil-client", EVIL)
check(not got, f"an unregistered client gets nothing for an administrator ({how})")
got, how = phished(admin, admin_pw, "inbuxa-admin", EVIL)
check(not got, f"a registered client with a foreign redirect URI gets nothing ({how})")
# Device flow: the administrator approves a code a made-up client asked for.
status, raw = request("/auth/device", "POST", b"client_id=evil-device", "application/x-www-form-urlencoded")
device = json.loads(raw) if status == 200 else {}
check("device_code" in device, f"a device code is issued to anyone ({status})")
if "device_code" in device:
status, raw = request("/api/auth", "POST", json.dumps({
"type": "authDevice", "accountName": admin, "accountSecret": admin_pw,
"code": device["user_code"]}).encode(), "application/json")
print(" approval:", json.loads(raw).get("type") if status == 200 else status)
status, raw = request("/auth/token", "POST", urllib.parse.urlencode({
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"client_id": "evil-device", "device_code": device["device_code"]}).encode(),
"application/x-www-form-urlencoded")
body = json.loads(raw or b"{}")
check("access_token" not in body,
f"but an administrator's approval can't be exchanged for a token ({status}, {body.get('error')})")
# Recovery mode: the bypass is back, for the recovery administrator.
restart({"INBUXA_RECOVERY_MODE": "1", "INBUXA_RECOVERY_ADMIN": f"admin:{recovery}"})
got, how = phished("admin", recovery, "recovery-tool", EVIL)
check(got, f"recovery mode: the recovery administrator signs in through an unregistered client ({how})")
if os.environ.get("KEEP") != "1":
stop()
for sub in ("etc-override", "data-override"):
shutil.rmtree(f"{DIR}/{sub}", ignore_errors=True)
for name in ("override-recovery", "override-admin", "override-env"):
try:
os.remove(f"{DIR}/secrets/{name}")
except FileNotFoundError:
pass
print()
if failures:
print(f"{len(failures)} failed")
sys.exit(1)
print("all passed")
if __name__ == "__main__":
main()
+3 -3
View File
@@ -185,9 +185,9 @@ def main():
admin, admin_pw = updated["username"], secret_file("basic-admin", updated["secret"])
# After setup, the default: Basic on DAV only. What follows needs a
# tracer to stdout, to read warnings back, and an account without
# administrator rights, which skip client checks (OAuthClientOverride).
# Both are made with a token, since Basic no longer reaches JMAP.
# tracer to stdout, to read warnings back, and a user account for the
# webmail's password check. Both are made with a token, since Basic no
# longer reaches JMAP.
restart()
admin_token, how = token(admin, admin_pw)
if not admin_token: