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.
This commit is contained in:
2026-09-29 07:02:05 -07:00
parent ffcfde0b5a
commit faf3d1e056
7 changed files with 489 additions and 3 deletions
+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
}