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

asf-gitbox-commits pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.2 by this push:
     new e79fd9b228dc [MINOR][UI][4.2] Encode query parameters when generating 
log page scripts
e79fd9b228dc is described below

commit e79fd9b228dcc369fb7494796c9abc17f2ae1c2f
Author: Claude <Holden's Claude Robot [email protected]>
AuthorDate: Fri Jul 10 12:59:53 2026 -0700

    [MINOR][UI][4.2] Encode query parameters when generating log page scripts
    
    ### What changes were proposed in this pull request?
    
    The log viewer pages assemble a query string from request parameters and 
embed it into an inline script literal. Encode that value for a JavaScript 
string context using the bundled commons-text StringEscapeUtils, matching the 
existing pattern in AllJobsPage, so the generated script stays well-formed 
regardless of the parameter contents. Also sanitize the parameters on the 
client side in log-view.js, and add a regression test.
    
    Backport to 4.2 of https://github.com/apache/spark/pull/57165
    
    ### Why are the changes needed?
    
    Formatting can get funky with unescaped parameters.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No
    
    ### How was this patch tested?
    
    New test
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Primarily Claude 4.8
    
    Closes #57166 from holdenk/log-page-encode-4.x.
    
    Lead-authored-by: Claude <Holden's Claude Robot 
holden.karau+claudegmail.com>
    
    (cherry picked from commit 2f75218fb6ad63c40bc48057f2a03ff39e6f1f5f)
    
    Closes #57188 from huaxingao/log-page-encode-4.2.
    
    Authored-by: Claude <Holden's Claude Robot [email protected]>
    Signed-off-by: Huaxin Gao <[email protected]>
---
 .../org/apache/spark/deploy/history/LogPage.scala  |  6 ++--
 .../apache/spark/deploy/master/ui/LogPage.scala    |  6 ++--
 .../apache/spark/deploy/worker/ui/LogPage.scala    | 15 +++++---
 .../scala/org/apache/spark/ui/DriverLogPage.scala  |  6 ++--
 .../main/scala/org/apache/spark/ui/UIUtils.scala   | 30 +++++++++++++++-
 .../apache/spark/deploy/history/LogPageSuite.scala | 42 ++++++++++++++++++++++
 .../spark/deploy/worker/ui/LogPageSuite.scala      | 28 +++++++++++++++
 7 files changed, 118 insertions(+), 15 deletions(-)

diff --git a/core/src/main/scala/org/apache/spark/deploy/history/LogPage.scala 
b/core/src/main/scala/org/apache/spark/deploy/history/LogPage.scala
index 1b809b80ee32..9b14e76db37a 100644
--- a/core/src/main/scala/org/apache/spark/deploy/history/LogPage.scala
+++ b/core/src/main/scala/org/apache/spark/deploy/history/LogPage.scala
@@ -56,9 +56,9 @@ private[history] class LogPage(conf: SparkConf) extends 
WebUIPage("logPage") wit
         End of Log
       </div>
 
-    val logParams = "?self&logType=%s".format(logType)
-    val jsOnload = "window.onload = " +
-      s"initLogPage('$logParams', $curLogLength, $startByte, $endByte, 
$logLength, $byteLength);"
+    val logParams = "?self&logType=%s".format(UIUtils.encodeLogParam(logType))
+    val jsOnload = UIUtils.logPageOnloadScript(
+      logParams, curLogLength, startByte, endByte, logLength, byteLength)
 
     val content =
       <script type="module" src={UIUtils.prependBaseUri(request, 
"/static/utils.js")}></script> ++
diff --git 
a/core/src/main/scala/org/apache/spark/deploy/master/ui/LogPage.scala 
b/core/src/main/scala/org/apache/spark/deploy/master/ui/LogPage.scala
index 780f8a2b6c53..7da873622202 100644
--- a/core/src/main/scala/org/apache/spark/deploy/master/ui/LogPage.scala
+++ b/core/src/main/scala/org/apache/spark/deploy/master/ui/LogPage.scala
@@ -55,9 +55,9 @@ private[ui] class LogPage(parent: MasterWebUI) extends 
WebUIPage("logPage") with
         End of Log
       </div>
 
-    val logParams = "?self&logType=%s".format(logType)
-    val jsOnload = "window.onload = " +
-      s"initLogPage('$logParams', $curLogLength, $startByte, $endByte, 
$logLength, $byteLength);"
+    val logParams = "?self&logType=%s".format(UIUtils.encodeLogParam(logType))
+    val jsOnload = UIUtils.logPageOnloadScript(
+      logParams, curLogLength, startByte, endByte, logLength, byteLength)
 
     val content =
       <script type="module" src={UIUtils.prependBaseUri(request, 
"/static/utils.js")}></script> ++
diff --git 
a/core/src/main/scala/org/apache/spark/deploy/worker/ui/LogPage.scala 
b/core/src/main/scala/org/apache/spark/deploy/worker/ui/LogPage.scala
index adfe9670f371..4c3c27c0d9f9 100644
--- a/core/src/main/scala/org/apache/spark/deploy/worker/ui/LogPage.scala
+++ b/core/src/main/scala/org/apache/spark/deploy/worker/ui/LogPage.scala
@@ -71,11 +71,16 @@ private[ui] class LogPage(parent: WorkerWebUI) extends 
WebUIPage("logPage") with
     val byteLength = Option(request.getParameter("byteLength")).map(_.toInt)
       .getOrElse(defaultBytes)
 
+    // The identifiers below flow both into a filesystem path (logDir, kept 
raw and guarded by
+    // Utils.isInDirectory in getLog) and into the query string of the 
generated /log requests
+    // (params). URL-encode the query-string copy so request-supplied values 
cannot inject extra
+    // parameters or truncate the request.
     val (logDir, params, pageName) = (appId, executorId, driverId, self) match 
{
       case (Some(a), Some(e), None, None) =>
-        (s"${workDir.getPath}/$a/$e/", s"appId=$a&executorId=$e", s"$a/$e")
+        (s"${workDir.getPath}/$a/$e/",
+          
s"appId=${UIUtils.encodeLogParam(a)}&executorId=${UIUtils.encodeLogParam(e)}", 
s"$a/$e")
       case (None, None, Some(d), None) =>
-        (s"${workDir.getPath}/$d/", s"driverId=$d", d)
+        (s"${workDir.getPath}/$d/", s"driverId=${UIUtils.encodeLogParam(d)}", 
d)
       case (None, None, None, Some(_)) =>
         (s"${sys.env.getOrElse("SPARK_LOG_DIR", workDir.getPath)}/", "self", 
"worker")
       case _ =>
@@ -105,9 +110,9 @@ private[ui] class LogPage(parent: WorkerWebUI) extends 
WebUIPage("logPage") with
         End of Log
       </div>
 
-    val logParams = "?%s&logType=%s".format(params, logType)
-    val jsOnload = "window.onload = " +
-      s"initLogPage('$logParams', $curLogLength, $startByte, $endByte, 
$logLength, $byteLength);"
+    val logParams = "?%s&logType=%s".format(params, 
UIUtils.encodeLogParam(logType))
+    val jsOnload = UIUtils.logPageOnloadScript(
+      logParams, curLogLength, startByte, endByte, logLength, byteLength)
 
     val content =
       <script type="module" src={UIUtils.prependBaseUri(request, 
"/static/utils.js")}></script> ++
diff --git a/core/src/main/scala/org/apache/spark/ui/DriverLogPage.scala 
b/core/src/main/scala/org/apache/spark/ui/DriverLogPage.scala
index 7b427cffcc5e..9cd2fe50715f 100644
--- a/core/src/main/scala/org/apache/spark/ui/DriverLogPage.scala
+++ b/core/src/main/scala/org/apache/spark/ui/DriverLogPage.scala
@@ -70,9 +70,9 @@ private[ui] class DriverLogPage(
         End of Log
       </div>
 
-    val logParams = "/?logType=%s".format(logType)
-    val jsOnload = "window.onload = " +
-      s"initLogPage('$logParams', $curLogLength, $startByte, $endByte, 
$logLength, $byteLength);"
+    val logParams = "/?logType=%s".format(UIUtils.encodeLogParam(logType))
+    val jsOnload = UIUtils.logPageOnloadScript(
+      logParams, curLogLength, startByte, endByte, logLength, byteLength)
 
     val content =
       <script type="module" src={UIUtils.prependBaseUri(request, 
"/static/utils.js")}></script> ++
diff --git a/core/src/main/scala/org/apache/spark/ui/UIUtils.scala 
b/core/src/main/scala/org/apache/spark/ui/UIUtils.scala
index eedec979f715..c0d36ef3a8ac 100644
--- a/core/src/main/scala/org/apache/spark/ui/UIUtils.scala
+++ b/core/src/main/scala/org/apache/spark/ui/UIUtils.scala
@@ -19,7 +19,7 @@ package org.apache.spark.ui
 
 import java.{util => ju}
 import java.lang.{Long => JLong}
-import java.net.URLDecoder
+import java.net.{URLDecoder, URLEncoder}
 import java.nio.charset.StandardCharsets.UTF_8
 import java.time.{Instant, ZoneId}
 import java.time.format.DateTimeFormatter
@@ -32,6 +32,7 @@ import scala.xml.transform.{RewriteRule, RuleTransformer}
 
 import jakarta.servlet.http.HttpServletRequest
 import jakarta.ws.rs.core.{MediaType, MultivaluedMap, Response}
+import org.apache.commons.text.StringEscapeUtils
 import org.eclipse.jetty.server.Request
 import org.glassfish.jersey.internal.util.collection.MultivaluedStringMap
 
@@ -751,6 +752,33 @@ private[spark] object UIUtils extends Logging {
   def getTimeZoneOffset() : Int =
     TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 1000 / 60
 
+  /**
+   * URL-encode a single request-supplied value before embedding it in a log 
page query string.
+   * This keeps characters such as '&', '=', '#' and whitespace from changing 
the structure of the
+   * subsequent `/log` requests (parameter injection / query truncation).
+   */
+  def encodeLogParam(value: String): String =
+    URLEncoder.encode(Option(value).getOrElse("null"), UTF_8)
+
+  /**
+   * Build the inline `window.onload = initLogPage(...)` bootstrap script 
shared by the log pages
+   * (history / master / worker / driver). Individual parameter values in 
`logParams` must already
+   * be URL-encoded via [[encodeLogParam]] so the query string is unambiguous; 
the assembled string
+   * is additionally escaped for the surrounding JavaScript string literal so 
request-supplied
+   * values cannot break out of the inline &lt;script&gt; block.
+   */
+  def logPageOnloadScript(
+      logParams: String,
+      curLogLength: Long,
+      startByte: Long,
+      endByte: Long,
+      logLength: Long,
+      byteLength: Long): String = {
+    "window.onload = " +
+      s"initLogPage('${StringEscapeUtils.escapeEcmaScript(logParams)}', " +
+      s"$curLogLength, $startByte, $endByte, $logLength, $byteLength);"
+  }
+
   /**
   * Return the correct Href after checking if master is running in the
   * reverse proxy mode or not.
diff --git 
a/core/src/test/scala/org/apache/spark/deploy/history/LogPageSuite.scala 
b/core/src/test/scala/org/apache/spark/deploy/history/LogPageSuite.scala
new file mode 100644
index 000000000000..f37eac47ccea
--- /dev/null
+++ b/core/src/test/scala/org/apache/spark/deploy/history/LogPageSuite.scala
@@ -0,0 +1,42 @@
+/*
+ * 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.history
+
+import jakarta.servlet.http.HttpServletRequest
+import org.mockito.Mockito.{mock, when}
+
+import org.apache.spark.{SparkConf, SparkFunSuite}
+
+class LogPageSuite extends SparkFunSuite {
+
+  test("render encodes the logType parameter embedded in the inline script") {
+    val page = new LogPage(new SparkConf(false))
+    val request = mock(classOf[HttpServletRequest])
+    // A value that would break out of the single-quoted JavaScript string 
literal, close the
+    // script element, or start a new statement if it were emitted into the 
page verbatim.
+    
when(request.getParameter("logType")).thenReturn("stdout');alert('xss')</script>")
+    val html = page.render(request).mkString
+
+    // The value is URL-encoded, so the quotes, angle brackets and slash that 
would break out of
+    // the inline <script> (or inject extra /log parameters) become inert 
percent escapes.
+    
assert(html.contains("logType=stdout%27%29%3Balert%28%27xss%27%29%3C%2Fscript%3E"))
+    // The raw, structure-breaking form must never reach the generated query 
string. (The page
+    // title reflects logType too, but there the XML library HTML-escapes it, 
so it is safe.)
+    assert(!html.contains("logType=stdout');"))
+  }
+}
diff --git 
a/core/src/test/scala/org/apache/spark/deploy/worker/ui/LogPageSuite.scala 
b/core/src/test/scala/org/apache/spark/deploy/worker/ui/LogPageSuite.scala
index c8b4e3372386..cd5e475b8771 100644
--- a/core/src/test/scala/org/apache/spark/deploy/worker/ui/LogPageSuite.scala
+++ b/core/src/test/scala/org/apache/spark/deploy/worker/ui/LogPageSuite.scala
@@ -19,6 +19,7 @@ package org.apache.spark.deploy.worker.ui
 
 import java.io.{File, FileWriter}
 
+import jakarta.servlet.http.HttpServletRequest
 import org.mockito.Mockito.{mock, when}
 import org.scalatest.PrivateMethodTester
 
@@ -75,6 +76,33 @@ class LogPageSuite extends SparkFunSuite with 
PrivateMethodTester {
     assert(error4.startsWith("Error: invalid log directory"))
   }
 
+  test("render encodes request parameters embedded in the inline script") {
+    val webui = mock(classOf[WorkerWebUI])
+    val worker = mock(classOf[Worker])
+    withTempDir { tmpDir =>
+      val workDir = new File(tmpDir, "work-dir")
+      workDir.mkdir()
+      when(webui.workDir).thenReturn(workDir)
+      when(webui.worker).thenReturn(worker)
+      when(worker.conf).thenReturn(new SparkConf())
+      when(worker.activeMasterWebUiUrl).thenReturn("http://master:8080";)
+      val logPage = new LogPage(webui)
+
+      val request = mock(classOf[HttpServletRequest])
+      // appId is user-controlled; a '&' in it would inject an extra parameter 
into the /log
+      // requests, and the '</script>' would break out of the inline <script> 
block, if emitted raw.
+      
when(request.getParameter("appId")).thenReturn("app&byteLength=0</script>")
+      when(request.getParameter("executorId")).thenReturn("0")
+      when(request.getParameter("logType")).thenReturn("stdout")
+      val html = logPage.render(request).mkString
+
+      // The value is percent-encoded, so it stays a single opaque appId value 
and cannot inject a
+      // parameter or close the script element.
+      
assert(html.contains("appId=app%26byteLength%3D0%3C%2Fscript%3E&executorId=0"))
+      assert(!html.contains("app&byteLength=0</script>"))
+    }
+  }
+
   /** Write the specified string to the file. */
   private def write(f: File, s: String): Unit = {
     val writer = new FileWriter(f)


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

Reply via email to