Merge pull request 'Take a token, never a password, outside DAV' (#122) from feature/http-basic-dav-only into main
This commit was merged in pull request #122.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user