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

This commit was merged in pull request #122.
This commit is contained in:
2026-09-29 17:04:01 +00:00
7 changed files with 489 additions and 3 deletions
+34
View File
@@ -72,6 +72,10 @@ pub struct Http {
pub cors_origins: Vec<hyper::header::HeaderValue>,
pub use_forwarded: bool,
pub redirect_root: Option<String>,
/// inbuxa: HTTP Basic accepted on every endpoint, not only DAV (contract
/// C-23). True in bootstrap and recovery mode, or with
/// `INBUXA_HTTP_BASIC_AUTH=all`.
pub basic_auth_everywhere: bool,
}
#[derive(Clone)]
@@ -453,6 +457,35 @@ impl Http {
.collect()
};
// inbuxa: outside DAV, HTTP sign-in is a token unless the operator
// says otherwise (contract C-23). The integration suites sign in with
// passwords over JMAP and the API, so test builds accept Basic
// everywhere.
#[cfg(feature = "test_mode")]
let basic_auth_everywhere = true;
#[cfg(not(feature = "test_mode"))]
let basic_auth_everywhere = bp.registry.is_recovery_mode()
|| bp.registry.is_bootstrap_mode()
|| match types::branding::env_var("HTTP_BASIC_AUTH") {
Ok(value) if value.trim().eq_ignore_ascii_case("all") => true,
Ok(value)
if value.trim().is_empty() || value.trim().eq_ignore_ascii_case("dav") =>
{
false
}
Ok(value) => {
bp.build_warning(
ObjectType::Http.singleton(),
format!(
"INBUXA_HTTP_BASIC_AUTH is {value:?}; expected \"dav\" or \"all\". Basic authentication stays on DAV only."
),
);
false
}
Err(_) => false,
};
if use_permissive_cors {
http_headers.push((
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
@@ -512,6 +545,7 @@ impl Http {
cors_origins,
use_forwarded: http.use_x_forwarded,
redirect_root: http.redirect_root,
basic_auth_everywhere,
}
}
}
+3
View File
@@ -2,8 +2,11 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
pub mod authenticate;
pub mod oauth;
pub mod permissions;
pub mod token_only;
+88
View File
@@ -0,0 +1,88 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Where HTTP Basic authentication is refused (contract C-23).
//!
//! Outside DAV, the HTTP endpoints take a token, never a password: JMAP, the
//! management API, and the OAuth endpoints that authenticate a user
//! (introspection, userinfo, authenticated client registration). CalDAV and
//! CardDAV keep Basic, since that's how calendar and contacts apps sign in.
//! The token endpoint's own client authentication isn't user sign-in and
//! isn't affected.
//!
//! Bootstrap and recovery mode accept Basic everywhere, as they keep
//! permissive CORS (C-16), and `INBUXA_HTTP_BASIC_AUTH=all` puts it back
//! everywhere for an operator who needs it.
use crate::auth::authenticate::HttpHeaders;
use http_proto::HttpRequest;
/// Whether `path` takes a token only when Basic isn't allowed everywhere.
pub fn is_token_only_path(path: &str) -> bool {
let mut segments = path.trim_start_matches('/').split('/');
match segments.next() {
Some("jmap" | "api") => true,
Some("auth") => matches!(
segments.next(),
Some("introspect" | "userinfo" | "register")
),
_ => false,
}
}
/// Whether this request signs in with a password where only a token is
/// accepted.
pub fn is_refused_basic(req: &HttpRequest, basic_auth_everywhere: bool) -> bool {
!basic_auth_everywhere
&& req.authorization_basic().is_some()
&& is_token_only_path(req.uri().path())
}
#[cfg(test)]
mod tests {
use super::is_token_only_path;
#[test]
fn token_only_paths() {
for path in [
"/jmap",
"/jmap/",
"/jmap/session",
"/jmap/upload/a/",
"/jmap/download/a/b/c",
"/jmap/eventsource/",
"/jmap/ws",
"/api",
"/api/account",
"/api/schema",
"/auth/introspect",
"/auth/userinfo",
"/auth/register",
] {
assert!(is_token_only_path(path), "{path} should take a token only");
}
}
#[test]
fn basic_stays_where_apps_need_it() {
for path in [
"/dav/cal/user/",
"/dav/card/user/",
"/.well-known/caldav",
"/.well-known/carddav",
"/.well-known/jmap",
"/auth/token",
"/auth/device",
"/scim/v2/Users",
"/",
"/login",
"/jmapx",
"/apis",
] {
assert!(!is_token_only_path(path), "{path} should be left alone");
}
}
}
+23 -2
View File
@@ -8,13 +8,14 @@
use crate::{
HttpSessionManager,
api::{AuthChallenge, ManagementApi, ToManageHttpResponse},
api::{AuthChallenge, ManagementApi, ToManageHttpResponse, UnauthorizedResponse},
auth::{
authenticate::{Authenticator, HttpHeaders},
oauth::{
FormData, auth::OAuthApiHandler, openid::OpenIdHandler,
registration::ClientRegistrationHandler, token::TokenHandler,
},
token_only::{is_refused_basic, is_token_only_path},
},
form::FormHandler,
};
@@ -92,6 +93,17 @@ impl ParseHttp for Server {
}
}
// inbuxa: outside DAV, sign in with a token, never a password (contract C-23)
if is_refused_basic(&req, self.core.network.http.basic_auth_everywhere) {
trc::event!(
Auth(trc::AuthEvent::Failed),
SpanId = session.session_id,
RemoteIp = session.remote_ip,
Reason = "Basic authentication is accepted on DAV only; use a bearer token",
);
return Ok(HttpResponse::unauthorized(AuthChallenge::Bearer));
}
match path.next().unwrap_or_default() {
"jmap" => {
match (path.next().unwrap_or_default(), req.method()) {
@@ -782,6 +794,15 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
// inbuxa: kept for the cross-origin allowlist (contract C-14)
let origin = req.headers().get(hyper::header::ORIGIN).cloned();
// inbuxa: offer Basic only where it's accepted (contract C-23)
let challenge = if server.core.network.http.basic_auth_everywhere
|| !is_token_only_path(req.uri().path())
{
AuthChallenge::BearerAndBasic
} else {
AuthChallenge::Bearer
};
// Parse HTTP request
let response = match Box::pin(server.parse_http_request(
req,
@@ -799,7 +820,7 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
{
Ok(response) => response,
Err(err) => {
let response = err.into_http_response(AuthChallenge::BearerAndBasic);
let response = err.into_http_response(challenge);
trc::error!(err.span_id(session.session_id));
response
}
+44
View File
@@ -240,6 +240,50 @@ Each has an ID, and tests name the IDs they check.
subscriptions to its own URL, with VAPID for browser notifications. The only
difference is that it authenticates with its token rather than the password.
### Passwords over HTTP
- **C-23.** **Outside DAV, HTTP sign-in is a token, never a password.** JMAP
(`/jmap`, with session, upload, download, event source and WebSocket), the
management API (`/api`), and the OAuth endpoints that authenticate a user
(`/auth/introspect`, `/auth/userinfo`, authenticated `/auth/register`)
refuse an `Authorization: Basic` header with a 401 whose only challenge is
`Bearer`, and don't check the password. CalDAV and CardDAV (`/dav`) keep
Basic, since that's how calendar and contacts apps sign in, and their 401s
still offer it. The sign-in page's own endpoint (`/api/auth`) takes the
password in its body, not a header, and isn't affected. Neither is the token
endpoint's client authentication. SCIM already takes an API key only.
Bootstrap and recovery mode accept Basic everywhere, as they keep
permissive CORS (C-16).
**Decision**: without this, anyone can put up a copy of a front end on a
server of their own that collects a person's password and replays it as
Basic. Cross-origin rules (C-14) don't stop that, because a server isn't a
browser, and neither does client registration (C-5), because Basic never
goes through OAuth. With C-23, the password only goes to the server's own
sign-in page (C-8), or to a DAV client or mail app the person set up
themselves.
An operator who needs Basic on every endpoint sets
`INBUXA_HTTP_BASIC_AUTH=all`; `dav`, the default, is this rule. Any other
value logs a warning and keeps the default. The setting moves to the
registry with `x:FrontEnds` (C-4).
ihasmail-inbuxa confirms a typed password, which it does before creating
an app password, on `/api/auth` as its own client, to its registered
redirect URI, with a PKCE challenge whose verifier it discards. A
"two-factor code needed" answer counts as confirmed, since the server gives
it only after the password matched.
**Built, 2026-09-29.** `crates/http/src/auth/token_only.rs` names the
paths; `request.rs` refuses before routing and picks the 401's challenge by
path; `Http.basic_auth_everywhere` holds the setting. Test builds
(`test_mode`) accept Basic everywhere, since the integration suites sign in
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.
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.
The logs don't say whether a sign-in used a Basic header or the sign-in
page.
## First boot
1. The installer, or INBUXA Admin's setup wizard, completes bootstrap
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""Local end-to-end check of contract C-23: outside DAV, HTTP sign-in is a
token, never a password.
Run it with `python3 tests/e2e/http_basic_auth.py` after
`cargo build -p inbuxa`. Needs Docker. Working state goes under target/e2e.
Boots the debug binary and checks that:
- in bootstrap mode, Basic works on JMAP (as permissive CORS does, C-16);
- after setup, Basic is refused on JMAP, the API, userinfo and introspection,
with a 401 that offers only Bearer, and the password isn't checked;
- DAV still takes Basic, and its 401 still offers it;
- a token from the sign-in endpoint (`/api/auth`, the password in the body)
and the token endpoint works on JMAP: the path the front ends use, and the
one ihasmail-inbuxa's password check relies on;
- INBUXA_HTTP_BASIC_AUTH=all puts Basic back everywhere, an unknown value
keeps the default with a warning, and recovery mode accepts Basic.
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-basic-auth"
PORT = 18180
HTTP = f"http://127.0.0.1:{PORT}"
ADMIN_URL = "http://admin.basic.test"
REDIRECT = f"{ADMIN_URL}/oauth/callback"
WEBMAIL_URL = "http://webmail.basic.test"
WEBMAIL_REDIRECT = f"{WEBMAIL_URL}/api/auth/callback"
failures = []
WEBMAIL_SECRET = secrets.token_urlsafe(24)
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):
args = ["run", "-d", "--name", NAME, "--user", f"{os.getuid()}:{os.getgid()}",
"--entrypoint", "/usr/local/bin/inbuxa",
"-v", f"{ROOT}/target/debug/inbuxa:/usr/local/bin/inbuxa:ro",
"-v", f"{DIR}/etc-basic:/etc/inbuxa", "-v", f"{DIR}/data-basic:/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 and ihasmail-inbuxa (C-6).
"-e", f"INBUXA_ADMIN_URL={ADMIN_URL}", "-e", f"INBUXA_WEBMAIL_URL={WEBMAIL_URL}"]
env_file = f"{DIR}/secrets/basic-env"
with open(env_file, "w") as f:
f.write(f"INBUXA_WEBMAIL_CLIENT_SECRET={WEBMAIL_SECRET}\n")
for key, value in (env or {}).items():
f.write(f"{key}={value}\n")
os.chmod(env_file, 0o600)
args += ["--env-file", env_file, "stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"]
docker(*args)
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 basic(user, password):
return "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()
def request(path, authorization=None, method="GET", body=None, content_type=None, headers=None):
"""(status, headers, body) for a request, whatever the status."""
req = urllib.request.Request(f"{HTTP}{path}", data=body, method=method)
if authorization:
req.add_header("Authorization", authorization)
if content_type:
req.add_header("Content-Type", content_type)
for key, value in (headers or {}).items():
req.add_header(key, value)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, resp.headers, resp.read()
except urllib.error.HTTPError as err:
return err.code, err.headers, err.read()
def challenges(headers):
return sorted(value.split(" ", 1)[0] for value in headers.get_all("WWW-Authenticate") or [])
def jmap(authorization, calls):
body = json.dumps({"using": ["urn:ietf:params:jmap:core", "urn:inbuxa:jmap:registry"],
"methodCalls": calls}).encode()
status, _, raw = request("/jmap/", authorization, "POST", body, "application/json")
if status != 200:
sys.exit(f"JMAP call failed: {status}")
return json.loads(raw)["methodResponses"]
def sign_in(user, password, client_id, redirect_uri, verifier):
"""What the sign-in endpoint answers, the password in the request body."""
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
status, _, raw = request("/api/auth", method="POST", content_type="application/json", body=json.dumps({
"type": "authCode", "accountName": user, "accountSecret": password,
"clientId": client_id, "redirectUri": redirect_uri,
"codeChallenge": challenge, "codeChallengeMethod": "S256"}).encode())
return json.loads(raw) if status == 200 else {"type": status}
def token(user, password):
"""An access token the way a front end gets one: the sign-in endpoint, then
the token endpoint, with PKCE."""
verifier = secrets.token_urlsafe(48)
answer = sign_in(user, password, "inbuxa-admin", REDIRECT, verifier)
if answer.get("type") != "authenticated":
return None, answer.get("type") or status
status, _, raw = request("/auth/token", method="POST", content_type="application/x-www-form-urlencoded",
body=urllib.parse.urlencode({
"grant_type": "authorization_code", "client_id": "inbuxa-admin",
"code": answer["client_code"], "redirect_uri": REDIRECT,
"code_verifier": verifier}).encode())
if status != 200:
return None, status
return json.loads(raw)["access_token"], "authenticated"
def propfind(path, authorization):
return request(path, authorization, "PROPFIND", b'<?xml version="1.0"?><propfind xmlns="DAV:"><prop><resourcetype/></prop></propfind>',
"application/xml", {"Depth": "0"})
def main():
stop()
for sub in ("etc-basic", "data-basic"):
shutil.rmtree(f"{DIR}/{sub}", ignore_errors=True)
for sub in ("etc-basic", "data-basic", "secrets"):
os.makedirs(f"{DIR}/{sub}", exist_ok=True)
os.chmod(f"{DIR}/secrets", 0o700)
# Bootstrap mode: Basic works on JMAP, as it must for the setup wizard.
recovery = secret_file("basic-recovery")
start({"INBUXA_RECOVERY_ADMIN": f"admin:{recovery}"})
status, _, _ = request("/jmap/session", basic("admin", recovery))
check(status == 200, "bootstrap mode: Basic works on JMAP")
got = jmap(basic("admin", recovery), [["x:Bootstrap/get", {"ids": None}, "0"]])
singleton = got[0][1]["list"][0]["id"]
res = jmap(basic("admin", recovery), [["x:Bootstrap/set", {"update": {singleton: {
"serverHostname": "mail.basic.test", "defaultDomain": "basic.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("basic-admin", updated["secret"])
# 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
# 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:
sys.exit(f"no token for the administrator: {how}")
domain = jmap(f"Bearer {admin_token}", [["x:Domain/get", {"ids": None}, "0"]])[0][1]["list"][0]["id"]
user, user_pw = "[email protected]", secret_file("basic-user")
res = jmap(f"Bearer {admin_token}", [
["x:Tracer/set", {"create": {"t": {"@type": "Stdout", "level": "info", "buffered": False, "ansi": False}}}, "0"],
["x:Account/set", {"create": {"a": {"@type": "User", "name": "u", "domainId": domain,
"credentials": {"0": {"@type": "Password", "secret": user_pw}}}}}, "1"]])
if not (res[0][1].get("created") or {}).get("t") or not (res[1][1].get("created") or {}).get("a"):
sys.exit("setup failed: " + json.dumps(res))
restart()
right, wrong = basic(admin, admin_pw), basic(admin, "not-the-password")
status, headers, _ = request("/jmap/session", right)
check(status == 401, "Basic with the right password is refused on /jmap/session")
check(challenges(headers) == ["Bearer"], f"that 401 offers only Bearer ({challenges(headers)})")
status, _, _ = request("/jmap/", right, "POST", b'{"using":[],"methodCalls":[]}', "application/json")
check(status == 401, "Basic is refused on a JMAP API call")
status, headers, _ = request("/jmap/", None, "POST", b'{"using":[],"methodCalls":[]}', "application/json")
check(status == 401 and challenges(headers) == ["Bearer"],
f"an unauthenticated JMAP call's 401 offers only Bearer ({challenges(headers)})")
for path in ("/api/account", "/auth/userinfo"):
status, headers, _ = request(path, right)
check(status == 401 and challenges(headers) == ["Bearer"], f"Basic is refused on {path}")
status, _, _ = request("/auth/introspect", right, "POST", b"token=x", "application/x-www-form-urlencoded")
check(status == 401, "Basic is refused on /auth/introspect")
# Refused before the password is looked at, so the answer is the same
# either way and can't be used to guess one.
status_right, headers_right, body_right = request("/jmap/session", right)
status_wrong, headers_wrong, body_wrong = request("/jmap/session", wrong)
check((status_wrong, challenges(headers_wrong), body_wrong) == (status_right, challenges(headers_right), body_right),
"a wrong password over Basic gets exactly the same answer as the right one")
# DAV keeps Basic.
status, _, _ = propfind(f"/dav/card/{admin}/", right)
check(status == 207, f"Basic works on CardDAV ({status})")
status, _, _ = propfind(f"/dav/cal/{admin}/", right)
check(status == 207, f"Basic works on CalDAV ({status})")
status, headers, _ = propfind(f"/dav/card/{admin}/", None)
check(status == 401 and "Basic" in challenges(headers),
f"DAV's 401 still offers Basic ({challenges(headers)})")
# The front ends' path: the sign-in endpoint and a token.
access, how = token(admin, admin_pw)
check(access is not None, f"the sign-in endpoint takes the password in its body ({how})")
_, how_wrong = token(admin, "not-the-password")
check(how_wrong == "failure", f"and says failure for a wrong one ({how_wrong})")
if access:
status, _, _ = request("/jmap/session", f"Bearer {access}")
check(status == 200, "a token works on /jmap/session")
status, _, _ = request("/api/account", f"Bearer {access}")
check(status == 200, f"a token works on /api/account ({status})")
# ihasmail-inbuxa's password check before an app password: its own client,
# its registered redirect URI, a verifier it throws away.
for password, want in ((user_pw, "authenticated"), ("not-the-password", "failure")):
got = sign_in(user, password, "ihasmail-inbuxa", WEBMAIL_REDIRECT, secrets.token_urlsafe(48))
check(got.get("type") == want, f"the webmail's password check answers {want} ({got.get('type')})")
got = sign_in(user, user_pw, "ihasmail-inbuxa", "https://evil.example/cb", secrets.token_urlsafe(48))
check(got.get("type") != "authenticated", f"but not to a redirect URI it didn't register ({got.get('type')})")
# The operator's switch.
restart({"INBUXA_HTTP_BASIC_AUTH": "all"})
status, _, _ = request("/jmap/session", right)
check(status == 200, "INBUXA_HTTP_BASIC_AUTH=all: Basic works on JMAP again")
status, headers, _ = request("/jmap/", None, "POST", b'{"using":[],"methodCalls":[]}', "application/json")
check("Basic" in challenges(headers), f"and JMAP's 401 offers it again ({challenges(headers)})")
restart({"INBUXA_HTTP_BASIC_AUTH": "sometimes"})
status, _, _ = request("/jmap/session", right)
check(status == 401, "an unknown INBUXA_HTTP_BASIC_AUTH keeps Basic refused")
logs = docker("logs", NAME, check_rc=False)
check("INBUXA_HTTP_BASIC_AUTH" in logs.stdout + logs.stderr, "and says so in the log")
restart({"INBUXA_HTTP_BASIC_AUTH": "dav"})
status, _, _ = request("/jmap/session", right)
check(status == 401, "INBUXA_HTTP_BASIC_AUTH=dav is the default")
# Recovery mode accepts Basic, for the recovery administrator.
restart({"INBUXA_RECOVERY_MODE": "1", "INBUXA_RECOVERY_ADMIN": f"admin:{recovery}"})
status, _, _ = request("/jmap/session", basic("admin", recovery))
check(status == 200, "recovery mode: Basic works on JMAP")
if os.environ.get("KEEP") != "1":
stop()
for sub in ("etc-basic", "data-basic"):
shutil.rmtree(f"{DIR}/{sub}", ignore_errors=True)
for name in ("basic-recovery", "basic-admin", "basic-user", "basic-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 -1
View File
@@ -90,7 +90,9 @@ def start(env_file=None):
"-p", f"127.0.0.1:{PORTS['submissions']}:465",
"-p", f"127.0.0.1:{PORTS['imap']}:993",
"-p", f"127.0.0.1:{PORTS['pop3']}:995",
"-p", f"127.0.0.1:{PORTS['smtp']}:25"]
"-p", f"127.0.0.1:{PORTS['smtp']}:25",
# This script signs in with passwords over JMAP (contract C-23).
"-e", "INBUXA_HTTP_BASIC_AUTH=all"]
if env_file:
args += ["--env-file", env_file]
args += ["stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"]