Skip to content

Commit b4f3924

Browse files
authored
fix: refuse to overwrite foreign services (#393)
1 parent 6e23cc8 commit b4f3924

2 files changed

Lines changed: 166 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ All notable changes to this project will be documented in this file.
66

77
- Document Helm deployed RBAC permissions and remove unnecessary permissions ([#380]).
88

9+
### Fixed
10+
11+
- Refuse to overwrite a pre-existing Service that is not owned by the reconciled `Listener`. This
12+
prevents a principal with `create` access on `Listener` from hijacking arbitrary same-named
13+
Services in the namespace via the operator's cluster-wide write permissions ([#393]).
14+
915
[#380]: https://github.com/stackabletech/listener-operator/pull/380
16+
[#393]: https://github.com/stackabletech/listener-operator/pull/393
1017

1118
## [26.3.0] - 2026-03-16
1219

rust/operator-binary/src/listener_controller.rs

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use stackable_operator::{
2020
k8s_openapi::{
2121
DeepMerge,
2222
api::core::v1::{Endpoints, Node, PersistentVolume, Service, ServicePort, ServiceSpec},
23-
apimachinery::pkg::apis::meta::v1::LabelSelector,
23+
apimachinery::pkg::apis::meta::v1::{LabelSelector, OwnerReference},
2424
},
2525
kube::{
2626
Resource, ResourceExt,
@@ -158,6 +158,9 @@ pub enum Error {
158158
#[snafu(display("object has no name"))]
159159
NoName,
160160

161+
#[snafu(display("object has no UID"))]
162+
NoUid,
163+
161164
#[snafu(display("failed to create cluster resources"))]
162165
CreateClusterResources {
163166
source: stackable_operator::cluster_resources::Error,
@@ -208,6 +211,17 @@ pub enum Error {
208211
source: stackable_operator::builder::meta::Error,
209212
},
210213

214+
#[snafu(display("failed to look up pre-existing Service {svc} before applying the Listener"))]
215+
GetExistingService {
216+
source: stackable_operator::client::Error,
217+
svc: ObjectRef<Service>,
218+
},
219+
220+
#[snafu(display(
221+
"refusing to overwrite pre-existing Service {svc} that is not owned by this Listener"
222+
))]
223+
RefuseToOverwriteForeignService { svc: ObjectRef<Service> },
224+
211225
#[snafu(display("failed to apply {svc}"))]
212226
ApplyService {
213227
source: stackable_operator::cluster_resources::Error,
@@ -235,6 +249,7 @@ impl ReconcilerError for Error {
235249
Self::InvalidListener { source: _ } => None,
236250
Self::NoNs => None,
237251
Self::NoName => None,
252+
Self::NoUid => None,
238253
Self::CreateClusterResources { source: _ } => None,
239254
Self::NoListenerClass => None,
240255
Self::ListenerPvSelector { source: _ } => None,
@@ -248,6 +263,8 @@ impl ReconcilerError for Error {
248263
Self::BuildClusterResourcesLabels { source: _ } => None,
249264
Self::GetObject { source: _, obj } => Some(obj.clone()),
250265
Self::BuildListenerOwnerRef { .. } => None,
266+
Self::GetExistingService { source: _, svc } => Some(svc.clone().erase()),
267+
Self::RefuseToOverwriteForeignService { svc } => Some(svc.clone().erase()),
251268
Self::ApplyService { source: _, svc } => Some(svc.clone().erase()),
252269
Self::DeleteOrphans { source: _ } => None,
253270
Self::ApplyStatus { source: _ } => None,
@@ -408,6 +425,16 @@ pub async fn reconcile(
408425
let svc = svc;
409426

410427
let svc_ref = ObjectRef::from_obj(&svc);
428+
429+
// The Service is named after the Listener and applied via server-side apply with `force=true`,
430+
// and the operator holds cluster-wide write permissions on Services. Without this guard, any
431+
// principal who can create a Listener could overwrite a pre-existing same-named foreign Service
432+
// in the namespace, hijack its selector/ports and cascade-delete it via the owner reference.
433+
// Refuse the reconciliation if a same-named Service already exists but is not owned by us.
434+
let listener_uid = listener.metadata.uid.as_deref().context(NoUidSnafu)?;
435+
ensure_existing_service_is_not_foreign(&ctx.client, &svc_name, ns, listener_uid, &svc_ref)
436+
.await?;
437+
411438
let svc = cluster_resources
412439
.add(&ctx.client, svc)
413440
.await
@@ -546,6 +573,50 @@ pub fn error_policy<T>(_obj: Arc<T>, error: &Error, _ctx: Arc<Ctx>) -> controlle
546573
}
547574
}
548575

576+
/// Returns `true` if `existing_owners` contain a controller [`OwnerReference`] that points to the
577+
/// [`listener::v1alpha1::Listener`] with the given UID. Used to ensure we only overwrite output
578+
/// Services that we previously created ourselves; refusing otherwise prevents the Listener
579+
/// primitive from being abused to clobber foreign same-named Services via the operator's elevated
580+
/// cluster-wide write permissions.
581+
fn is_owned_by_listener(existing_owners: &[OwnerReference], listener_uid: &str) -> bool {
582+
let listener_kind = <listener::v1alpha1::Listener as Resource>::kind(&());
583+
existing_owners.iter().any(|owner| {
584+
owner.controller == Some(true)
585+
&& owner.kind == listener_kind
586+
&& owner.api_version.starts_with("listeners.stackable.tech/")
587+
&& owner.uid == listener_uid
588+
})
589+
}
590+
591+
/// Looks up a pre-existing Service with the same name/namespace as the Listener output and fails if
592+
/// it exists but is not controlled by this Listener. See [`is_owned_by_listener`] for the security
593+
/// rationale.
594+
async fn ensure_existing_service_is_not_foreign(
595+
client: &stackable_operator::client::Client,
596+
name: &str,
597+
namespace: &str,
598+
listener_uid: &str,
599+
svc_ref: &ObjectRef<Service>,
600+
) -> Result<()> {
601+
let existing =
602+
client
603+
.get_opt::<Service>(name, namespace)
604+
.await
605+
.context(GetExistingServiceSnafu {
606+
svc: svc_ref.clone(),
607+
})?;
608+
if let Some(existing) = existing {
609+
let owners = existing.metadata.owner_references.as_deref().unwrap_or(&[]);
610+
if !is_owned_by_listener(owners, listener_uid) {
611+
return RefuseToOverwriteForeignServiceSnafu {
612+
svc: svc_ref.clone(),
613+
}
614+
.fail();
615+
}
616+
}
617+
Ok(())
618+
}
619+
549620
/// Lists the names of the [`Node`]s backing this [`listener::v1alpha1::Listener`].
550621
///
551622
/// Should only be used for [`NodePort`](`listener::v1alpha1::ServiceType::NodePort`) [`listener::v1alpha1::Listener`]s.
@@ -684,3 +755,90 @@ pub fn listener_persistent_volume_label(
684755
]
685756
.into())
686757
}
758+
759+
#[cfg(test)]
760+
mod tests {
761+
use super::*;
762+
763+
fn owner_ref(
764+
api_version: &str,
765+
kind: &str,
766+
uid: &str,
767+
controller: Option<bool>,
768+
) -> OwnerReference {
769+
OwnerReference {
770+
api_version: api_version.to_string(),
771+
kind: kind.to_string(),
772+
name: "some-name".to_string(),
773+
uid: uid.to_string(),
774+
controller,
775+
block_owner_deletion: None,
776+
}
777+
}
778+
779+
#[test]
780+
fn empty_owner_refs_are_rejected() {
781+
assert!(!is_owned_by_listener(&[], "my-uid"));
782+
}
783+
784+
#[test]
785+
fn matching_controller_owner_ref_is_accepted() {
786+
let owners = [owner_ref(
787+
"listeners.stackable.tech/v1alpha1",
788+
"Listener",
789+
"my-uid",
790+
Some(true),
791+
)];
792+
assert!(is_owned_by_listener(&owners, "my-uid"));
793+
}
794+
795+
#[test]
796+
fn non_controller_owner_ref_is_rejected() {
797+
let owners = [owner_ref(
798+
"listeners.stackable.tech/v1alpha1",
799+
"Listener",
800+
"my-uid",
801+
Some(false),
802+
)];
803+
assert!(!is_owned_by_listener(&owners, "my-uid"));
804+
}
805+
806+
#[test]
807+
fn different_uid_is_rejected() {
808+
let owners = [owner_ref(
809+
"listeners.stackable.tech/v1alpha1",
810+
"Listener",
811+
"other-uid",
812+
Some(true),
813+
)];
814+
assert!(!is_owned_by_listener(&owners, "my-uid"));
815+
}
816+
817+
#[test]
818+
fn different_kind_is_rejected() {
819+
let owners = [owner_ref("v1", "ConfigMap", "my-uid", Some(true))];
820+
assert!(!is_owned_by_listener(&owners, "my-uid"));
821+
}
822+
823+
#[test]
824+
fn foreign_api_group_is_rejected() {
825+
let owners = [owner_ref(
826+
"evil.example.com/v1",
827+
"Listener",
828+
"my-uid",
829+
Some(true),
830+
)];
831+
assert!(!is_owned_by_listener(&owners, "my-uid"));
832+
}
833+
834+
#[test]
835+
fn unrelated_controller_owner_ref_is_rejected() {
836+
let owners = [owner_ref(
837+
"apps/v1",
838+
"Deployment",
839+
"some-deployment-uid",
840+
Some(true),
841+
)];
842+
assert!(!is_owned_by_listener(&owners, "my-uid"));
843+
}
844+
}

0 commit comments

Comments
 (0)