Compare commits

..
Author SHA1 Message Date
jcoffey-dev a742d0cd87 Take a token, never a password, outside DAV
ci / fork-checks (pull_request) Successful in 52s
ci / build (pull_request) Canceled after 11m17s
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 for an ordinary account.
2026-09-29 06:50:23 -07:00
5 changed files with 14 additions and 259 deletions
+2 -6
View File
@@ -270,12 +270,8 @@ impl ClientRegistrationHandler for Server {
false false
}; };
// Check if the account is allowed to override client registration. // Check if the account is allowed to override client registration
// inbuxa: only while setting up or recovering, when the recovery if self
// 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) .access_token(account_id)
.await .await
.caused_by(trc::location!())? .caused_by(trc::location!())?
+1 -1
View File
@@ -81,7 +81,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
#[macro_export] #[macro_export]
macro_rules! brand_version { macro_rules! brand_version {
() => { () => {
"2026.9.29.2" "2026.9.29"
}; };
} }
+3 -15
View File
@@ -98,20 +98,6 @@ Each has an ID, and tests name the IDs they check.
deliberate differences from upstream (see "Security note"). An operator who deliberate differences from upstream (see "Security note"). An operator who
wants open dynamic registration for third-party apps can turn it back on; 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. 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 - **C-6.** Two first-party clients are registered as `x:OAuthClient` whenever
`x:FrontEnds` is set or changed: `x:FrontEnds` is set or changed:
- **`inbuxa-admin`**: a public client (no secret), authorization code with - **`inbuxa-admin`**: a public client (no secret), authorization code with
@@ -291,7 +277,9 @@ Each has an ID, and tests name the IDs they check.
with passwords. Checked by `tests/e2e/http_basic_auth.py` against the debug 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 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 password answered exactly as the right one, and a redirect URI the webmail
didn't register refused. 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.
Observed before the change, in INBUXA's production logs from 2026-09-20 to 2026-09-29: 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 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. ihasmail-inbuxa's password sign-in on 2026-09-22, before it moved to OAuth.
-229
View File
@@ -1,229 +0,0 @@
#!/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"]) admin, admin_pw = updated["username"], secret_file("basic-admin", updated["secret"])
# After setup, the default: Basic on DAV only. What follows needs a # After setup, the default: Basic on DAV only. What follows needs a
# tracer to stdout, to read warnings back, and a user account for the # tracer to stdout, to read warnings back, and an account without
# webmail's password check. Both are made with a token, since Basic no # administrator rights, which skip client checks (OAuthClientOverride).
# longer reaches JMAP. # Both are made with a token, since Basic no longer reaches JMAP.
restart() restart()
admin_token, how = token(admin, admin_pw) admin_token, how = token(admin, admin_pw)
if not admin_token: if not admin_token: