security(external_user): protect unnecessary route access to external users

This commit is contained in:
Edouard Vanbelle
2026-06-02 14:46:27 +02:00
parent 21c06da700
commit 6d70a7000e
8 changed files with 236 additions and 19 deletions
@@ -24,12 +24,42 @@ pub fn app_password_routes() -> Router<Arc<AppState>> {
/// POST /api/auth/app-passwords — Create a new app password.
///
/// Returns the plain-text password ONCE. The user must copy it immediately.
///
/// External users are rejected with 403: app passwords are persistent
/// credentials, and the magic-link-eligibility rule (`has_login_credential`)
/// is built on the assumption that externals have NO other credential
/// configured. Letting an external mint an app password would break that
/// invariant — and the Basic-Auth surface (`/remote.php/*`, `/ocs/*`)
/// has no semantic meaning for them anyway. See the
/// [magic-link auth architecture page] for the full visibility model.
///
/// [magic-link auth architecture page]: ../../../../docs/architecture/magic-link-auth.md
async fn create_app_password(
State(state): State<Arc<AppState>>,
user: AuthUser,
Json(request): Json<CreateAppPasswordRequestDto>,
) -> Result<Json<crate::application::dtos::app_password_dto::AppPasswordCreatedResponseDto>, AppError>
{
// Gate externals BEFORE we touch the app_password_service. The
// service treats every authenticated caller equally; the policy
// that externals can't hold persistent credentials lives here.
if let Some(auth_svc) = state.auth_service.as_ref()
&& let Err(err) = crate::interfaces::middleware::user::require_internal_user(
&auth_svc.auth_application_service,
user.id,
)
.await
{
tracing::info!(
target: "audit",
event = "auth.app_password_create_rejected",
reason = "external_user",
caller_id = %user.id,
"👮🏻‍♂️ External user blocked from creating an app password"
);
return Err(err);
}
let service = state
.app_password_service
.as_ref()
@@ -258,9 +258,28 @@ pub async fn search_groups(
headers: HeaderMap,
Query(q): Query<SearchGroupsQuery>,
) -> Result<impl IntoResponse, AppError> {
// Any authenticated user can discover groups for the share dialog —
// membership lists remain admin-only via list_members.
let (_caller_id, role) = require_authenticated(&state, &headers).await?;
// Any authenticated INTERNAL user can discover groups for the
// share dialog — membership lists remain admin-only via
// list_members. External users have no business enumerating
// groups; defence-in-depth on top of the ReBAC layer (which
// already prevents them from being added to any group anyway).
let (caller_id, role) = require_authenticated(&state, &headers).await?;
if let Some(auth_svc) = state.auth_service.as_ref()
&& let Err(err) = crate::interfaces::middleware::user::require_internal_user(
&auth_svc.auth_application_service,
caller_id,
)
.await
{
tracing::info!(
target: "audit",
event = "groups.search_rejected",
reason = "external_user",
caller_id = %caller_id,
"👮🏻‍♂️ External user blocked from /api/groups/search"
);
return Err(err);
}
let can_manage = role == "admin";
let svc = service(&state)?;
// The share-dialog autocomplete doesn't render a member-count chip, so
+58
View File
@@ -21,11 +21,17 @@
//! [`super::admin`] — that variant exists because some handlers take
//! `headers: HeaderMap` directly instead of `AuthUser`.
use axum::extract::{Request, State};
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use std::sync::Arc;
use uuid::Uuid;
use crate::application::services::auth_application_service::AuthApplicationService;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser;
/// Require the caller to be an internal user. Returns `Ok(())` for
/// internal callers, `Err(403)` for externals.
@@ -90,3 +96,55 @@ pub async fn require_admin_user(
}
Ok(())
}
/// Axum middleware layer that blocks external users from a whole route
/// subtree. Apply via `.layer(from_fn_with_state(state, require_internal_user_layer))`
/// on the protocol nests (CalDAV / CardDAV / WebDAV) that have no
/// semantic meaning for externals — they own no calendars, no address
/// books, no home folder.
///
/// Must run AFTER the auth middleware so `CurrentUser` is in the
/// request extensions; in tower order that means the auth layer is
/// added LAST (outermost). If the layer fires on an unauthenticated
/// path (no `CurrentUser` populated), it simply passes through — the
/// inner handler is then responsible for the 401, and we don't blanket-
/// 403 traffic the auth layer would have rejected anyway.
///
/// Emits an `authz.external_user_blocked` audit event on rejection so
/// operators can spot which surfaces externals are probing.
pub async fn require_internal_user_layer(
State(state): State<Arc<AppState>>,
request: Request,
next: Next,
) -> Response {
let caller_id = request
.extensions()
.get::<Arc<CurrentUser>>()
.map(|cu| cu.id);
let (Some(caller_id), Some(svc)) = (
caller_id,
state
.auth_service
.as_ref()
.map(|s| &*s.auth_application_service),
) else {
// No auth populated, or auth disabled globally — pass through.
return next.run(request).await;
};
if let Err(err) = require_internal_user(svc, caller_id).await {
let path = request.uri().path().to_owned();
tracing::info!(
target: "audit",
event = "authz.external_user_blocked",
reason = "internal_only_surface",
caller_id = %caller_id,
path = %path,
"👮🏻‍♂️ External user blocked from internal-only route subtree"
);
return err.into_response();
}
next.run(request).await
}
@@ -89,6 +89,31 @@ pub async fn basic_auth_middleware(
if let Some(auth_svc) = state.auth_service.as_ref() {
auth_svc.login_lockout.record_success(&username);
}
// External users must never authenticate against the NC
// surface — that whole subtree (WebDAV files, uploads,
// trashbin, OCS user info, sharees autocomplete, etc.) has
// no semantic meaning for a magic-link-only principal, and
// an app password would be a persistent credential
// bypassing the magic-link-eligibility rule. POST
// /api/auth/app-passwords also gates externals upfront;
// this is the belt-and-braces check in case one slipped
// through (e.g. user later flipped to is_external).
if let Some(auth_svc) = state.auth_service.as_ref()
&& let Ok(user) = auth_svc
.auth_application_service
.get_user_by_id(user_id)
.await
&& user.is_external
{
tracing::info!(
target: "audit",
event = "auth.nc_basic_rejected",
reason = "external_user",
user_id = %user_id,
"👮🏻‍♂️ External user attempted NC Basic auth — rejected"
);
return Err(NextcloudAuthError::Unauthorized);
}
request.extensions_mut().insert(Arc::new(CurrentUser {
id: user_id,
username: uname,