From a6f49937648c34f0e89a21002af276e1349b4728 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:59:13 +0000 Subject: [PATCH] Fix IDOR vulnerability in bulk instance endpoints This commit secures the `bulk_update_state`, `bulk_reschedule`, `list_dlq`, and `batch_action` endpoints in `orch8-api/src/instances/bulk.rs`. Previously, these endpoints relied solely on `auth::scoped_tenant_id` to extract the tenant. Because they allowed falling back to a query parameter or request body value for `tenant_id`, an unauthenticated or unscoped caller could specify an arbitrary tenant ID and bypass tenant isolation. By injecting the `OptionalAdmin` extractor into these handlers and enforcing `require_admin` when `tenant_ctx` is missing, we ensure that only authenticated tenants or global admin callers can interact with these endpoints. proxy calls in `mcp_server.rs` were also updated. Co-authored-by: ovasylenko <3797513+ovasylenko@users.noreply.github.com> --- orch8-api/src/instances/bulk.rs | 16 ++++++++++++++++ orch8-api/src/mcp_server.rs | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/orch8-api/src/instances/bulk.rs b/orch8-api/src/instances/bulk.rs index 3f2f2a17..3bc8487d 100644 --- a/orch8-api/src/instances/bulk.rs +++ b/orch8-api/src/instances/bulk.rs @@ -38,10 +38,14 @@ fn metadata_filter(filter: &BulkFilter) -> Option { )] pub async fn bulk_update_state( State(state): State, + admin_ctx: crate::auth::OptionalAdmin, tenant_ctx: crate::auth::OptionalTenant, Json(req): Json, ) -> Result { let scoped_tenant = crate::auth::scoped_tenant_id(&tenant_ctx, req.filter.tenant_id.as_deref()); + if tenant_ctx.is_none() { + crate::api_keys::require_admin(&admin_ctx)?; + } if scoped_tenant.is_none() { return Err(ApiError::InvalidArgument( "bulk operations require a tenant_id".into(), @@ -72,10 +76,14 @@ pub async fn bulk_update_state( )] pub async fn bulk_reschedule( State(state): State, + admin_ctx: crate::auth::OptionalAdmin, tenant_ctx: crate::auth::OptionalTenant, Json(req): Json, ) -> Result { let scoped_tenant = crate::auth::scoped_tenant_id(&tenant_ctx, req.filter.tenant_id.as_deref()); + if tenant_ctx.is_none() { + crate::api_keys::require_admin(&admin_ctx)?; + } if scoped_tenant.is_none() { return Err(ApiError::InvalidArgument( "bulk operations require a tenant_id".into(), @@ -112,10 +120,14 @@ pub async fn bulk_reschedule( )] pub async fn list_dlq( State(state): State, + admin_ctx: crate::auth::OptionalAdmin, tenant_ctx: crate::auth::OptionalTenant, Query(q): Query, ) -> Result { let scoped_tenant = crate::auth::scoped_tenant_id(&tenant_ctx, q.tenant_id.as_deref()); + if tenant_ctx.is_none() { + crate::api_keys::require_admin(&admin_ctx)?; + } let filter = InstanceFilter { tenant_id: scoped_tenant, namespace: q.namespace.map(Namespace::new), @@ -153,10 +165,14 @@ pub async fn list_dlq( )] pub async fn batch_action( State(state): State, + admin_ctx: crate::auth::OptionalAdmin, tenant_ctx: crate::auth::OptionalTenant, Json(req): Json, ) -> Result { let scoped_tenant = crate::auth::scoped_tenant_id(&tenant_ctx, req.filter.tenant_id.as_deref()); + if tenant_ctx.is_none() { + crate::api_keys::require_admin(&admin_ctx)?; + } let Some(tenant_id) = scoped_tenant else { return Err(ApiError::InvalidArgument( "batch actions require a tenant_id".into(), diff --git a/orch8-api/src/mcp_server.rs b/orch8-api/src/mcp_server.rs index 086f65c3..b22a714e 100644 --- a/orch8-api/src/mcp_server.rs +++ b/orch8-api/src/mcp_server.rs @@ -426,7 +426,7 @@ async fn tool_list_dlq(state: AppState, tenant_ctx: OptionalTenant, args: &Value query.insert("limit".into(), json!(limit)); } let q: crate::instances::ListQuery = parse_args(Value::Object(query))?; - rest_json(crate::instances::list_dlq(State(state), tenant_ctx, Query(q)).await).await + rest_json(crate::instances::list_dlq(State(state), None, tenant_ctx, Query(q)).await).await } /// `get_usage`: tenant-scoped LLM token/cost aggregation over a time window.