diff --git a/docs/guide/search.md b/docs/guide/search.md index 1fbb307c..d86ad1d0 100644 --- a/docs/guide/search.md +++ b/docs/guide/search.md @@ -9,9 +9,10 @@ OxiCloud provides authenticated file and folder search with simple query paramet | `GET` | `/api/search/` | Simple search using query parameters | | `POST` | `/api/search/advanced` | Advanced search with a JSON body | | `GET` | `/api/search/suggest` | Lightweight autocomplete suggestions | -| `DELETE` | `/api/search/cache` | Clear the search results cache | +| `DELETE` | `/api/admin/search/cache` | Flush the shared search results cache (admin only) | -All search endpoints require authentication. +All search endpoints require authentication. The cache flush is +additionally restricted to administrators — see [Result Caching](#result-caching). ## Simple Search Parameters @@ -59,7 +60,10 @@ Search results are cached in memory using the search criteria and user ID as the - Cache TTL: 5 minutes - Max entries: 1000 -- Manual invalidation: `DELETE /api/search/cache` +- Manual invalidation: `DELETE /api/admin/search/cache` — admin-only. + The endpoint calls `invalidate_all()` on the shared moka cache, so + one call cold-starts every subsequent search for every tenant; it's + an operator debug lever, not a per-user affordance. ## Feature Flag diff --git a/frontend/src/lib/api/endpoints/search.ts b/frontend/src/lib/api/endpoints/search.ts index 0d246577..232976e0 100644 --- a/frontend/src/lib/api/endpoints/search.ts +++ b/frontend/src/lib/api/endpoints/search.ts @@ -69,9 +69,14 @@ export function searchSuggest( }); } -/** Clear the server-side search cache (`DELETE /api/search/cache`). */ +/** + * Clear the shared server-side search cache + * (`DELETE /api/admin/search/cache`). Admin-only — moved from + * `/api/search/cache` on 2026-07-17 because the underlying + * `invalidate_all()` touches every tenant (see AuthZ audit #14). + */ export async function clearSearchCache(): Promise { - const res = await apiFetch('/api/search/cache', { + const res = await apiFetch('/api/admin/search/cache', { method: 'DELETE', credentials: 'same-origin' }); diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index d285b5e2..0aa1c863 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -27,6 +27,7 @@ use crate::application::ports::storage_ports::StorageUsagePort; use crate::common::di::AppState; use crate::domain::repositories::drive_repository::DriveRepository; use crate::domain::services::authorization::{Resource, Subject}; +use crate::interfaces::api::handlers::search_handler::clear_search_cache; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::admin::require_admin; use std::sync::Arc; @@ -89,6 +90,12 @@ pub fn admin_routes() -> Router> { .route("/plugins/{id}/logs/stream", get(stream_plugin_logs)) .route("/plugins/{id}/retention", get(get_plugin_retention)) .route("/plugins/{id}/retention", put(set_plugin_retention)) + // Search — operator flush of the shared moka results cache + // (AuthZ audit #14, 2026-07-16). `invalidate_all()` semantics + // touch every tenant, so this is admin-only. Lived at + // `/api/search/cache` pre-2026-07-17; the URL now declares + // its admin intent up front. + .route("/search/cache", delete(clear_search_cache)) // SMTP diagnostics .route("/smtp/info", get(get_smtp_info)) .route("/smtp/test", post(send_smtp_test)) diff --git a/src/interfaces/api/handlers/search_handler.rs b/src/interfaces/api/handlers/search_handler.rs index 2893103f..aa1f69e6 100644 --- a/src/interfaces/api/handlers/search_handler.rs +++ b/src/interfaces/api/handlers/search_handler.rs @@ -1,7 +1,7 @@ use axum::{ extract::{Json, Query, State}, - http::StatusCode, - response::IntoResponse, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, }; use serde_json::json; use tracing::{error, info}; @@ -11,6 +11,8 @@ use crate::application::dtos::search_dto::{ }; use crate::application::ports::inbound::SearchUseCase; use crate::common::di::AppState; +use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::admin::require_admin; use crate::interfaces::middleware::auth::AuthUser; use std::sync::Arc; @@ -187,40 +189,54 @@ impl SearchHandler { } } - /// DELETE /search/cache — clears the search results cache. + /// `DELETE /search/cache` — flush the shared moka search results + /// cache. Admin-only. + /// + /// AuthZ audit #14 (2026-07-12): pre-fix this endpoint required + /// only a valid JWT (via the top-level auth middleware) — any + /// authenticated user, including external / magic-link accounts, + /// could DELETE it in a loop and keep the results cache cold + /// indefinitely (sustained DoS on every subsequent `/api/search` + /// query). Now gated by `require_admin` (401 for missing token, + /// 403 for non-admin caller, 200 for admin). Audit line on success + /// so operator-driven flushes are traceable in security reviews. pub(super) async fn clear_search_cache_impl( State(state): State>, - ) -> impl IntoResponse { + headers: HeaderMap, + ) -> Result { + let (caller_id, _) = require_admin(&state, &headers).await?; info!("API: Clearing search cache"); - let search_service = match &state.applications.search_service { - Some(service) => service, - None => { - error!("Search service not available"); - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Search service is not available" })), - ) - .into_response(); - } + let Some(search_service) = &state.applications.search_service else { + error!("Search service not available"); + return Ok(( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "Search service is not available" })), + ) + .into_response()); }; match search_service.clear_search_cache().await { Ok(_) => { - info!("Search cache cleared successfully"); - ( + tracing::info!( + target: "audit", + event = "search.cache_cleared", + caller_id = %caller_id, + "🧹 search results cache flushed by admin", + ); + Ok(( StatusCode::OK, Json(json!({ "message": "Search cache cleared successfully" })), ) - .into_response() + .into_response()) } Err(err) => { error!("Error clearing search cache: {}", err); - ( + Ok(( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "Error clearing search cache" })), ) - .into_response() + .into_response()) } } } @@ -368,14 +384,19 @@ pub async fn suggest_files( #[utoipa::path( delete, - path = "/api/search/cache", + path = "/api/admin/search/cache", responses( (status = 200, description = "Cache cleared"), + (status = 401, description = "Missing or invalid token"), + (status = 403, description = "Caller is not an admin"), (status = 503, description = "Search service unavailable"), ), security(("bearerAuth" = [])), - tag = "search" + tag = "admin" )] -pub async fn clear_search_cache(state: State>) -> impl IntoResponse { - SearchHandler::clear_search_cache_impl(state).await +pub async fn clear_search_cache( + state: State>, + headers: HeaderMap, +) -> Result { + SearchHandler::clear_search_cache_impl(state, headers).await } diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 8276c4e9..03e01807 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -70,7 +70,7 @@ use crate::interfaces::api::handlers::i18n_handler::{ get_locales, get_translations_by_locale, translate, }; use crate::interfaces::api::handlers::search_handler::{ - clear_search_cache, search_files_get, search_files_post, suggest_files, + search_files_get, search_files_post, suggest_files, }; use crate::interfaces::api::handlers::trash_handler; @@ -275,8 +275,11 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { .route("/suggest", get(suggest_files)) // Advanced search with full criteria object .route("/advanced", post(search_files_post)) - // Clear search cache - .route("/cache", delete(clear_search_cache)) + // `DELETE /api/search/cache` used to live here as a per-user- + // reachable endpoint. It's an operator-only debug lever + // (moka `invalidate_all()` — nukes every tenant), so it + // moved to `/api/admin/search/cache` where the URL declares + // intent. AuthZ audit #14 (2026-07-16). .with_state(app_state.clone()) } else { Router::new() diff --git a/tests/api/search_basic.hurl b/tests/api/search_basic.hurl index 02a7b92b..43c94508 100644 --- a/tests/api/search_basic.hurl +++ b/tests/api/search_basic.hurl @@ -251,6 +251,39 @@ jsonpath "$.filtered" not exists jsonpath "$.total" not exists +# ───────────────────────────────────────────────────────────── +# 6b — Regression pin for AuthZ audit #14 (2026-07-12). +# `DELETE /api/admin/search/cache` calls moka `invalidate_all()` +# on the shared results cache — one call cold-starts every +# subsequent search for every tenant. Pre-fix, this lived at +# `/api/search/cache` gated only by the top-level auth +# middleware: any authenticated caller (including external / +# magic-link accounts) could DELETE it in a loop and hold the +# results cache empty indefinitely (sustained DoS). Fix: gate +# on `require_admin` AND move the URL to `/api/admin/...` so +# the taxonomy declares the intent up front. Moved 2026-07-17. +# +# Bob (regular user) → 403; missing token → 401; admin → 200. +# The 200 confirms the admin path still works (no regression +# on the operator debug lever the endpoint remains for). +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/admin/search/cache +Authorization: Bearer {{bob_token}} + +HTTP 403 + + +DELETE {{base_url}}/api/admin/search/cache + +HTTP 401 + + +DELETE {{base_url}}/api/admin/search/cache +Authorization: Bearer {{admin_token}} + +HTTP 200 + + # ───────────────────────────────────────────────────────────── # 7 — Teardown: removing the folder recursively takes the files # with it, so a single DELETE is enough.