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.
89 lines
2.6 KiB
Rust
89 lines
2.6 KiB
Rust
/*
|
|
* 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");
|
|
}
|
|
}
|
|
}
|