Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e06a3b1f6 | ||
|
|
e147206e82 | ||
|
|
ca6484c356 |
@@ -72,10 +72,6 @@ pub struct Http {
|
|||||||
pub cors_origins: Vec<hyper::header::HeaderValue>,
|
pub cors_origins: Vec<hyper::header::HeaderValue>,
|
||||||
pub use_forwarded: bool,
|
pub use_forwarded: bool,
|
||||||
pub redirect_root: Option<String>,
|
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)]
|
#[derive(Clone)]
|
||||||
@@ -457,35 +453,6 @@ impl Http {
|
|||||||
.collect()
|
.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 {
|
if use_permissive_cors {
|
||||||
http_headers.push((
|
http_headers.push((
|
||||||
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||||
@@ -545,7 +512,6 @@ impl Http {
|
|||||||
cors_origins,
|
cors_origins,
|
||||||
use_forwarded: http.use_x_forwarded,
|
use_forwarded: http.use_x_forwarded,
|
||||||
redirect_root: http.redirect_root,
|
redirect_root: http.redirect_root,
|
||||||
basic_auth_everywhere,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*
|
|
||||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
pub mod authenticate;
|
pub mod authenticate;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
pub mod permissions;
|
pub mod permissions;
|
||||||
pub mod token_only;
|
|
||||||
|
|||||||
@@ -270,13 +270,17 @@ 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.
|
||||||
if self
|
// inbuxa: only while setting up or recovering, when the recovery
|
||||||
.access_token(account_id)
|
// administrator signs in before any client is registered (contract C-5)
|
||||||
.await
|
let registry = self.registry();
|
||||||
.caused_by(trc::location!())?
|
if (registry.is_bootstrap_mode() || registry.is_recovery_mode())
|
||||||
.build()
|
&& self
|
||||||
.has_permission(Permission::OAuthClientOverride)
|
.access_token(account_id)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.build()
|
||||||
|
.has_permission(Permission::OAuthClientOverride)
|
||||||
{
|
{
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,14 +8,13 @@
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
HttpSessionManager,
|
HttpSessionManager,
|
||||||
api::{AuthChallenge, ManagementApi, ToManageHttpResponse, UnauthorizedResponse},
|
api::{AuthChallenge, ManagementApi, ToManageHttpResponse},
|
||||||
auth::{
|
auth::{
|
||||||
authenticate::{Authenticator, HttpHeaders},
|
authenticate::{Authenticator, HttpHeaders},
|
||||||
oauth::{
|
oauth::{
|
||||||
FormData, auth::OAuthApiHandler, openid::OpenIdHandler,
|
FormData, auth::OAuthApiHandler, openid::OpenIdHandler,
|
||||||
registration::ClientRegistrationHandler, token::TokenHandler,
|
registration::ClientRegistrationHandler, token::TokenHandler,
|
||||||
},
|
},
|
||||||
token_only::{is_refused_basic, is_token_only_path},
|
|
||||||
},
|
},
|
||||||
form::FormHandler,
|
form::FormHandler,
|
||||||
};
|
};
|
||||||
@@ -93,17 +92,6 @@ 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() {
|
match path.next().unwrap_or_default() {
|
||||||
"jmap" => {
|
"jmap" => {
|
||||||
match (path.next().unwrap_or_default(), req.method()) {
|
match (path.next().unwrap_or_default(), req.method()) {
|
||||||
@@ -794,15 +782,6 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
|
|||||||
// inbuxa: kept for the cross-origin allowlist (contract C-14)
|
// inbuxa: kept for the cross-origin allowlist (contract C-14)
|
||||||
let origin = req.headers().get(hyper::header::ORIGIN).cloned();
|
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
|
// Parse HTTP request
|
||||||
let response = match Box::pin(server.parse_http_request(
|
let response = match Box::pin(server.parse_http_request(
|
||||||
req,
|
req,
|
||||||
@@ -820,7 +799,7 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
|
|||||||
{
|
{
|
||||||
Ok(response) => response,
|
Ok(response) => response,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let response = err.into_http_response(challenge);
|
let response = err.into_http_response(AuthChallenge::BearerAndBasic);
|
||||||
trc::error!(err.span_id(session.session_id));
|
trc::error!(err.span_id(session.session_id));
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"2026.9.29.1"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -240,50 +240,6 @@ Each has an ID, and tests name the IDs they check.
|
|||||||
subscriptions to its own URL, with VAPID for browser notifications. The only
|
subscriptions to its own URL, with VAPID for browser notifications. The only
|
||||||
difference is that it authenticates with its token rather than the password.
|
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
|
## First boot
|
||||||
|
|
||||||
1. The installer, or INBUXA Admin's setup wizard, completes bootstrap
|
1. The installer, or INBUXA Admin's setup wizard, completes bootstrap
|
||||||
|
|||||||
@@ -1,294 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -90,9 +90,7 @@ def start(env_file=None):
|
|||||||
"-p", f"127.0.0.1:{PORTS['submissions']}:465",
|
"-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['imap']}:993",
|
||||||
"-p", f"127.0.0.1:{PORTS['pop3']}:995",
|
"-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:
|
if env_file:
|
||||||
args += ["--env-file", env_file]
|
args += ["--env-file", env_file]
|
||||||
args += ["stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"]
|
args += ["stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"]
|
||||||
|
|||||||
Reference in New Issue
Block a user