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
+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
}