@@ -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