This is an automated email from the ASF dual-hosted git repository.

dongjoon-hyun pushed a commit to branch branch-4.0
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.0 by this push:
     new 5c2ab30d84a7 [SPARK-57510][CORE] Enforce the modify ACL on the 
`/workers/kill` endpoint in MasterWebUI
5c2ab30d84a7 is described below

commit 5c2ab30d84a7c1e2794a064823b0851e9bf54461
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Wed Jun 17 15:19:02 2026 -0700

    [SPARK-57510][CORE] Enforce the modify ACL on the `/workers/kill` endpoint 
in MasterWebUI
    
    ### What changes were proposed in this pull request?
    
    This PR makes the Master Web UI's `/workers/kill` endpoint check the modify 
ACL, like the other kill actions (`MasterPage`, `JobsTab`, `StagesTab`). For 
example, Apache Spark Master UI ignores the request when ACL is denied.
    
    
https://github.com/apache/spark/blob/28c232f295edf06836667121ad859a730f8163d4/core/src/main/scala/org/apache/spark/deploy/master/ui/MasterPage.scala#L65-L67
    
    This is a subtask of SPARK-55556 `Improve Web Security`.
    
    ### Why are the changes needed?
    
    `/workers/kill` was the only destructive Master UI action that did not 
honor the modify ACL; it was guarded only by 
`spark.master.ui.decommission.allow.mode` (IP-based). So with ACLs enabled, a 
user without the modify permission could still decommission workers.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. With `spark.acls.enable=true`, a `/workers/kill` request from a user 
not in `spark.modify.acls` is now ignored. No change with the default config.
    
    ### How was this patch tested?
    
    Pass the CIs with newly added `MasterWebUIAclSuite`.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 4.8)
    
    Closes #56574 from dongjoon-hyun/SPARK-57510.
    
    Authored-by: Dongjoon Hyun <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
    (cherry picked from commit 6b3a5695ca601ce7ed05b6f37c5db545e2f9a917)
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 .../spark/deploy/master/ui/MasterWebUI.scala       |   1 +
 .../deploy/master/ui/MasterWebUIAclSuite.scala     | 103 +++++++++++++++++++++
 .../spark/deploy/master/ui/MasterWebUISuite.scala  |   4 +-
 3 files changed, 107 insertions(+), 1 deletion(-)

diff --git 
a/core/src/main/scala/org/apache/spark/deploy/master/ui/MasterWebUI.scala 
b/core/src/main/scala/org/apache/spark/deploy/master/ui/MasterWebUI.scala
index 6d2fb0245547..86e342aad066 100644
--- a/core/src/main/scala/org/apache/spark/deploy/master/ui/MasterWebUI.scala
+++ b/core/src/main/scala/org/apache/spark/deploy/master/ui/MasterWebUI.scala
@@ -72,6 +72,7 @@ class MasterWebUI(
     if (decommissionEnabled) {
       attachHandler(createServletHandler("/workers/kill", new HttpServlet {
         override def doPost(req: HttpServletRequest, resp: 
HttpServletResponse): Unit = {
+          if (!master.securityMgr.checkModifyPermissions(req.getRemoteUser)) 
return
           val hostnames: Seq[String] = Option(req.getParameterValues("host"))
             .getOrElse(Array[String]()).toImmutableArraySeq
           if (!isDecommissioningRequestAllowed(req)) {
diff --git 
a/core/src/test/scala/org/apache/spark/deploy/master/ui/MasterWebUIAclSuite.scala
 
b/core/src/test/scala/org/apache/spark/deploy/master/ui/MasterWebUIAclSuite.scala
new file mode 100644
index 000000000000..b3e773f075c7
--- /dev/null
+++ 
b/core/src/test/scala/org/apache/spark/deploy/master/ui/MasterWebUIAclSuite.scala
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.deploy.master.ui
+
+import jakarta.servlet.{Filter, FilterChain, ServletRequest, ServletResponse}
+import jakarta.servlet.http.{HttpServletRequest, HttpServletRequestWrapper}
+import org.mockito.Mockito.{mock, never, verify, when}
+
+import org.apache.spark.{SecurityManager, SparkConf, SparkFunSuite}
+import org.apache.spark.deploy.DeployMessages.DecommissionWorkersOnHosts
+import org.apache.spark.deploy.master.Master
+import org.apache.spark.internal.config.DECOMMISSION_ENABLED
+import org.apache.spark.internal.config.UI._
+import org.apache.spark.rpc.{RpcEndpointRef, RpcEnv}
+import org.apache.spark.util.Utils
+
+/**
+ * Tests the modify ACL enforcement of the Master Web UI's `/workers/kill` 
endpoint.
+ * A [[FakeAuthFilter]] injects the remote user so the modify permission can 
be exercised.
+ */
+class MasterWebUIAclSuite extends SparkFunSuite {
+  import MasterWebUISuite._
+
+  val conf = new SparkConf()
+    .set(DECOMMISSION_ENABLED, true)
+    .set(UI_FILTERS, Seq(classOf[FakeAuthFilter].getName))
+    .set(ACLS_ENABLE, true)
+    .set(UI_VIEW_ACLS, Seq("*"))
+    .set(MODIFY_ACLS, Seq("alice"))
+  val securityMgr = new SecurityManager(conf)
+  val rpcEnv = mock(classOf[RpcEnv])
+  val master = mock(classOf[Master])
+  val masterEndpointRef = mock(classOf[RpcEndpointRef])
+  when(master.securityMgr).thenReturn(securityMgr)
+  when(master.conf).thenReturn(conf)
+  when(master.rpcEnv).thenReturn(rpcEnv)
+  when(master.self).thenReturn(masterEndpointRef)
+  val masterWebUI = new MasterWebUI(master, 0)
+
+  override def beforeAll(): Unit = {
+    super.beforeAll()
+    masterWebUI.bind()
+  }
+
+  override def afterAll(): Unit = {
+    try {
+      masterWebUI.stop()
+    } finally {
+      super.afterAll()
+    }
+  }
+
+  private def killWorkers(hostnames: Seq[String], user: String): Unit = {
+    val url = 
s"http://${Utils.localHostNameForURI()}:${masterWebUI.boundPort}/workers/kill/"
+    val body = convPostDataToString(hostnames.map(("host", _)))
+    val headers = Seq(FakeAuthFilter.FAKE_HTTP_USER -> user)
+    val conn = sendHttpRequest(url, "POST", body, headers)
+    // The master is mocked here, so cannot assert on the response code.
+    conn.getResponseCode
+  }
+
+  test("Allow the worker kill request with the modify permission") {
+    val hostnames = Seq("allowed")
+    killWorkers(hostnames, "alice")
+    
verify(masterEndpointRef).askSync[Integer](DecommissionWorkersOnHosts(hostnames))
+  }
+
+  test("Reject the worker kill request without the modify permission") {
+    val hostnames = Seq("denied")
+    killWorkers(hostnames, "nobody")
+    verify(masterEndpointRef, 
never()).askSync[Integer](DecommissionWorkersOnHosts(hostnames))
+  }
+}
+
+/** Test filter that sets the remote user from the request's HTTP_USER header. 
*/
+class FakeAuthFilter extends Filter {
+  override def doFilter(req: ServletRequest, res: ServletResponse, chain: 
FilterChain): Unit = {
+    val hreq = req.asInstanceOf[HttpServletRequest]
+    val wrapped = new HttpServletRequestWrapper(hreq) {
+      override def getRemoteUser(): String = 
hreq.getHeader(FakeAuthFilter.FAKE_HTTP_USER)
+    }
+    chain.doFilter(wrapped, res)
+  }
+}
+
+object FakeAuthFilter {
+  val FAKE_HTTP_USER = "HTTP_USER"
+}
diff --git 
a/core/src/test/scala/org/apache/spark/deploy/master/ui/MasterWebUISuite.scala 
b/core/src/test/scala/org/apache/spark/deploy/master/ui/MasterWebUISuite.scala
index 37d0c5ec692d..332e7bf9b5e0 100644
--- 
a/core/src/test/scala/org/apache/spark/deploy/master/ui/MasterWebUISuite.scala
+++ 
b/core/src/test/scala/org/apache/spark/deploy/master/ui/MasterWebUISuite.scala
@@ -146,9 +146,11 @@ object MasterWebUISuite {
   private[ui] def sendHttpRequest(
       url: String,
       method: String,
-      body: String = ""): HttpURLConnection = {
+      body: String = "",
+      headers: Seq[(String, String)] = Nil): HttpURLConnection = {
     val conn = new 
URI(url).toURL.openConnection().asInstanceOf[HttpURLConnection]
     conn.setRequestMethod(method)
+    headers.foreach { case (k, v) => conn.setRequestProperty(k, v) }
     if (body.nonEmpty) {
       conn.setDoOutput(true)
       conn.setRequestProperty("Content-Type", 
"application/x-www-form-urlencoded")


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to