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

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


The following commit(s) were added to refs/heads/master by this push:
     new e12bd5a6434c [SPARK-55522][WEBUI] Allow inline scripts, event handlers 
and styles in Spark UI with Content-Security-Policy
e12bd5a6434c is described below

commit e12bd5a6434c95ae3b259b4e85df0382475e7b49
Author: Kousuke Saruta <[email protected]>
AuthorDate: Sat Feb 14 00:55:20 2026 -0800

    [SPARK-55522][WEBUI] Allow inline scripts, event handlers and styles in 
Spark UI with Content-Security-Policy
    
    ### What changes were proposed in this pull request?
    This PR fixes an issue that the WebUI has been broken since 
Content-Security-Policy was introduced in #54028
    
    This is one example.
    <img width="1720" height="850" alt="broken-ui" 
src="https://github.com/user-attachments/assets/2ac1f328-c5d9-487e-a2e3-e216612a50f0";
 />
    
    The reason is that inline scripts, event handlers and styles are not 
allowed.
    To allow inline scripts, this PR adds `nonce`.
    
https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/nonce#using_nonce_to_allowlist_a_script_element
    
    This workaround cannot be applied to inline styles so this PR applies 
`unsafe-inline`.
    
https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy#unsafe-inline
    I think this compromise is OK because inline scripts are protected by nonce.
    
    Nonce cannot be applied to inline handlers too. So this PR rewrites them.
    
    ### Why are the changes needed?
    Bug fix.
    
    ### Does this PR introduce _any_ user-facing change?
    No.
    
    ### How was this patch tested?
    New tests added mainly for nonce.
    I also confirmed that UI components are correctly rendered, no error 
message on browser-embedded developer console and mouse actions correctly works 
with simple RDD jobs and queries.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    Kiro CLI / Opus 4.6
    
    Closes #54315 from sarutak/fix-content-security-policy-for-inline-js2.
    
    Authored-by: Kousuke Saruta <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 .../resources/org/apache/spark/ui/static/table.js  | 26 ++++++
 .../resources/org/apache/spark/ui/static/utils.js  |  2 +-
 .../resources/org/apache/spark/ui/static/webui.js  | 52 +++++++++++-
 .../apache/spark/deploy/history/HistoryPage.scala  |  5 +-
 .../org/apache/spark/deploy/history/LogPage.scala  |  8 +-
 .../spark/deploy/master/ui/ApplicationPage.scala   |  7 +-
 .../spark/deploy/master/ui/EnvironmentPage.scala   | 28 +++----
 .../apache/spark/deploy/master/ui/LogPage.scala    |  8 +-
 .../apache/spark/deploy/master/ui/MasterPage.scala | 32 ++++----
 .../apache/spark/deploy/worker/ui/LogPage.scala    |  8 +-
 .../apache/spark/deploy/worker/ui/WorkerPage.scala | 16 ++--
 .../main/scala/org/apache/spark/ui/CspNonce.scala  | 44 ++++++++++
 .../scala/org/apache/spark/ui/DriverLogPage.scala  |  6 +-
 .../scala/org/apache/spark/ui/GraphUIData.scala    |  2 +-
 .../org/apache/spark/ui/HttpSecurityFilter.scala   | 83 ++++++++++---------
 .../main/scala/org/apache/spark/ui/UIUtils.scala   |  8 +-
 .../org/apache/spark/ui/env/EnvironmentPage.scala  | 28 +++----
 .../spark/ui/exec/ExecutorThreadDumpPage.scala     | 31 +++----
 .../org/apache/spark/ui/exec/ExecutorsTab.scala    |  4 +-
 .../org/apache/spark/ui/jobs/AllJobsPage.scala     | 25 +++---
 .../org/apache/spark/ui/jobs/AllStagesPage.scala   |  7 +-
 .../scala/org/apache/spark/ui/jobs/JobPage.scala   | 20 +++--
 .../scala/org/apache/spark/ui/jobs/PoolPage.scala  |  4 +-
 .../scala/org/apache/spark/ui/jobs/StagePage.scala |  4 +-
 .../org/apache/spark/ui/jobs/StageTable.scala      | 16 +---
 .../org/apache/spark/ui/storage/RDDPage.scala      |  2 +-
 .../org/apache/spark/ui/storage/StoragePage.scala  |  3 +-
 .../scala/org/apache/spark/ui/CspNonceSuite.scala  | 84 +++++++++++++++++++
 .../apache/spark/ui/HttpSecurityFilterSuite.scala  | 81 +++++++++++++++++-
 .../scala/org/apache/spark/ui/UIUtilsSuite.scala   |  8 ++
 .../sql/connect/ui/SparkConnectServerPage.scala    |  8 +-
 .../connect/ui/SparkConnectServerSessionPage.scala |  4 +-
 .../connect/ui/SparkConnectServerPageSuite.scala   |  4 +-
 .../spark/sql/execution/ui/AllExecutionsPage.scala | 24 ++----
 .../spark/sql/execution/ui/ExecutionPage.scala     | 11 +--
 .../sql/streaming/ui/StreamingQueryPage.scala      |  6 +-
 .../ui/StreamingQueryStatisticsPage.scala          | 10 +--
 .../hive/thriftserver/ui/ThriftServerPage.scala    |  8 +-
 .../thriftserver/ui/ThriftServerSessionPage.scala  |  4 +-
 .../thriftserver/ui/ThriftServerPageSuite.scala    |  4 +-
 .../org/apache/spark/streaming/ui/BatchPage.scala  |  2 +-
 .../apache/spark/streaming/ui/StreamingPage.scala  | 20 ++---
 ui-test/package.json                               |  6 +-
 ui-test/tests/csp-compliance.test.js               | 96 ++++++++++++++++++++++
 44 files changed, 624 insertions(+), 235 deletions(-)

diff --git a/core/src/main/resources/org/apache/spark/ui/static/table.js 
b/core/src/main/resources/org/apache/spark/ui/static/table.js
index b3aa85f64c5d..1e99b13f7ccb 100644
--- a/core/src/main/resources/org/apache/spark/ui/static/table.js
+++ b/core/src/main/resources/org/apache/spark/ui/static/table.js
@@ -117,3 +117,29 @@ function collapseTableAndButton(thisName, table) {
   }
 }
 /* eslint-enable no-unused-vars */
+
+// Event delegation for thread dump page (CSP-compliant)
+$(function() {
+  // toggleThreadStackTrace on row click
+  $(document).on("click", "tr.accordion-heading[data-thread-id]", function() {
+    toggleThreadStackTrace($(this).data("thread-id"), false);
+  });
+
+  // expandAll / collapseAll
+  $(document).on("click", "[data-action=expandAllThreadStackTrace]", 
function() {
+    expandAllThreadStackTrace(true);
+  });
+  $(document).on("click", "[data-action=collapseAllThreadStackTrace]", 
function() {
+    collapseAllThreadStackTrace($(this).data("toggle-button") !== false);
+  });
+
+  // onMouseOverAndOut
+  $(document).on("mouseenter mouseleave", 
"tr.accordion-heading[data-thread-id]", function() {
+    onMouseOverAndOut($(this).data("thread-id"));
+  });
+
+  // onSearchStringChange
+  $(document).on("input", "[data-search-input]", function() {
+    onSearchStringChange();
+  });
+});
diff --git a/core/src/main/resources/org/apache/spark/ui/static/utils.js 
b/core/src/main/resources/org/apache/spark/ui/static/utils.js
index 2d4123bc75ab..ca5fbdc6b80c 100644
--- a/core/src/main/resources/org/apache/spark/ui/static/utils.js
+++ b/core/src/main/resources/org/apache/spark/ui/static/utils.js
@@ -237,7 +237,7 @@ function getBaseURI() {
 
 function detailsUINode(isMultiline, message) {
   if (isMultiline) {
-    const span = '<span 
onclick="this.parentNode.querySelector(\'.stacktrace-details\').classList.toggle(\'collapsed\')"
 class="expand-details">+details</span>';
+    const span = '<span data-toggle-details=".stacktrace-details" 
class="expand-details">+details</span>';
     const pre = '<pre>' + message + '</pre>';
     const div = '<div class="stacktrace-details collapsed">' + pre + '</div>';
     return span + div;
diff --git a/core/src/main/resources/org/apache/spark/ui/static/webui.js 
b/core/src/main/resources/org/apache/spark/ui/static/webui.js
index 4c7cf8c8ea90..6d20c16ffaf4 100644
--- a/core/src/main/resources/org/apache/spark/ui/static/webui.js
+++ b/core/src/main/resources/org/apache/spark/ui/static/webui.js
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-/* global $ */
+/* global $, collapseTableAndButton, loadMore, loadNew, toggleDagViz, 
togglePlanViz, clickPhysicalPlanDetails */
 /* eslint-disable no-unused-vars */
 var uiRoot = "";
 var appBasePath = "";
@@ -111,3 +111,53 @@ $(function() {
     $(this).toggleClass("description-input-full");
   });
 });
+
+// Event delegation for CSP-compliant inline event handler replacement.
+$(function() {
+  // collapseTable / collapseTableAndButton
+  $(document).on("click", "[data-collapse-name]", function() {
+    var name = $(this).data("collapse-name");
+    var table = $(this).data("collapse-table");
+    if ($(this).data("collapse-button")) {
+      collapseTableAndButton(name, table);
+    } else {
+      collapseTable(name, table);
+    }
+  });
+
+  // toggle details (stage-details, stacktrace-details, expand-details)
+  $(document).on("click", "[data-toggle-details]", function() {
+    var selector = $(this).data("toggle-details");
+    this.parentNode.querySelector(selector).classList.toggle("collapsed");
+  });
+
+  // toggle sub-execution list (tr two siblings away from parent tr)
+  $(document).on("click", "[data-toggle-sub-execution]", function() {
+    
$(this).closest("tr").nextAll("tr.sub-execution-list").first().toggleClass("collapsed");
+  });
+
+  // kill links with confirmation
+  $(document).on("click", "a.kill-link[data-kill-message]", function(e) {
+    if (!window.confirm($(this).data("kill-message"))) {
+      e.preventDefault();
+    } else if ($(this).closest("form").length > 0) {
+      e.preventDefault();
+      $(this).closest("form").submit();
+    }
+  });
+
+  // loadMore / loadNew buttons
+  $(document).on("click", ".log-more-btn", function() { loadMore(); });
+  $(document).on("click", ".log-new-btn", function() { loadNew(); });
+
+  // toggleDagViz
+  $(document).on("click", ".expand-dag-viz[data-forjob]", function() {
+    toggleDagViz($(this).data("forjob"));
+  });
+
+  // togglePlanViz / clickPhysicalPlanDetails
+  $(document).on("click", "[data-action=togglePlanViz]", function() { 
togglePlanViz(); });
+  $(document).on("click", "[data-action=clickPhysicalPlanDetails]", function() 
{
+    clickPhysicalPlanDetails();
+  });
+});
diff --git 
a/core/src/main/scala/org/apache/spark/deploy/history/HistoryPage.scala 
b/core/src/main/scala/org/apache/spark/deploy/history/HistoryPage.scala
index 4eeddd7cc709..f0131ba0081f 100644
--- a/core/src/main/scala/org/apache/spark/deploy/history/HistoryPage.scala
+++ b/core/src/main/scala/org/apache/spark/deploy/history/HistoryPage.scala
@@ -22,7 +22,7 @@ import scala.xml.{Node, Unparsed}
 import jakarta.servlet.http.HttpServletRequest
 
 import org.apache.spark.status.api.v1.ApplicationInfo
-import org.apache.spark.ui.{UIUtils, WebUIPage}
+import org.apache.spark.ui.{CspNonce, UIUtils, WebUIPage}
 import org.apache.spark.ui.UIUtils.formatImportJavaScript
 
 private[history] class HistoryPage(parent: HistoryServer) extends 
WebUIPage("") {
@@ -74,7 +74,8 @@ private[history] class HistoryPage(parent: HistoryServer) 
extends WebUIPage("")
               request, "/static/dataTables.rowsGroup.js")}></script> ++
             <script type="module" src={UIUtils.prependBaseUri(
               request, "/static/historypage.js")} ></script> ++
-            <script type="module">{Unparsed(js)}</script> ++ <div 
id="history-summary"></div>
+            <script type="module" nonce={CspNonce.get}>{Unparsed(js)}</script> 
++
+              <div id="history-summary"></div>
           } else if (requestedIncomplete) {
             <h4>No incomplete applications found!</h4>
           } else if (eventLogsUnderProcessCount > 0) {
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 3507ebd101ae..c7f02ea0ab36 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
@@ -24,7 +24,7 @@ import jakarta.servlet.http.HttpServletRequest
 import org.apache.spark.SparkConf
 import org.apache.spark.deploy.Utils.{getLog, DEFAULT_BYTES}
 import org.apache.spark.internal.Logging
-import org.apache.spark.ui.{UIUtils, WebUIPage}
+import org.apache.spark.ui.{CspNonce, UIUtils, WebUIPage}
 
 private[history] class LogPage(conf: SparkConf) extends WebUIPage("logPage") 
with Logging {
   def render(request: HttpServletRequest): Seq[Node] = {
@@ -42,12 +42,12 @@ private[history] class LogPage(conf: SparkConf) extends 
WebUIPage("logPage") wit
       </span>
 
     val moreButton =
-      <button type="button" onclick={"loadMore()"} class="log-more-btn btn 
btn-secondary">
+      <button type="button" class="log-more-btn btn btn-secondary">
         Load More
       </button>
 
     val newButton =
-      <button type="button" onclick={"loadNew()"} class="log-new-btn btn 
btn-secondary">
+      <button type="button" class="log-new-btn btn btn-secondary">
         Load New
       </button>
 
@@ -71,7 +71,7 @@ private[history] class LogPage(conf: SparkConf) extends 
WebUIPage("logPage") wit
           {alert}
           <div>{newButton}</div>
         </div>
-        <script>{Unparsed(jsOnload)}</script>
+        <script nonce={CspNonce.get}>{Unparsed(jsOnload)}</script>
       </div>
 
     UIUtils.basicSparkPage(request, content, logType + " log page for history 
server")
diff --git 
a/core/src/main/scala/org/apache/spark/deploy/master/ui/ApplicationPage.scala 
b/core/src/main/scala/org/apache/spark/deploy/master/ui/ApplicationPage.scala
index 1a4668802234..5483d30de70d 100644
--- 
a/core/src/main/scala/org/apache/spark/deploy/master/ui/ApplicationPage.scala
+++ 
b/core/src/main/scala/org/apache/spark/deploy/master/ui/ApplicationPage.scala
@@ -116,7 +116,8 @@ private[ui] class ApplicationPage(parent: MasterWebUI) 
extends WebUIPage("app")
       <div class="row"> <!-- Executors -->
         <div class="col-12">
           <span class="collapse-aggregated-executors collapse-table"
-              
onClick="collapseTable('collapse-aggregated-executors','aggregated-executors')">
+              data-collapse-name="collapse-aggregated-executors"
+              data-collapse-table="aggregated-executors">
             <h4>
               <span class="collapse-table-arrow arrow-open"></span>
               <a>Executor Summary ({allExecutors.length})</a>
@@ -128,8 +129,8 @@ private[ui] class ApplicationPage(parent: MasterWebUI) 
extends WebUIPage("app")
           {
             if (removedExecutors.nonEmpty) {
               <span class="collapse-aggregated-removedExecutors collapse-table"
-                  
onClick="collapseTable('collapse-aggregated-removedExecutors',
-                  'aggregated-removedExecutors')">
+                  data-collapse-name="collapse-aggregated-removedExecutors"
+                  data-collapse-table="aggregated-removedExecutors">
                 <h4>
                   <span class="collapse-table-arrow arrow-open"></span>
                   <a>Removed Executors ({removedExecutors.length})</a>
diff --git 
a/core/src/main/scala/org/apache/spark/deploy/master/ui/EnvironmentPage.scala 
b/core/src/main/scala/org/apache/spark/deploy/master/ui/EnvironmentPage.scala
index 977f8cfae75e..396e33e7a675 100644
--- 
a/core/src/main/scala/org/apache/spark/deploy/master/ui/EnvironmentPage.scala
+++ 
b/core/src/main/scala/org/apache/spark/deploy/master/ui/EnvironmentPage.scala
@@ -66,8 +66,8 @@ private[ui] class EnvironmentPage(
       </div>
       <span>
         <span class="collapse-aggregated-runtimeInformation collapse-table"
-            onClick="collapseTable('collapse-aggregated-runtimeInformation',
-            'aggregated-runtimeInformation')">
+            data-collapse-name="collapse-aggregated-runtimeInformation"
+            data-collapse-table="aggregated-runtimeInformation">
           <h4>
             <span class="collapse-table-arrow arrow-open"></span>
             <a>Runtime Information</a>
@@ -77,8 +77,8 @@ private[ui] class EnvironmentPage(
           {runtimeInformationTable}
         </div>
         <span class="collapse-aggregated-sparkProperties collapse-table"
-            onClick="collapseTable('collapse-aggregated-sparkProperties',
-            'aggregated-sparkProperties')">
+            data-collapse-name="collapse-aggregated-sparkProperties"
+            data-collapse-table="aggregated-sparkProperties">
           <h4>
             <span class="collapse-table-arrow arrow-open"></span>
             <a>Spark Properties</a>
@@ -88,8 +88,8 @@ private[ui] class EnvironmentPage(
           {sparkPropertiesTable}
         </div>
         <span class="collapse-aggregated-hadoopProperties collapse-table"
-              onClick="collapseTable('collapse-aggregated-hadoopProperties',
-            'aggregated-hadoopProperties')">
+              data-collapse-name="collapse-aggregated-hadoopProperties"
+              data-collapse-table="aggregated-hadoopProperties">
           <h4>
             <span class="collapse-table-arrow arrow-closed"></span>
             <a>Hadoop Properties</a>
@@ -99,8 +99,8 @@ private[ui] class EnvironmentPage(
           {hadoopPropertiesTable}
         </div>
         <span class="collapse-aggregated-systemProperties collapse-table"
-            onClick="collapseTable('collapse-aggregated-systemProperties',
-            'aggregated-systemProperties')">
+            data-collapse-name="collapse-aggregated-systemProperties"
+            data-collapse-table="aggregated-systemProperties">
           <h4>
             <span class="collapse-table-arrow arrow-closed"></span>
             <a>System Properties</a>
@@ -110,8 +110,8 @@ private[ui] class EnvironmentPage(
           {systemPropertiesTable}
         </div>
         <span class="collapse-aggregated-metricsProperties collapse-table"
-              onClick="collapseTable('collapse-aggregated-metricsProperties',
-            'aggregated-metricsProperties')">
+              data-collapse-name="collapse-aggregated-metricsProperties"
+              data-collapse-table="aggregated-metricsProperties">
           <h4>
             <span class="collapse-table-arrow arrow-closed"></span>
             <a>Metrics Properties</a>
@@ -121,8 +121,8 @@ private[ui] class EnvironmentPage(
           {metricsPropertiesTable}
         </div>
         <span class="collapse-aggregated-classpathEntries collapse-table"
-            onClick="collapseTable('collapse-aggregated-classpathEntries',
-            'aggregated-classpathEntries')">
+            data-collapse-name="collapse-aggregated-classpathEntries"
+            data-collapse-table="aggregated-classpathEntries">
           <h4>
             <span class="collapse-table-arrow arrow-closed"></span>
             <a>Classpath Entries</a>
@@ -132,8 +132,8 @@ private[ui] class EnvironmentPage(
           {classpathEntriesTable}
         </div>
         <span class="collapse-aggregated-environmentVariables collapse-table"
-            onClick="collapseTable('collapse-aggregated-environmentVariables',
-            'aggregated-environmentVariables')">
+            data-collapse-name="collapse-aggregated-environmentVariables"
+            data-collapse-table="aggregated-environmentVariables">
           <h4>
             <span class="collapse-table-arrow arrow-closed"></span>
             <a>Environment Variables</a>
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 6d3ff1906d17..7d2613ad5fa3 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
@@ -23,7 +23,7 @@ import jakarta.servlet.http.HttpServletRequest
 
 import org.apache.spark.deploy.Utils.{getLog, DEFAULT_BYTES}
 import org.apache.spark.internal.Logging
-import org.apache.spark.ui.{UIUtils, WebUIPage}
+import org.apache.spark.ui.{CspNonce, UIUtils, WebUIPage}
 
 private[ui] class LogPage(parent: MasterWebUI) extends WebUIPage("logPage") 
with Logging {
   def render(request: HttpServletRequest): Seq[Node] = {
@@ -41,12 +41,12 @@ private[ui] class LogPage(parent: MasterWebUI) extends 
WebUIPage("logPage") with
       </span>
 
     val moreButton =
-      <button type="button" onclick={"loadMore()"} class="log-more-btn btn 
btn-secondary">
+      <button type="button" class="log-more-btn btn btn-secondary">
         Load More
       </button>
 
     val newButton =
-      <button type="button" onclick={"loadNew()"} class="log-new-btn btn 
btn-secondary">
+      <button type="button" class="log-new-btn btn btn-secondary">
         Load New
       </button>
 
@@ -70,7 +70,7 @@ private[ui] class LogPage(parent: MasterWebUI) extends 
WebUIPage("logPage") with
           {alert}
           <div>{newButton}</div>
         </div>
-        <script>{Unparsed(jsOnload)}</script>
+        <script nonce={CspNonce.get}>{Unparsed(jsOnload)}</script>
       </div>
 
     UIUtils.basicSparkPage(request, content, logType + " log page for master")
diff --git 
a/core/src/main/scala/org/apache/spark/deploy/master/ui/MasterPage.scala 
b/core/src/main/scala/org/apache/spark/deploy/master/ui/MasterPage.scala
index a396444ebe9c..b33fdc4939ac 100644
--- a/core/src/main/scala/org/apache/spark/deploy/master/ui/MasterPage.scala
+++ b/core/src/main/scala/org/apache/spark/deploy/master/ui/MasterPage.scala
@@ -182,7 +182,8 @@ private[ui] class MasterPage(parent: MasterWebUI) extends 
WebUIPage("") {
         <div class="row">
           <div class="col-12">
             <span class="collapse-aggregated-workers collapse-table"
-                
onClick="collapseTable('collapse-aggregated-workers','aggregated-workers')">
+                data-collapse-name="collapse-aggregated-workers"
+                data-collapse-table="aggregated-workers">
               <h4>
                 <span class="collapse-table-arrow arrow-open"></span>
                 <a>Workers ({workers.length})</a>
@@ -197,7 +198,8 @@ private[ui] class MasterPage(parent: MasterWebUI) extends 
WebUIPage("") {
         <div class="row">
           <div class="col-12">
             <span id="running-app" class="collapse-aggregated-activeApps 
collapse-table"
-                
onClick="collapseTable('collapse-aggregated-activeApps','aggregated-activeApps')">
+                data-collapse-name="collapse-aggregated-activeApps"
+                data-collapse-table="aggregated-activeApps">
               <h4>
                 <span class="collapse-table-arrow arrow-open"></span>
                 <a>Running Applications ({activeApps.length})</a>
@@ -214,8 +216,8 @@ private[ui] class MasterPage(parent: MasterWebUI) extends 
WebUIPage("") {
              <div class="row">
                <div class="col-12">
                  <span class="collapse-aggregated-activeDrivers collapse-table"
-                     
onClick="collapseTable('collapse-aggregated-activeDrivers',
-                     'aggregated-activeDrivers')">
+                     data-collapse-name="collapse-aggregated-activeDrivers"
+                     data-collapse-table="aggregated-activeDrivers">
                    <h4>
                      <span class="collapse-table-arrow arrow-open"></span>
                      <a>Running Drivers ({activeDrivers.length})</a>
@@ -233,8 +235,8 @@ private[ui] class MasterPage(parent: MasterWebUI) extends 
WebUIPage("") {
         <div class="row">
           <div class="col-12">
             <span id="completed-app" class="collapse-aggregated-completedApps 
collapse-table"
-                onClick="collapseTable('collapse-aggregated-completedApps',
-                'aggregated-completedApps')">
+                data-collapse-name="collapse-aggregated-completedApps"
+                data-collapse-table="aggregated-completedApps">
               <h4>
                 <span class="collapse-table-arrow arrow-open"></span>
                 <a>Completed Applications ({completedApps.length})</a>
@@ -252,8 +254,8 @@ private[ui] class MasterPage(parent: MasterWebUI) extends 
WebUIPage("") {
               <div class="row">
                 <div class="col-12">
                   <span class="collapse-aggregated-completedDrivers 
collapse-table"
-                      
onClick="collapseTable('collapse-aggregated-completedDrivers',
-                      'aggregated-completedDrivers')">
+                      data-collapse-name="collapse-aggregated-completedDrivers"
+                      data-collapse-table="aggregated-completedDrivers">
                     <h4>
                       <span class="collapse-table-arrow arrow-open"></span>
                       <a>Completed Drivers ({completedDrivers.length})</a>
@@ -300,13 +302,12 @@ private[ui] class MasterPage(parent: MasterWebUI) extends 
WebUIPage("") {
   private def appRow(app: ApplicationInfo): Seq[Node] = {
     val killLink = if (parent.killEnabled &&
       (app.state == ApplicationState.RUNNING || app.state == 
ApplicationState.WAITING)) {
-      val confirm =
-        s"if (window.confirm('Are you sure you want to kill application 
${app.id} ?')) " +
-          "{ this.parentNode.submit(); return true; } else { return false; }"
       <form action="app/kill/" method="POST" style="display:inline">
         <input type="hidden" name="id" value={app.id}/>
         <input type="hidden" name="terminate" value="true"/>
-        <a href="#" onclick={confirm} class="kill-link">(kill)</a>
+        <a href="#"
+           data-kill-message={s"Are you sure you want to kill application 
${app.id} ?"}
+           class="kill-link">(kill)</a>
       </form>
     }
     <tr>
@@ -350,13 +351,12 @@ private[ui] class MasterPage(parent: MasterWebUI) extends 
WebUIPage("") {
     val killLink = if (parent.killEnabled &&
       (driver.state == DriverState.RUNNING ||
         driver.state == DriverState.SUBMITTED)) {
-      val confirm =
-        s"if (window.confirm('Are you sure you want to kill driver 
${driver.id} ?')) " +
-          "{ this.parentNode.submit(); return true; } else { return false; }"
       <form action="driver/kill/" method="POST" style="display:inline">
         <input type="hidden" name="id" value={driver.id}/>
         <input type="hidden" name="terminate" value="true"/>
-        <a href="#" onclick={confirm} class="kill-link">(kill)</a>
+        <a href="#"
+           data-kill-message={s"Are you sure you want to kill driver 
${driver.id} ?"}
+           class="kill-link">(kill)</a>
       </form>
     }
     <tr>
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 bdc143be91b9..06fe1f841613 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
@@ -25,7 +25,7 @@ import jakarta.servlet.http.HttpServletRequest
 
 import org.apache.spark.internal.Logging
 import org.apache.spark.internal.LogKeys.{LOG_TYPE, PATH}
-import org.apache.spark.ui.{UIUtils, WebUIPage}
+import org.apache.spark.ui.{CspNonce, UIUtils, WebUIPage}
 import org.apache.spark.util.Utils
 import org.apache.spark.util.logging.RollingFileAppender
 
@@ -91,12 +91,12 @@ private[ui] class LogPage(parent: WorkerWebUI) extends 
WebUIPage("logPage") with
       </span>
 
     val moreButton =
-      <button type="button" onclick={"loadMore()"} class="log-more-btn btn 
btn-secondary">
+      <button type="button" class="log-more-btn btn btn-secondary">
         Load More
       </button>
 
     val newButton =
-      <button type="button" onclick={"loadNew()"} class="log-new-btn btn 
btn-secondary">
+      <button type="button" class="log-new-btn btn btn-secondary">
         Load New
       </button>
 
@@ -120,7 +120,7 @@ private[ui] class LogPage(parent: WorkerWebUI) extends 
WebUIPage("logPage") with
           {alert}
           <div>{newButton}</div>
         </div>
-        <script>{Unparsed(jsOnload)}</script>
+        <script nonce={CspNonce.get}>{Unparsed(jsOnload)}</script>
       </div>
 
     UIUtils.basicSparkPage(request, content, logType + " log page for " + 
pageName)
diff --git 
a/core/src/main/scala/org/apache/spark/deploy/worker/ui/WorkerPage.scala 
b/core/src/main/scala/org/apache/spark/deploy/worker/ui/WorkerPage.scala
index bfc402811451..bf55a588d548 100644
--- a/core/src/main/scala/org/apache/spark/deploy/worker/ui/WorkerPage.scala
+++ b/core/src/main/scala/org/apache/spark/deploy/worker/ui/WorkerPage.scala
@@ -101,8 +101,8 @@ private[ui] class WorkerPage(parent: WorkerWebUI) extends 
WebUIPage("") {
       <div class="row"> <!-- Executors and Drivers -->
         <div class="col-12">
           <span class="collapse-aggregated-runningExecutors collapse-table"
-              onClick="collapseTable('collapse-aggregated-runningExecutors',
-              'aggregated-runningExecutors')">
+              data-collapse-name="collapse-aggregated-runningExecutors"
+              data-collapse-table="aggregated-runningExecutors">
             <h4>
               <span class="collapse-table-arrow arrow-open"></span>
               <a>Running Executors ({runningExecutors.size})</a>
@@ -114,8 +114,8 @@ private[ui] class WorkerPage(parent: WorkerWebUI) extends 
WebUIPage("") {
           {
             if (runningDrivers.nonEmpty) {
               <span class="collapse-aggregated-runningDrivers collapse-table"
-                  onClick="collapseTable('collapse-aggregated-runningDrivers',
-                  'aggregated-runningDrivers')">
+                  data-collapse-name="collapse-aggregated-runningDrivers"
+                  data-collapse-table="aggregated-runningDrivers">
                 <h4>
                   <span class="collapse-table-arrow arrow-open"></span>
                   <a>Running Drivers ({runningDrivers.size})</a>
@@ -129,8 +129,8 @@ private[ui] class WorkerPage(parent: WorkerWebUI) extends 
WebUIPage("") {
           {
             if (finishedExecutors.nonEmpty) {
               <span class="collapse-aggregated-finishedExecutors 
collapse-table"
-                  
onClick="collapseTable('collapse-aggregated-finishedExecutors',
-                  'aggregated-finishedExecutors')">
+                  data-collapse-name="collapse-aggregated-finishedExecutors"
+                  data-collapse-table="aggregated-finishedExecutors">
                 <h4>
                   <span class="collapse-table-arrow arrow-open"></span>
                   <a>Finished Executors ({finishedExecutors.size})</a>
@@ -144,8 +144,8 @@ private[ui] class WorkerPage(parent: WorkerWebUI) extends 
WebUIPage("") {
           {
             if (finishedDrivers.nonEmpty) {
               <span class="collapse-aggregated-finishedDrivers collapse-table"
-                  onClick="collapseTable('collapse-aggregated-finishedDrivers',
-                  'aggregated-finishedDrivers')">
+                  data-collapse-name="collapse-aggregated-finishedDrivers"
+                  data-collapse-table="aggregated-finishedDrivers">
                 <h4>
                   <span class="collapse-table-arrow arrow-open"></span>
                   <a>Finished Drivers ({finishedDrivers.size})</a>
diff --git a/core/src/main/scala/org/apache/spark/ui/CspNonce.scala 
b/core/src/main/scala/org/apache/spark/ui/CspNonce.scala
new file mode 100644
index 000000000000..97d8b811bbd5
--- /dev/null
+++ b/core/src/main/scala/org/apache/spark/ui/CspNonce.scala
@@ -0,0 +1,44 @@
+/*
+ * 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.ui
+
+import java.util.UUID
+
+/**
+ * Thread-local storage for CSP nonce values.
+ *
+ * A nonce is generated per request in [[HttpSecurityFilter]] and used by UI 
pages
+ * to mark inline scripts and styles as trusted.
+ */
+private[spark] object CspNonce {
+
+  private val nonce = new ThreadLocal[String]
+
+  /** Generate a new nonce and store it in the current thread. */
+  def generate(): String = {
+    val value = UUID.randomUUID().toString
+    nonce.set(value)
+    value
+  }
+
+  /** Get the nonce for the current request. */
+  def get: String = nonce.get()
+
+  /** Remove the nonce after the request is complete. */
+  def clear(): Unit = nonce.remove()
+}
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 dca85b53178a..294e9491c04d 100644
--- a/core/src/main/scala/org/apache/spark/ui/DriverLogPage.scala
+++ b/core/src/main/scala/org/apache/spark/ui/DriverLogPage.scala
@@ -56,12 +56,12 @@ private[ui] class DriverLogPage(
       </span>
 
     val moreButton =
-      <button type="button" onclick={"loadMore()"} class="log-more-btn btn 
btn-secondary">
+      <button type="button" class="log-more-btn btn btn-secondary">
         Load More
       </button>
 
     val newButton =
-      <button type="button" onclick={"loadNew()"} class="log-new-btn btn 
btn-secondary">
+      <button type="button" class="log-new-btn btn btn-secondary">
         Load New
       </button>
 
@@ -85,7 +85,7 @@ private[ui] class DriverLogPage(
           {alert}
           <div>{newButton}</div>
         </div>
-        <script>{Unparsed(jsOnload)}</script>
+        <script nonce={CspNonce.get}>{Unparsed(jsOnload)}</script>
       </div>
 
     UIUtils.headerSparkPage(request, "Logs", content, parent)
diff --git a/core/src/main/scala/org/apache/spark/ui/GraphUIData.scala 
b/core/src/main/scala/org/apache/spark/ui/GraphUIData.scala
index 9dcbb9d3c329..215f0f047269 100644
--- a/core/src/main/scala/org/apache/spark/ui/GraphUIData.scala
+++ b/core/src/main/scala/org/apache/spark/ui/GraphUIData.scala
@@ -177,6 +177,6 @@ private[spark] class JsCollector(req: HttpServletRequest) {
          |    ${statements.mkString("\n")}
          |});""".stripMargin
 
-    <script type="module">{Unparsed(js)}</script>
+    <script type="module" nonce={CspNonce.get}>{Unparsed(js)}</script>
   }
 }
diff --git a/core/src/main/scala/org/apache/spark/ui/HttpSecurityFilter.scala 
b/core/src/main/scala/org/apache/spark/ui/HttpSecurityFilter.scala
index 2c5a74d2e6c6..a7e0bb683dd0 100644
--- a/core/src/main/scala/org/apache/spark/ui/HttpSecurityFilter.scala
+++ b/core/src/main/scala/org/apache/spark/ui/HttpSecurityFilter.scala
@@ -48,48 +48,57 @@ private class HttpSecurityFilter(
     val hreq = req.asInstanceOf[HttpServletRequest]
     val hres = res.asInstanceOf[HttpServletResponse]
     hres.setHeader("Cache-Control", "no-cache, no-store, must-revalidate")
-    hres.setHeader("Content-Security-Policy", "default-src 'self'")
-
-    val requestUser = hreq.getRemoteUser()
-
-    // The doAs parameter allows proxy servers (e.g. Knox) to impersonate 
other users. For
-    // that to be allowed, the authenticated user needs to be an admin.
-    val effectiveUser = Option(hreq.getParameter("doAs"))
-      .map { proxy =>
-        if (requestUser != proxy && 
!securityMgr.checkAdminPermissions(requestUser)) {
-          hres.sendError(HttpServletResponse.SC_FORBIDDEN,
-            s"User $requestUser is not allowed to impersonate others.")
-          return
+
+    val cspNonce = CspNonce.generate()
+    try {
+      hres.setHeader("Content-Security-Policy",
+        s"default-src 'self'; script-src 'self' 'nonce-$cspNonce'; " +
+        s"style-src 'self' 'unsafe-inline'; img-src 'self' data:; " +
+        s"object-src 'none'; base-uri 'self';")
+
+      val requestUser = hreq.getRemoteUser()
+
+      // The doAs parameter allows proxy servers (e.g. Knox) to impersonate 
other users. For
+      // that to be allowed, the authenticated user needs to be an admin.
+      val effectiveUser = Option(hreq.getParameter("doAs"))
+        .map { proxy =>
+          if (requestUser != proxy && 
!securityMgr.checkAdminPermissions(requestUser)) {
+            hres.sendError(HttpServletResponse.SC_FORBIDDEN,
+              s"User $requestUser is not allowed to impersonate others.")
+            return
+          }
+          proxy
         }
-        proxy
+        .getOrElse(requestUser)
+
+      if (!securityMgr.checkUIViewPermissions(effectiveUser)) {
+        hres.sendError(HttpServletResponse.SC_FORBIDDEN,
+          s"User $effectiveUser is not authorized to access this page.")
+        return
       }
-      .getOrElse(requestUser)
 
-    if (!securityMgr.checkUIViewPermissions(effectiveUser)) {
-      hres.sendError(HttpServletResponse.SC_FORBIDDEN,
-        s"User $effectiveUser is not authorized to access this page.")
-      return
-    }
+      // SPARK-10589 avoid frame-related click-jacking vulnerability, using 
X-Frame-Options
+      // (see http://tools.ietf.org/html/rfc7034). By default allow framing 
only from the
+      // same origin, but allow framing for a specific named URI.
+      // Example: spark.ui.allowFramingFrom = https://example.com/
+      val xFrameOptionsValue = conf.getOption("spark.ui.allowFramingFrom")
+        .map { uri => s"ALLOW-FROM $uri" }
+        .getOrElse("SAMEORIGIN")
+
+      hres.setHeader("X-Frame-Options", xFrameOptionsValue)
+      hres.setHeader("X-XSS-Protection", conf.get(UI_X_XSS_PROTECTION))
+      if (conf.get(UI_X_CONTENT_TYPE_OPTIONS)) {
+        hres.setHeader("X-Content-Type-Options", "nosniff")
+      }
+      if (hreq.getScheme() == "https") {
+        conf.get(UI_STRICT_TRANSPORT_SECURITY).foreach(
+          hres.setHeader("Strict-Transport-Security", _))
+      }
 
-    // SPARK-10589 avoid frame-related click-jacking vulnerability, using 
X-Frame-Options
-    // (see http://tools.ietf.org/html/rfc7034). By default allow framing only 
from the
-    // same origin, but allow framing for a specific named URI.
-    // Example: spark.ui.allowFramingFrom = https://example.com/
-    val xFrameOptionsValue = conf.getOption("spark.ui.allowFramingFrom")
-      .map { uri => s"ALLOW-FROM $uri" }
-      .getOrElse("SAMEORIGIN")
-
-    hres.setHeader("X-Frame-Options", xFrameOptionsValue)
-    hres.setHeader("X-XSS-Protection", conf.get(UI_X_XSS_PROTECTION))
-    if (conf.get(UI_X_CONTENT_TYPE_OPTIONS)) {
-      hres.setHeader("X-Content-Type-Options", "nosniff")
-    }
-    if (hreq.getScheme() == "https") {
-      conf.get(UI_STRICT_TRANSPORT_SECURITY).foreach(
-        hres.setHeader("Strict-Transport-Security", _))
+      chain.doFilter(new XssSafeRequest(hreq, effectiveUser), res)
+    } finally {
+      CspNonce.clear()
     }
-
-    chain.doFilter(new XssSafeRequest(hreq, effectiveUser), res)
   }
 
 }
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 35b00daefdb5..e84adbe710cb 100644
--- a/core/src/main/scala/org/apache/spark/ui/UIUtils.scala
+++ b/core/src/main/scala/org/apache/spark/ui/UIUtils.scala
@@ -232,7 +232,7 @@ private[spark] object UIUtils extends Logging {
     <script src={prependBaseUri(request, "/static/log-view.js")}></script>
     <script src={prependBaseUri(request, "/static/webui.js")}></script>
     <script src={prependBaseUri(request, "/static/scroll-button.js")} 
type="module"></script>
-    <script>setUIRoot('{UIUtils.uiRoot(request)}')</script>
+    <script 
nonce={CspNonce.get}>setUIRoot('{UIUtils.uiRoot(request)}')</script>
   }
 
   def vizHeaderNodes(request: HttpServletRequest): Seq[Node] = {
@@ -283,7 +283,7 @@ private[spark] object UIUtils extends Logging {
     <html>
       <head>
         {commonHeaderNodes(request)}
-        <script>setAppBasePath('{activeTab.basePath}')</script>
+        <script 
nonce={CspNonce.get}>setAppBasePath('{activeTab.basePath}')</script>
         {if (showVisualization) vizHeaderNodes(request) else Seq.empty}
         {if (useDataTables) dataTablesHeaderNodes(request) else Seq.empty}
         <link rel="shortcut icon"
@@ -507,7 +507,7 @@ private[spark] object UIUtils extends Logging {
       graphs: collection.Seq[RDDOperationGraph], forJob: Boolean): 
collection.Seq[Node] = {
     <div>
       <span id={if (forJob) "job-dag-viz" else "stage-dag-viz"}
-            class="expand-dag-viz" onclick={s"toggleDagViz($forJob);"}>
+            class="expand-dag-viz" data-forjob={forJob.toString}>
         <span class="expand-dag-viz-arrow arrow-closed"></span>
         <a data-toggle="tooltip" title={if (forJob) ToolTips.JOB_DAG else 
ToolTips.STAGE_DAG}
            data-placement="top">
@@ -703,7 +703,7 @@ private[spark] object UIUtils extends Logging {
   def detailsUINode(isMultiline: Boolean, message: String): Seq[Node] = {
     if (isMultiline) {
       // scalastyle:off
-      <span 
onclick="this.parentNode.querySelector('.stacktrace-details').classList.toggle('collapsed')"
+      <span data-toggle-details=".stacktrace-details"
             class="expand-details">
         +details
       </span> ++
diff --git a/core/src/main/scala/org/apache/spark/ui/env/EnvironmentPage.scala 
b/core/src/main/scala/org/apache/spark/ui/env/EnvironmentPage.scala
index df504e4cc8ef..7994f6983ba6 100644
--- a/core/src/main/scala/org/apache/spark/ui/env/EnvironmentPage.scala
+++ b/core/src/main/scala/org/apache/spark/ui/env/EnvironmentPage.scala
@@ -93,8 +93,8 @@ private[ui] class EnvironmentPage(
     val content =
       <span>
         <span class="collapse-aggregated-runtimeInformation collapse-table"
-            onClick="collapseTable('collapse-aggregated-runtimeInformation',
-            'aggregated-runtimeInformation')">
+            data-collapse-name="collapse-aggregated-runtimeInformation"
+            data-collapse-table="aggregated-runtimeInformation">
           <h4>
             <span class="collapse-table-arrow arrow-open"></span>
             <a>Runtime Information</a>
@@ -104,8 +104,8 @@ private[ui] class EnvironmentPage(
           {runtimeInformationTable}
         </div>
         <span class="collapse-aggregated-sparkProperties collapse-table"
-            onClick="collapseTable('collapse-aggregated-sparkProperties',
-            'aggregated-sparkProperties')">
+            data-collapse-name="collapse-aggregated-sparkProperties"
+            data-collapse-table="aggregated-sparkProperties">
           <h4>
             <span class="collapse-table-arrow arrow-open"></span>
             <a>Spark Properties</a>
@@ -115,8 +115,8 @@ private[ui] class EnvironmentPage(
           {sparkPropertiesTable}
         </div>
         <span class="collapse-aggregated-execResourceProfileInformation 
collapse-table"
-              
onClick="collapseTable('collapse-aggregated-execResourceProfileInformation',
-            'aggregated-execResourceProfileInformation')">
+              
data-collapse-name="collapse-aggregated-execResourceProfileInformation"
+              data-collapse-table="aggregated-execResourceProfileInformation">
           <h4>
             <span class="collapse-table-arrow arrow-open"></span>
             <a>Resource Profiles</a>
@@ -126,8 +126,8 @@ private[ui] class EnvironmentPage(
           {resourceProfileInformationTable}
         </div>
         <span class="collapse-aggregated-hadoopProperties collapse-table"
-              onClick="collapseTable('collapse-aggregated-hadoopProperties',
-            'aggregated-hadoopProperties')">
+              data-collapse-name="collapse-aggregated-hadoopProperties"
+              data-collapse-table="aggregated-hadoopProperties">
           <h4>
             <span class="collapse-table-arrow arrow-closed"></span>
             <a>Hadoop Properties</a>
@@ -137,8 +137,8 @@ private[ui] class EnvironmentPage(
           {hadoopPropertiesTable}
         </div>
         <span class="collapse-aggregated-systemProperties collapse-table"
-            onClick="collapseTable('collapse-aggregated-systemProperties',
-            'aggregated-systemProperties')">
+            data-collapse-name="collapse-aggregated-systemProperties"
+            data-collapse-table="aggregated-systemProperties">
           <h4>
             <span class="collapse-table-arrow arrow-closed"></span>
             <a>System Properties</a>
@@ -148,8 +148,8 @@ private[ui] class EnvironmentPage(
           {systemPropertiesTable}
         </div>
         <span class="collapse-aggregated-metricsProperties collapse-table"
-              onClick="collapseTable('collapse-aggregated-metricsProperties',
-            'aggregated-metricsProperties')">
+              data-collapse-name="collapse-aggregated-metricsProperties"
+              data-collapse-table="aggregated-metricsProperties">
           <h4>
             <span class="collapse-table-arrow arrow-closed"></span>
             <a>Metrics Properties</a>
@@ -159,8 +159,8 @@ private[ui] class EnvironmentPage(
           {metricsPropertiesTable}
         </div>
         <span class="collapse-aggregated-classpathEntries collapse-table"
-            onClick="collapseTable('collapse-aggregated-classpathEntries',
-            'aggregated-classpathEntries')">
+            data-collapse-name="collapse-aggregated-classpathEntries"
+            data-collapse-table="aggregated-classpathEntries">
           <h4>
             <span class="collapse-table-arrow arrow-closed"></span>
             <a>Classpath Entries</a>
diff --git 
a/core/src/main/scala/org/apache/spark/ui/exec/ExecutorThreadDumpPage.scala 
b/core/src/main/scala/org/apache/spark/ui/exec/ExecutorThreadDumpPage.scala
index fa10c8937144..000a88c0ac53 100644
--- a/core/src/main/scala/org/apache/spark/ui/exec/ExecutorThreadDumpPage.scala
+++ b/core/src/main/scala/org/apache/spark/ui/exec/ExecutorThreadDumpPage.scala
@@ -24,7 +24,7 @@ import jakarta.servlet.http.HttpServletRequest
 import org.apache.spark.SparkContext
 import org.apache.spark.internal.config.UI.UI_FLAMEGRAPH_ENABLED
 import org.apache.spark.status.api.v1.ThreadStackTrace
-import org.apache.spark.ui.{SparkUITab, UIUtils, WebUIPage}
+import org.apache.spark.ui.{CspNonce, SparkUITab, UIUtils, WebUIPage}
 import org.apache.spark.ui.UIUtils.{formatImportJavaScript, prependBaseUri}
 import org.apache.spark.ui.flamegraph.FlamegraphNode
 
@@ -59,9 +59,7 @@ private[ui] class ExecutorThreadDumpPage(
         val heldLocks = (synchronizers ++ monitors).mkString(", ")
 
         <tr id={s"thread_${threadId}_tr"} class="accordion-heading"
-            onclick={s"toggleThreadStackTrace($threadId, false)"}
-            onmouseover={s"onMouseOverAndOut($threadId)"}
-            onmouseout={s"onMouseOverAndOut($threadId)"}>
+            data-thread-id={threadId.toString}>
           <td id={s"${threadId}_td_id"}>{threadId}</td>
           <td id={s"${threadId}_td_name"}>{thread.threadName}</td>
           <td id={s"${threadId}_td_state"}>{thread.threadState}</td>
@@ -83,22 +81,25 @@ private[ui] class ExecutorThreadDumpPage(
         {
           // scalastyle:off
           <p></p>
-          <span class="collapse-thead-stack-trace-table collapse-table" 
onClick="collapseTableAndButton('collapse-thead-stack-trace-table', 
'thead-stack-trace-table')">
+          <span class="collapse-thead-stack-trace-table collapse-table"
+                data-collapse-name="collapse-thead-stack-trace-table"
+                data-collapse-table="thead-stack-trace-table"
+                data-collapse-button="true">
             <h4>
               <span class="collapse-table-arrow arrow-open"></span>
               <a>Thread Stack Trace</a>
             </h4>
           </span>
           <div class="thead-stack-trace-table-button" style="display: flex; 
align-items: center;">
-            <a class="expandbutton" 
onClick="expandAllThreadStackTrace(true)">Expand All</a>
-            <a class="expandbutton d-none" 
onClick="collapseAllThreadStackTrace(true)">Collapse All</a>
+            <a class="expandbutton" 
data-action="expandAllThreadStackTrace">Expand All</a>
+            <a class="expandbutton d-none" 
data-action="collapseAllThreadStackTrace">Collapse All</a>
             <a class="downloadbutton" href={"data:text/plain;charset=utf-8," + 
threadDump.map(_.toString).mkString} download={"threaddump_" + executorId + 
".txt"}>Download</a>
             <div class="form-inline">
               <div class="bs-example" data-example-id="simple-form-inline">
                 <div class="form-group">
                   <div class="input-group">
                     <label class="mr-2" for="search">Search:</label>
-                    <input type="text" class="form-control" id="search" 
oninput="onSearchStringChange()"></input>
+                    <input type="text" class="form-control" id="search" 
data-search-input="true"></input>
                   </div>
                 </div>
               </div>
@@ -108,10 +109,10 @@ private[ui] class ExecutorThreadDumpPage(
         }
         <table class={UIUtils.TABLE_CLASS_STRIPED + " accordion-group" + " 
sortable" + " thead-stack-trace-table collapsible-table"}>
           <thead>
-            <th onClick="collapseAllThreadStackTrace(false)">Thread ID</th>
-            <th onClick="collapseAllThreadStackTrace(false)">Thread Name</th>
-            <th onClick="collapseAllThreadStackTrace(false)">Thread State</th>
-            <th onClick="collapseAllThreadStackTrace(false)">
+            <th data-action="collapseAllThreadStackTrace" 
data-toggle-button="false">Thread ID</th>
+            <th data-action="collapseAllThreadStackTrace" 
data-toggle-button="false">Thread Name</th>
+            <th data-action="collapseAllThreadStackTrace" 
data-toggle-button="false">Thread State</th>
+            <th data-action="collapseAllThreadStackTrace" 
data-toggle-button="false">
               <span data-toggle="tooltip" data-placement="top"
                     title="Objects whose lock the thread currently holds">
                 Thread Locks
@@ -149,7 +150,7 @@ private[ui] class ExecutorThreadDumpPage(
         <script src={UIUtils.prependBaseUri(request, 
"/static/d3.min.js")}></script>
         <script src={UIUtils.prependBaseUri(request, 
"/static/d3-flamegraph.min.js")}></script>
         <script type="module" src={UIUtils.prependBaseUri(request, 
"/static/flamegraph.js")}></script>
-        <script type="module">{Unparsed(js)}</script>
+        <script type="module" nonce={CspNonce.get}>{Unparsed(js)}</script>
       </div>
     </div>
   }
@@ -158,7 +159,9 @@ private[ui] class ExecutorThreadDumpPage(
   private def threadDumpSummary(threadDump: Array[ThreadStackTrace]): 
Seq[Node] = {
     val totalCount = threadDump.length
     <div>
-      <span class="thead-dump-summary collapse-table" 
onClick="collapseTable('thead-dump-summary', 'thread-dump-summary-table')">
+      <span class="thead-dump-summary collapse-table"
+            data-collapse-name="thead-dump-summary"
+            data-collapse-table="thread-dump-summary-table">
         <h4>
           <span class="collapse-table-arrow arrow-open"></span>
           <a>Thread Dump Summary: { totalCount }</a>
diff --git a/core/src/main/scala/org/apache/spark/ui/exec/ExecutorsTab.scala 
b/core/src/main/scala/org/apache/spark/ui/exec/ExecutorsTab.scala
index db18b02f23d8..5b09fc6eb04a 100644
--- a/core/src/main/scala/org/apache/spark/ui/exec/ExecutorsTab.scala
+++ b/core/src/main/scala/org/apache/spark/ui/exec/ExecutorsTab.scala
@@ -22,7 +22,7 @@ import scala.xml.{Node, Unparsed}
 import jakarta.servlet.http.HttpServletRequest
 
 import org.apache.spark.internal.config.UI._
-import org.apache.spark.ui.{SparkUI, SparkUITab, UIUtils, WebUIPage}
+import org.apache.spark.ui.{CspNonce, SparkUI, SparkUITab, UIUtils, WebUIPage}
 
 private[ui] class ExecutorsTab(parent: SparkUI) extends SparkUITab(parent, 
"executors") {
 
@@ -70,7 +70,7 @@ private[ui] class ExecutorsPage(
         <script type="module" src={UIUtils.prependBaseUri(request, 
"/static/utils.js")}></script> ++
         <script type="module"
                 src={UIUtils.prependBaseUri(request, 
"/static/executorspage.js")}></script> ++
-        <script type="module">{Unparsed(js)}</script>
+        <script type="module" nonce={CspNonce.get}>{Unparsed(js)}</script>
       }
 
     UIUtils.headerSparkPage(request, "Executors", content, parent, 
useDataTables = true)
diff --git a/core/src/main/scala/org/apache/spark/ui/jobs/AllJobsPage.scala 
b/core/src/main/scala/org/apache/spark/ui/jobs/AllJobsPage.scala
index 4d65aba31021..a1a9fff53f64 100644
--- a/core/src/main/scala/org/apache/spark/ui/jobs/AllJobsPage.scala
+++ b/core/src/main/scala/org/apache/spark/ui/jobs/AllJobsPage.scala
@@ -235,7 +235,7 @@ private[ui] class AllJobsPage(parent: JobsTab, store: 
AppStatusStore) extends We
         </div>
       </div>
     </div> ++
-    <script type="text/javascript">
+    <script type="text/javascript" nonce={CspNonce.get}>
       {Unparsed(s"drawApplicationTimeline(${groupJsonArrayAsStr}," +
       s"${eventArrayAsStr}, ${startTime}, ${UIUtils.getTimeZoneOffset()});")}
     </script>
@@ -380,7 +380,8 @@ private[ui] class AllJobsPage(parent: JobsTab, store: 
AppStatusStore) extends We
     if (shouldShowActiveJobs) {
       content ++=
         <span id="active" class="collapse-aggregated-activeJobs collapse-table"
-            
onClick="collapseTable('collapse-aggregated-activeJobs','aggregated-activeJobs')">
+            data-collapse-name="collapse-aggregated-activeJobs"
+            data-collapse-table="aggregated-activeJobs">
           <h4>
             <span class="collapse-table-arrow arrow-open"></span>
             <a>Active Jobs ({activeJobs.size})</a>
@@ -393,7 +394,8 @@ private[ui] class AllJobsPage(parent: JobsTab, store: 
AppStatusStore) extends We
     if (shouldShowCompletedJobs) {
       content ++=
         <span id="completed" class="collapse-aggregated-completedJobs 
collapse-table"
-            
onClick="collapseTable('collapse-aggregated-completedJobs','aggregated-completedJobs')">
+            data-collapse-name="collapse-aggregated-completedJobs"
+            data-collapse-table="aggregated-completedJobs">
           <h4>
             <span class="collapse-table-arrow arrow-open"></span>
             <a>Completed Jobs ({completedJobNumStr})</a>
@@ -406,7 +408,8 @@ private[ui] class AllJobsPage(parent: JobsTab, store: 
AppStatusStore) extends We
     if (shouldShowFailedJobs) {
       content ++=
         <span id ="failed" class="collapse-aggregated-failedJobs 
collapse-table"
-            
onClick="collapseTable('collapse-aggregated-failedJobs','aggregated-failedJobs')">
+            data-collapse-name="collapse-aggregated-failedJobs"
+            data-collapse-table="aggregated-failedJobs">
           <h4>
             <span class="collapse-table-arrow arrow-open"></span>
             <a>Failed Jobs ({failedJobs.size})</a>
@@ -573,19 +576,11 @@ private[ui] class JobPagedTable(
     val job = jobTableRow.jobData
 
     val killLink = if (killEnabled) {
-      val confirm =
-        s"if (window.confirm('Are you sure you want to kill job ${job.jobId} 
?')) " +
-          "{ this.parentNode.submit(); return true; } else { return false; }"
       // SPARK-6846 this should be POST-only but YARN AM won't proxy POST
-      /*
-      val killLinkUri = s"$basePathUri/jobs/job/kill/"
-      <form action={killLinkUri} method="POST" style="display:inline">
-        <input type="hidden" name="id" value={job.jobId.toString}/>
-        <a href="#" onclick={confirm} class="kill-link">(kill)</a>
-      </form>
-       */
       val killLinkUri = s"$basePath/jobs/job/kill/?id=${job.jobId}"
-      <a href={killLinkUri} onclick={confirm} class="kill-link">(kill)</a>
+      <a href={killLinkUri}
+         data-kill-message={s"Are you sure you want to kill job ${job.jobId} 
?"}
+         class="kill-link">(kill)</a>
     } else {
       Seq.empty
     }
diff --git a/core/src/main/scala/org/apache/spark/ui/jobs/AllStagesPage.scala 
b/core/src/main/scala/org/apache/spark/ui/jobs/AllStagesPage.scala
index ae8337179627..165358fd9864 100644
--- a/core/src/main/scala/org/apache/spark/ui/jobs/AllStagesPage.scala
+++ b/core/src/main/scala/org/apache/spark/ui/jobs/AllStagesPage.scala
@@ -58,7 +58,8 @@ private[ui] class AllStagesPage(parent: StagesTab) extends 
WebUIPage("") {
 
     val poolsDescription = if (parent.isFairScheduler) {
         <span class="collapse-aggregated-poolTable collapse-table"
-            
onClick="collapseTable('collapse-aggregated-poolTable','aggregated-poolTable')">
+            data-collapse-name="collapse-aggregated-poolTable"
+            data-collapse-table="aggregated-poolTable">
           <h4>
             <span class="collapse-table-arrow arrow-open"></span>
             <a>Fair Scheduler Pools ({pools.size})</a>
@@ -146,8 +147,8 @@ private[ui] class AllStagesPage(parent: StagesTab) extends 
WebUIPage("") {
     val classSuffix = s"${statusName(status).capitalize}Stages"
     <span id={statusName(status)}
           class={s"collapse-aggregated-all$classSuffix collapse-table"}
-          onClick={s"collapseTable('collapse-aggregated-all$classSuffix'," +
-            s" 'aggregated-all$classSuffix')"}>
+          data-collapse-name={s"collapse-aggregated-all$classSuffix"}
+          data-collapse-table={s"aggregated-all$classSuffix"}>
       <h4>
         <span class="collapse-table-arrow arrow-open"></span>
         <a>{headerDescription(status)} Stages ({summaryContent(appSummary, 
status, size)})</a>
diff --git a/core/src/main/scala/org/apache/spark/ui/jobs/JobPage.scala 
b/core/src/main/scala/org/apache/spark/ui/jobs/JobPage.scala
index 793e65f44ba9..2f5e3a8e098b 100644
--- a/core/src/main/scala/org/apache/spark/ui/jobs/JobPage.scala
+++ b/core/src/main/scala/org/apache/spark/ui/jobs/JobPage.scala
@@ -215,7 +215,7 @@ private[ui] class JobPage(parent: JobsTab, store: 
AppStatusStore) extends WebUIP
         </div>
       </div>
     </div> ++
-    <script type="text/javascript">
+    <script type="text/javascript" nonce={CspNonce.get}>
       {Unparsed(s"drawJobTimeline(${groupJsonArrayAsStr}, ${eventArrayAsStr}, 
" +
       s"${appStartTime}, ${UIUtils.getTimeZoneOffset()});")}
     </script>
@@ -459,7 +459,8 @@ private[ui] class JobPage(parent: JobsTab, store: 
AppStatusStore) extends WebUIP
     if (shouldShowActiveStages) {
       content ++=
         <span id="active" class="collapse-aggregated-activeStages 
collapse-table"
-            
onClick="collapseTable('collapse-aggregated-activeStages','aggregated-activeStages')">
+            data-collapse-name="collapse-aggregated-activeStages"
+            data-collapse-table="aggregated-activeStages">
           <h4>
             <span class="collapse-table-arrow arrow-open"></span>
             <a>Active Stages ({activeStages.size})</a>
@@ -472,8 +473,8 @@ private[ui] class JobPage(parent: JobsTab, store: 
AppStatusStore) extends WebUIP
     if (shouldShowPendingStages) {
       content ++=
         <span id="pending" class="collapse-aggregated-pendingOrSkippedStages 
collapse-table"
-            
onClick="collapseTable('collapse-aggregated-pendingOrSkippedStages',
-            'aggregated-pendingOrSkippedStages')">
+            data-collapse-name="collapse-aggregated-pendingOrSkippedStages"
+            data-collapse-table="aggregated-pendingOrSkippedStages">
           <h4>
             <span class="collapse-table-arrow arrow-open"></span>
             <a>Pending Stages ({pendingOrSkippedStages.size})</a>
@@ -486,8 +487,8 @@ private[ui] class JobPage(parent: JobsTab, store: 
AppStatusStore) extends WebUIP
     if (shouldShowCompletedStages) {
       content ++=
         <span id="completed" class="collapse-aggregated-completedStages 
collapse-table"
-            onClick="collapseTable('collapse-aggregated-completedStages',
-            'aggregated-completedStages')">
+            data-collapse-name="collapse-aggregated-completedStages"
+            data-collapse-table="aggregated-completedStages">
           <h4>
             <span class="collapse-table-arrow arrow-open"></span>
             <a>Completed Stages ({completedStages.size})</a>
@@ -500,8 +501,8 @@ private[ui] class JobPage(parent: JobsTab, store: 
AppStatusStore) extends WebUIP
     if (shouldShowSkippedStages) {
       content ++=
         <span id="skipped" class="collapse-aggregated-pendingOrSkippedStages 
collapse-table"
-            
onClick="collapseTable('collapse-aggregated-pendingOrSkippedStages',
-            'aggregated-pendingOrSkippedStages')">
+            data-collapse-name="collapse-aggregated-pendingOrSkippedStages"
+            data-collapse-table="aggregated-pendingOrSkippedStages">
           <h4>
             <span class="collapse-table-arrow arrow-open"></span>
             <a>Skipped Stages ({pendingOrSkippedStages.size})</a>
@@ -514,7 +515,8 @@ private[ui] class JobPage(parent: JobsTab, store: 
AppStatusStore) extends WebUIP
     if (shouldShowFailedStages) {
       content ++=
         <span id ="failed" class="collapse-aggregated-failedStages 
collapse-table"
-            
onClick="collapseTable('collapse-aggregated-failedStages','aggregated-failedStages')">
+            data-collapse-name="collapse-aggregated-failedStages"
+            data-collapse-table="aggregated-failedStages">
           <h4>
             <span class="collapse-table-arrow arrow-open"></span>
             <a>Failed Stages ({failedStages.size})</a>
diff --git a/core/src/main/scala/org/apache/spark/ui/jobs/PoolPage.scala 
b/core/src/main/scala/org/apache/spark/ui/jobs/PoolPage.scala
index 15592adc57ce..85dbed0aa41a 100644
--- a/core/src/main/scala/org/apache/spark/ui/jobs/PoolPage.scala
+++ b/core/src/main/scala/org/apache/spark/ui/jobs/PoolPage.scala
@@ -51,8 +51,8 @@ private[ui] class PoolPage(parent: StagesTab) extends 
WebUIPage("pool") {
     if (activeStages.nonEmpty) {
       content ++=
         <span class="collapse-aggregated-poolActiveStages collapse-table"
-            onClick="collapseTable('collapse-aggregated-poolActiveStages',
-            'aggregated-poolActiveStages')">
+            data-collapse-name="collapse-aggregated-poolActiveStages"
+            data-collapse-table="aggregated-poolActiveStages">
           <h4>
             <span class="collapse-table-arrow arrow-open"></span>
             <a>Active Stages ({activeStages.size})</a>
diff --git a/core/src/main/scala/org/apache/spark/ui/jobs/StagePage.scala 
b/core/src/main/scala/org/apache/spark/ui/jobs/StagePage.scala
index 4fa7c8149690..9d246a414714 100644
--- a/core/src/main/scala/org/apache/spark/ui/jobs/StagePage.scala
+++ b/core/src/main/scala/org/apache/spark/ui/jobs/StagePage.scala
@@ -240,7 +240,7 @@ private[ui] class StagePage(parent: StagesTab, store: 
AppStatusStore) extends We
           <script type="module" src={UIUtils.prependBaseUri(request, 
"/static/utils.js")}></script>
           <script type="module"
                   src={UIUtils.prependBaseUri(request, 
"/static/stagepage.js")}></script>
-          <script type="module">{Unparsed(js)}</script>
+          <script type="module" nonce={CspNonce.get}>{Unparsed(js)}</script>
         </div>
         UIUtils.headerSparkPage(request, stageHeader, content, parent, 
showVisualization = true,
           useDataTables = true)
@@ -447,7 +447,7 @@ private[ui] class StagePage(parent: StagesTab, store: 
AppStatusStore) extends We
       </div>
       {TIMELINE_LEGEND}
     </div> ++
-    <script type="text/javascript">
+    <script type="text/javascript" nonce={CspNonce.get}>
       {Unparsed("drawTaskAssignmentTimeline(" +
       s"$groupArrayStr, $executorsArrayStr, $minLaunchTime, $maxFinishTime, " +
         s"${UIUtils.getTimeZoneOffset()})")}
diff --git a/core/src/main/scala/org/apache/spark/ui/jobs/StageTable.scala 
b/core/src/main/scala/org/apache/spark/ui/jobs/StageTable.scala
index 124e41ce56d8..e3064ed9a0cb 100644
--- a/core/src/main/scala/org/apache/spark/ui/jobs/StageTable.scala
+++ b/core/src/main/scala/org/apache/spark/ui/jobs/StageTable.scala
@@ -221,19 +221,11 @@ private[ui] class StagePagedTable(
     val basePathUri = UIUtils.prependBaseUri(request, basePath)
 
     val killLink = if (killEnabled) {
-      val confirm =
-        s"if (window.confirm('Are you sure you want to kill stage ${s.stageId} 
?')) " +
-        "{ this.parentNode.submit(); return true; } else { return false; }"
       // SPARK-6846 this should be POST-only but YARN AM won't proxy POST
-      /*
-      val killLinkUri = s"$basePathUri/stages/stage/kill/"
-      <form action={killLinkUri} method="POST" style="display:inline">
-        <input type="hidden" name="id" value={s.stageId.toString}/>
-        <a href="#" onclick={confirm} class="kill-link">(kill)</a>
-      </form>
-       */
       val killLinkUri = s"$basePathUri/stages/stage/kill/?id=${s.stageId}"
-      <a href={killLinkUri} onclick={confirm} class="kill-link">(kill)</a>
+      <a href={killLinkUri}
+         data-kill-message={s"Are you sure you want to kill stage ${s.stageId} 
?"}
+         class="kill-link">(kill)</a>
     } else {
       Seq.empty
     }
@@ -243,7 +235,7 @@ private[ui] class StagePagedTable(
 
     val cachedRddInfos = store.rddList().filter { rdd => 
s.rddIds.contains(rdd.id) }
     val details = if (s.details != null && s.details.nonEmpty) {
-      <span 
onclick="this.parentNode.querySelector('.stage-details').classList.toggle('collapsed')"
+      <span data-toggle-details=".stage-details"
             class="expand-details">
         +details
       </span> ++
diff --git a/core/src/main/scala/org/apache/spark/ui/storage/RDDPage.scala 
b/core/src/main/scala/org/apache/spark/ui/storage/RDDPage.scala
index d21ea3732f3b..ff11816835ec 100644
--- a/core/src/main/scala/org/apache/spark/ui/storage/RDDPage.scala
+++ b/core/src/main/scala/org/apache/spark/ui/storage/RDDPage.scala
@@ -65,7 +65,7 @@ private[ui] class RDDPage(parent: SparkUITab, store: 
AppStatusStore) extends Web
     }
 
     val jsForScrollingDownToBlockTable =
-      <script>
+      <script nonce={CspNonce.get}>
         {
           Unparsed {
             """
diff --git a/core/src/main/scala/org/apache/spark/ui/storage/StoragePage.scala 
b/core/src/main/scala/org/apache/spark/ui/storage/StoragePage.scala
index 4713dc5fc304..40d3a3693616 100644
--- a/core/src/main/scala/org/apache/spark/ui/storage/StoragePage.scala
+++ b/core/src/main/scala/org/apache/spark/ui/storage/StoragePage.scala
@@ -46,7 +46,8 @@ private[ui] class StoragePage(parent: SparkUITab, store: 
AppStatusStore) extends
     } else {
       <div>
         <span class="collapse-aggregated-rdds collapse-table"
-            
onClick="collapseTable('collapse-aggregated-rdds','aggregated-rdds')">
+            data-collapse-name="collapse-aggregated-rdds"
+            data-collapse-table="aggregated-rdds">
           <h4>
             <span class="collapse-table-arrow arrow-open"></span>
             <a>RDDs ({rdds.length})</a>
diff --git a/core/src/test/scala/org/apache/spark/ui/CspNonceSuite.scala 
b/core/src/test/scala/org/apache/spark/ui/CspNonceSuite.scala
new file mode 100644
index 000000000000..efe261c7d48d
--- /dev/null
+++ b/core/src/test/scala/org/apache/spark/ui/CspNonceSuite.scala
@@ -0,0 +1,84 @@
+/*
+ * 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.ui
+
+import java.util.concurrent.{CyclicBarrier, Executors}
+import java.util.concurrent.atomic.AtomicReference
+
+import org.apache.spark.SparkFunSuite
+
+class CspNonceSuite extends SparkFunSuite {
+
+  override def afterEach(): Unit = {
+    try {
+      CspNonce.clear()
+    } finally {
+      super.afterEach()
+    }
+  }
+
+  test("generate returns a non-empty string and get returns the same value") {
+    val nonce = CspNonce.generate()
+    assert(nonce != null && nonce.nonEmpty)
+    assert(CspNonce.get === nonce)
+  }
+
+  test("generate produces different values on each call") {
+    val nonce1 = CspNonce.generate()
+    val nonce2 = CspNonce.generate()
+    assert(nonce1 !== nonce2)
+    // get returns the latest value
+    assert(CspNonce.get === nonce2)
+  }
+
+  test("clear removes the nonce") {
+    CspNonce.generate()
+    assert(CspNonce.get != null)
+    CspNonce.clear()
+    assert(CspNonce.get === null)
+  }
+
+  test("nonce is thread-local (isolated between threads)") {
+    val barrier = new CyclicBarrier(2)
+    val nonceFromThread = new AtomicReference[String]()
+
+    CspNonce.generate()
+    val mainNonce = CspNonce.get
+
+    val executor = Executors.newSingleThreadExecutor()
+    try {
+      executor.submit(new Runnable {
+        override def run(): Unit = {
+          // Before generate, should be null (no nonce set in this thread)
+          assert(CspNonce.get === null)
+          val threadNonce = CspNonce.generate()
+          nonceFromThread.set(threadNonce)
+          barrier.await()
+        }
+      })
+      barrier.await()
+
+      // Main thread's nonce should be unchanged
+      assert(CspNonce.get === mainNonce)
+      // Other thread's nonce should be different
+      assert(nonceFromThread.get() !== mainNonce)
+    } finally {
+      executor.shutdown()
+    }
+  }
+}
diff --git 
a/core/src/test/scala/org/apache/spark/ui/HttpSecurityFilterSuite.scala 
b/core/src/test/scala/org/apache/spark/ui/HttpSecurityFilterSuite.scala
index 911ee053a23a..9965751c42cc 100644
--- a/core/src/test/scala/org/apache/spark/ui/HttpSecurityFilterSuite.scala
+++ b/core/src/test/scala/org/apache/spark/ui/HttpSecurityFilterSuite.scala
@@ -21,7 +21,7 @@ import java.util.UUID
 
 import scala.jdk.CollectionConverters._
 
-import jakarta.servlet.FilterChain
+import jakarta.servlet.{FilterChain, ServletRequest, ServletResponse}
 import jakarta.servlet.http.{HttpServletRequest, HttpServletResponse}
 import org.mockito.ArgumentCaptor
 import org.mockito.ArgumentMatchers.{any, eq => meq}
@@ -129,8 +129,17 @@ class HttpSecurityFilterSuite extends SparkFunSuite {
     val filter = new HttpSecurityFilter(conf, secMgr)
     filter.doFilter(req, res, chain)
 
+    // CSP header contains a dynamic nonce, so verify it matches the expected 
pattern
+    val cspCaptor = ArgumentCaptor.forClass(classOf[String])
+    verify(res).setHeader(meq("Content-Security-Policy"), cspCaptor.capture())
+    val cspValue = cspCaptor.getValue
+    assert(cspValue.startsWith("default-src 'self'; script-src 'self' 
'nonce-"))
+    assert(cspValue.contains("style-src 'self' 'unsafe-inline'"))
+    assert(cspValue.contains("img-src 'self' data:"))
+    assert(cspValue.contains("object-src 'none'"))
+    assert(cspValue.contains("base-uri 'self'"))
+
     Map(
-      "Content-Security-Policy" -> "default-src 'self'",
       "X-Frame-Options" -> "ALLOW-FROM example.com",
       "X-XSS-Protection" -> "xssProtection",
       "X-Content-Type-Options" -> "nosniff",
@@ -176,6 +185,74 @@ class HttpSecurityFilterSuite extends SparkFunSuite {
     verify(res, times(2)).sendError(meq(HttpServletResponse.SC_FORBIDDEN), 
any())
   }
 
+  test("CSP nonce is available during chain.doFilter and cleared after") {
+    val conf = new SparkConf(false)
+    val secMgr = new SecurityManager(conf)
+    val req = mockRequest()
+    val res = mock(classOf[HttpServletResponse])
+
+    var nonceInsideChain: String = null
+    val chain = new FilterChain {
+      override def doFilter(req: ServletRequest, res: ServletResponse): Unit = 
{
+        nonceInsideChain = CspNonce.get
+      }
+    }
+
+    val filter = new HttpSecurityFilter(conf, secMgr)
+    filter.doFilter(req, res, chain)
+
+    // Nonce should have been available inside chain.doFilter
+    assert(nonceInsideChain != null && nonceInsideChain.nonEmpty)
+    // Nonce should be cleared after doFilter completes
+    assert(CspNonce.get === null)
+
+    // CSP header should contain the same nonce that was available inside the 
chain
+    val cspCaptor = ArgumentCaptor.forClass(classOf[String])
+    verify(res).setHeader(meq("Content-Security-Policy"), cspCaptor.capture())
+    assert(cspCaptor.getValue.contains(s"'nonce-$nonceInsideChain'"))
+  }
+
+  test("CSP nonce is cleared even when access is denied") {
+    val conf = new SparkConf(false)
+      .set(ACLS_ENABLE, true)
+      .set(UI_VIEW_ACLS, Seq("alice"))
+    val secMgr = new SecurityManager(conf)
+    val req = mockRequest()
+    val res = mock(classOf[HttpServletResponse])
+    val chain = mock(classOf[FilterChain])
+
+    when(req.getRemoteUser()).thenReturn("unauthorized-user")
+    val filter = new HttpSecurityFilter(conf, secMgr)
+    filter.doFilter(req, res, chain)
+
+    // chain.doFilter should not have been called
+    verify(chain, times(0)).doFilter(any(), any())
+    // Nonce should still be cleared
+    assert(CspNonce.get === null)
+  }
+
+  test("each request gets a unique CSP nonce") {
+    val conf = new SparkConf(false)
+    val secMgr = new SecurityManager(conf)
+    val filter = new HttpSecurityFilter(conf, secMgr)
+
+    val nonces = (1 to 3).map { _ =>
+      val req = mockRequest()
+      val res = mock(classOf[HttpServletResponse])
+      var nonce: String = null
+      val chain = new FilterChain {
+        override def doFilter(req: ServletRequest, res: ServletResponse): Unit 
= {
+          nonce = CspNonce.get
+        }
+      }
+      filter.doFilter(req, res, chain)
+      nonce
+    }
+
+    // All nonces should be unique
+    assert(nonces.distinct.size === 3)
+  }
+
   private def mockRequest(params: Map[String, Array[String]] = Map()): 
HttpServletRequest = {
     val req = mock(classOf[HttpServletRequest])
     when(req.getParameterMap()).thenReturn(params.asJava)
diff --git a/core/src/test/scala/org/apache/spark/ui/UIUtilsSuite.scala 
b/core/src/test/scala/org/apache/spark/ui/UIUtilsSuite.scala
index a677f19f5439..ec05eda8c128 100644
--- a/core/src/test/scala/org/apache/spark/ui/UIUtilsSuite.scala
+++ b/core/src/test/scala/org/apache/spark/ui/UIUtilsSuite.scala
@@ -225,4 +225,12 @@ class UIUtilsSuite extends SparkFunSuite {
     assert(cell4 === 
<td>{"java.lang.RuntimeException"}{UIUtils.detailsUINode(isMultiline = true, 
e4)}</td>)
   }
   // scalastyle:on line.size.limit
+
+  test("detailsUINode uses data-toggle-details instead of onclick") {
+    val result = UIUtils.detailsUINode(isMultiline = true, "error\nmessage")
+    val html = result.toString
+    assert(!html.contains("onclick"), "detailsUINode should not contain inline 
onclick handler")
+    assert(html.contains("data-toggle-details"), "detailsUINode should use 
data-toggle-details")
+    assert(html.contains("stacktrace-details"), "detailsUINode should contain 
stacktrace-details")
+  }
 }
diff --git 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/SparkConnectServerPage.scala
 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/SparkConnectServerPage.scala
index bf9293ef1488..9e9243fe9f72 100644
--- 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/SparkConnectServerPage.scala
+++ 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/SparkConnectServerPage.scala
@@ -105,8 +105,8 @@ private[ui] class SparkConnectServerPage(parent: 
SparkConnectServerTab)
     }
     val content =
       <span id="sqlstat" class="collapse-aggregated-sqlstat collapse-table"
-            onClick="collapseTable('collapse-aggregated-sqlstat',
-                'aggregated-sqlstat')">
+            data-collapse-name="collapse-aggregated-sqlstat"
+            data-collapse-table="aggregated-sqlstat">
         <h4>
           <span class="collapse-table-arrow arrow-open"></span>
           <a>Request Statistics ({numStatement})</a>
@@ -152,8 +152,8 @@ private[ui] class SparkConnectServerPage(parent: 
SparkConnectServerTab)
 
     val content =
       <span id="sessionstat" class="collapse-aggregated-sessionstat 
collapse-table"
-            onClick="collapseTable('collapse-aggregated-sessionstat',
-                'aggregated-sessionstat')">
+            data-collapse-name="collapse-aggregated-sessionstat"
+            data-collapse-table="aggregated-sessionstat">
         <h4>
           <span class="collapse-table-arrow arrow-open"></span>
           <a>Session Statistics ({numSessions})</a>
diff --git 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/SparkConnectServerSessionPage.scala
 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/SparkConnectServerSessionPage.scala
index 1f335c9ce005..ac360c8b3219 100644
--- 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/SparkConnectServerSessionPage.scala
+++ 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/ui/SparkConnectServerSessionPage.scala
@@ -115,8 +115,8 @@ private[ui] class SparkConnectServerSessionPage(parent: 
SparkConnectServerTab)
     }
     val content =
       <span id="sqlsessionstat" class="collapse-aggregated-sqlsessionstat 
collapse-table"
-            onClick="collapseTable('collapse-aggregated-sqlsessionstat',
-                'aggregated-sqlsessionstat')">
+            data-collapse-name="collapse-aggregated-sqlsessionstat"
+            data-collapse-table="aggregated-sqlsessionstat">
         <h4>
           <span class="collapse-table-arrow arrow-open"></span>
           <a>Request Statistics</a>
diff --git 
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/ui/SparkConnectServerPageSuite.scala
 
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/ui/SparkConnectServerPageSuite.scala
index 3fda38723025..7ae7a68be5f3 100644
--- 
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/ui/SparkConnectServerPageSuite.scala
+++ 
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/ui/SparkConnectServerPageSuite.scala
@@ -102,7 +102,7 @@ class SparkConnectServerPageSuite
     // Hiding table support
     assert(
       html.contains("class=\"collapse-aggregated-sessionstat" +
-        " collapse-table\" onclick=\"collapsetable"))
+        " collapse-table\" 
data-collapse-name=\"collapse-aggregated-sessionstat\""))
   }
 
   test("Spark Connect Server session page should load successfully") {
@@ -129,6 +129,6 @@ class SparkConnectServerPageSuite
     // Hiding table support
     assert(
       html.contains("collapse-aggregated-sqlsessionstat collapse-table\"" +
-        " onclick=\"collapsetable"))
+        " data-collapse-name=\"collapse-aggregated-sqlsessionstat\""))
   }
 }
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/ui/AllExecutionsPage.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/ui/AllExecutionsPage.scala
index fd364c457a42..8d0dc154906a 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/ui/AllExecutionsPage.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/ui/AllExecutionsPage.scala
@@ -89,8 +89,8 @@ private[ui] class AllExecutionsPage(parent: SQLTab) extends 
WebUIPage("") with L
 
         _content ++=
           <span id="running" class="collapse-aggregated-runningExecutions 
collapse-table"
-                onClick="collapseTable('collapse-aggregated-runningExecutions',
-                'aggregated-runningExecutions')">
+                data-collapse-name="collapse-aggregated-runningExecutions"
+                data-collapse-table="aggregated-runningExecutions">
             <h4>
               <span class="collapse-table-arrow arrow-open"></span>
               <a>Running Queries ({running.size})</a>
@@ -115,8 +115,8 @@ private[ui] class AllExecutionsPage(parent: SQLTab) extends 
WebUIPage("") with L
 
         _content ++=
           <span id="completed" class="collapse-aggregated-completedExecutions 
collapse-table"
-                
onClick="collapseTable('collapse-aggregated-completedExecutions',
-                'aggregated-completedExecutions')">
+                data-collapse-name="collapse-aggregated-completedExecutions"
+                data-collapse-table="aggregated-completedExecutions">
             <h4>
               <span class="collapse-table-arrow arrow-open"></span>
               <a>Completed Queries ({completed.size})</a>
@@ -142,8 +142,8 @@ private[ui] class AllExecutionsPage(parent: SQLTab) extends 
WebUIPage("") with L
 
         _content ++=
           <span id="failed" class="collapse-aggregated-failedExecutions 
collapse-table"
-                onClick="collapseTable('collapse-aggregated-failedExecutions',
-                'aggregated-failedExecutions')">
+                data-collapse-name="collapse-aggregated-failedExecutions"
+                data-collapse-table="aggregated-failedExecutions">
             <h4>
               <span class="collapse-table-arrow arrow-open"></span>
               <a>Failed Queries ({failed.size})</a>
@@ -155,12 +155,6 @@ private[ui] class AllExecutionsPage(parent: SQLTab) 
extends WebUIPage("") with L
       }
       _content
     }
-    content ++=
-      <script>
-        function clickDetail(details) {{
-          
details.parentNode.querySelector('.stage-details').classList.toggle('collapsed')
-        }}
-      </script>
     val summary: NodeSeq =
       <div>
         <ul class="list-unstyled">
@@ -350,9 +344,7 @@ private[ui] class ExecutionPagedTable(
 
     def executionLinks(executionData: Seq[Long]): Seq[Node] = {
       val details = if (executionData.nonEmpty) {
-        val onClickScript = 
"this.parentNode.parentNode.nextElementSibling.nextElementSibling" +
-          ".classList.toggle('collapsed')"
-        <span onclick={onClickScript} class="expand-details">
+        <span class="expand-details" data-toggle-sub-execution="true">
           +details
         </span>
       } else {
@@ -474,7 +466,7 @@ private[ui] class ExecutionPagedTable(
 
   private def descriptionCell(execution: SQLExecutionUIData): Seq[Node] = {
     val details = if (execution.details != null && execution.details.nonEmpty) 
{
-      <span 
onclick="this.parentNode.querySelector('.stage-details').classList.toggle('collapsed')"
+      <span data-toggle-details=".stage-details"
             class="expand-details">
         +details
       </span> ++
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/ui/ExecutionPage.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/ui/ExecutionPage.scala
index 694ea605e4a3..0d31eb9e88e8 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/ui/ExecutionPage.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/ui/ExecutionPage.scala
@@ -160,7 +160,7 @@ class ExecutionPage(parent: SQLTab) extends 
WebUIPage("execution") with Logging
 
     <div>
       <div>
-        <span style="cursor: pointer;" onclick="togglePlanViz();">
+        <span data-action="togglePlanViz">
           <h4>
             <span id="plan-viz-graph-arrow" class="arrow-open"></span>
             <a>Plan Visualization</a>
@@ -188,7 +188,7 @@ class ExecutionPage(parent: SQLTab) extends 
WebUIPage("execution") with Logging
 
   private def physicalPlanDescription(physicalPlanDescription: String): 
Seq[Node] = {
     <div>
-      <span style="cursor: pointer;" onclick="clickPhysicalPlanDetails();">
+      <span data-action="clickPhysicalPlanDetails">
         <h4>
           <span id="physical-plan-details-arrow" class="arrow-closed"></span>
           <a>Plan Details</a>
@@ -212,7 +212,8 @@ class ExecutionPage(parent: SQLTab) extends 
WebUIPage("execution") with Logging
 
     <div>
       <span class="collapse-sql-properties collapse-table"
-            onClick="collapseTable('collapse-sql-properties', 
'sql-properties')">
+            data-collapse-name="collapse-sql-properties"
+            data-collapse-table="sql-properties">
         <h4>
           <span class="collapse-table-arrow arrow-closed"></span>
           <a>SQL / DataFrame Properties</a>
@@ -251,8 +252,8 @@ class ExecutionPage(parent: SQLTab) extends 
WebUIPage("execution") with Logging
 
     <div>
       <span class="collapse-pandas-on-spark-properties collapse-table"
-            onClick="collapseTable('collapse-pandas-on-spark-properties',
-             'pandas-on-spark-properties')">
+            data-collapse-name="collapse-pandas-on-spark-properties"
+            data-collapse-table="pandas-on-spark-properties">
         <h4>
           <span class="collapse-table-arrow arrow-closed"></span>
           <a>Pandas API Properties</a>
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/streaming/ui/StreamingQueryPage.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/streaming/ui/StreamingQueryPage.scala
index 3a52238bb8c8..d7f13153078e 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/streaming/ui/StreamingQueryPage.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/streaming/ui/StreamingQueryPage.scala
@@ -48,7 +48,8 @@ private[ui] class StreamingQueryPage(parent: 
StreamingQueryTab)
       // scalastyle:off
       content ++=
         <span id="active" class="collapse-aggregated-activeQueries 
collapse-table"
-            
onClick="collapseTable('collapse-aggregated-activeQueries','aggregated-activeQueries')">
+            data-collapse-name="collapse-aggregated-activeQueries"
+            data-collapse-table="aggregated-activeQueries">
           <h5 id="activequeries">
             <span class="collapse-table-arrow arrow-open"></span>
             <a>Active Streaming Queries ({activeQueries.length})</a>
@@ -66,7 +67,8 @@ private[ui] class StreamingQueryPage(parent: 
StreamingQueryTab)
       // scalastyle:off
       content ++=
         <span id="completed" class="collapse-aggregated-completedQueries 
collapse-table"
-            
onClick="collapseTable('collapse-aggregated-completedQueries','aggregated-completedQueries')">
+            data-collapse-name="collapse-aggregated-completedQueries"
+            data-collapse-table="aggregated-completedQueries">
           <h5 id="completedqueries">
             <span class="collapse-table-arrow arrow-open"></span>
             <a>Completed Streaming Queries ({inactiveQueries.length})</a>
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/streaming/ui/StreamingQueryStatisticsPage.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/streaming/ui/StreamingQueryStatisticsPage.scala
index ba491df88076..64ece724466e 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/streaming/ui/StreamingQueryStatisticsPage.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/streaming/ui/StreamingQueryStatisticsPage.scala
@@ -31,7 +31,7 @@ import 
org.apache.spark.sql.execution.streaming.state.StateStoreProvider
 import org.apache.spark.sql.internal.SQLConf.STATE_STORE_PROVIDER_CLASS
 import 
org.apache.spark.sql.internal.StaticSQLConf.ENABLED_STREAMING_UI_CUSTOM_METRIC_LIST
 import org.apache.spark.sql.streaming.ui.UIUtils._
-import org.apache.spark.ui.{GraphUIData, JsCollector, UIUtils => SparkUIUtils, 
WebUIPage}
+import org.apache.spark.ui.{CspNonce, GraphUIData, JsCollector, UIUtils => 
SparkUIUtils, WebUIPage}
 import org.apache.spark.util.ArrayImplicits._
 
 private[ui] class StreamingQueryStatisticsPage(parent: StreamingQueryTab)
@@ -79,7 +79,7 @@ private[ui] class StreamingQueryStatisticsPage(parent: 
StreamingQueryTab)
       s"timeFormat[$time] = '$formattedTime';"
     }.mkString("\n")
 
-    <script>{Unparsed(js)}</script>
+    <script nonce={CspNonce.get}>{Unparsed(js)}</script>
   }
 
   def generateTimeTipStrings(values: Array[(Long, Long)]): Seq[Node] = {
@@ -88,7 +88,7 @@ private[ui] class StreamingQueryStatisticsPage(parent: 
StreamingQueryTab)
       s"timeTipStrings[$time] = 'batch $batchId ($formattedTime)';"
     }.mkString("\n")
 
-    <script>{Unparsed(js)}</script>
+    <script nonce={CspNonce.get}>{Unparsed(js)}</script>
   }
 
   def generateFormattedTimeTipStrings(values: Array[(Long, Long)]): Seq[Node] 
= {
@@ -97,7 +97,7 @@ private[ui] class StreamingQueryStatisticsPage(parent: 
StreamingQueryTab)
       s"""formattedTimeTipStrings["$formattedTime"] = 'batch $batchId 
($formattedTime)';"""
     }.mkString("\n")
 
-    <script>{Unparsed(js)}</script>
+    <script nonce={CspNonce.get}>{Unparsed(js)}</script>
   }
 
   def generateTimeToValues(values: Array[(Long, ju.Map[String, JLong])]): 
Seq[Node] = {
@@ -108,7 +108,7 @@ private[ui] class StreamingQueryStatisticsPage(parent: 
StreamingQueryTab)
       s"""formattedTimeToValues["$formattedTime"] = $s;"""
     }.mkString("\n")
 
-    <script>{Unparsed(js)}</script>
+    <script nonce={CspNonce.get}>{Unparsed(js)}</script>
   }
 
   def generateBasicInfo(uiData: StreamingQueryUIData): Seq[Node] = {
diff --git 
a/sql/hive-thriftserver/src/main/scala/org/apache/spark/sql/hive/thriftserver/ui/ThriftServerPage.scala
 
b/sql/hive-thriftserver/src/main/scala/org/apache/spark/sql/hive/thriftserver/ui/ThriftServerPage.scala
index 442d3cfa6a41..0f431959f9c4 100644
--- 
a/sql/hive-thriftserver/src/main/scala/org/apache/spark/sql/hive/thriftserver/ui/ThriftServerPage.scala
+++ 
b/sql/hive-thriftserver/src/main/scala/org/apache/spark/sql/hive/thriftserver/ui/ThriftServerPage.scala
@@ -100,8 +100,8 @@ private[ui] class ThriftServerPage(parent: ThriftServerTab) 
extends WebUIPage(""
     }
     val content =
       <span id="sqlstat" class="collapse-aggregated-sqlstat collapse-table"
-            onClick="collapseTable('collapse-aggregated-sqlstat',
-                'aggregated-sqlstat')">
+            data-collapse-name="collapse-aggregated-sqlstat"
+            data-collapse-table="aggregated-sqlstat">
         <h4>
           <span class="collapse-table-arrow arrow-open"></span>
           <a>SQL Statistics ({numStatement})</a>
@@ -147,8 +147,8 @@ private[ui] class ThriftServerPage(parent: ThriftServerTab) 
extends WebUIPage(""
 
     val content =
     <span id="sessionstat" class="collapse-aggregated-sessionstat 
collapse-table"
-          onClick="collapseTable('collapse-aggregated-sessionstat',
-                'aggregated-sessionstat')">
+          data-collapse-name="collapse-aggregated-sessionstat"
+          data-collapse-table="aggregated-sessionstat">
       <h4>
         <span class="collapse-table-arrow arrow-open"></span>
         <a>Session Statistics ({numSessions})</a>
diff --git 
a/sql/hive-thriftserver/src/main/scala/org/apache/spark/sql/hive/thriftserver/ui/ThriftServerSessionPage.scala
 
b/sql/hive-thriftserver/src/main/scala/org/apache/spark/sql/hive/thriftserver/ui/ThriftServerSessionPage.scala
index 50a4530de905..f0b7c59f22b4 100644
--- 
a/sql/hive-thriftserver/src/main/scala/org/apache/spark/sql/hive/thriftserver/ui/ThriftServerSessionPage.scala
+++ 
b/sql/hive-thriftserver/src/main/scala/org/apache/spark/sql/hive/thriftserver/ui/ThriftServerSessionPage.scala
@@ -102,8 +102,8 @@ private[ui] class ThriftServerSessionPage(parent: 
ThriftServerTab)
     }
     val content =
       <span id="sqlsessionstat" class="collapse-aggregated-sqlsessionstat 
collapse-table"
-            onClick="collapseTable('collapse-aggregated-sqlsessionstat',
-                'aggregated-sqlsessionstat')">
+            data-collapse-name="collapse-aggregated-sqlsessionstat"
+            data-collapse-table="aggregated-sqlsessionstat">
         <h4>
           <span class="collapse-table-arrow arrow-open"></span>
           <a>SQL Statistics</a>
diff --git 
a/sql/hive-thriftserver/src/test/scala/org/apache/spark/sql/hive/thriftserver/ui/ThriftServerPageSuite.scala
 
b/sql/hive-thriftserver/src/test/scala/org/apache/spark/sql/hive/thriftserver/ui/ThriftServerPageSuite.scala
index 806eabc96fe3..34dadd628148 100644
--- 
a/sql/hive-thriftserver/src/test/scala/org/apache/spark/sql/hive/thriftserver/ui/ThriftServerPageSuite.scala
+++ 
b/sql/hive-thriftserver/src/test/scala/org/apache/spark/sql/hive/thriftserver/ui/ThriftServerPageSuite.scala
@@ -102,7 +102,7 @@ class ThriftServerPageSuite extends SparkFunSuite with 
BeforeAndAfter {
 
     // Hiding table support
     assert(html.contains("class=\"collapse-aggregated-sessionstat" +
-       " collapse-table\" onclick=\"collapsetable"))
+       " collapse-table\" 
data-collapse-name=\"collapse-aggregated-sessionstat\""))
   }
 
   test("thriftserver session page should load successfully") {
@@ -128,7 +128,7 @@ class ThriftServerPageSuite extends SparkFunSuite with 
BeforeAndAfter {
 
     // Hiding table support
     assert(html.contains("collapse-aggregated-sqlsessionstat collapse-table\"" 
+
-          " onclick=\"collapsetable"))
+          " data-collapse-name=\"collapse-aggregated-sqlsessionstat\""))
   }
 }
 
diff --git 
a/streaming/src/main/scala/org/apache/spark/streaming/ui/BatchPage.scala 
b/streaming/src/main/scala/org/apache/spark/streaming/ui/BatchPage.scala
index 40a5f184ab24..a47b08c7b949 100644
--- a/streaming/src/main/scala/org/apache/spark/streaming/ui/BatchPage.scala
+++ b/streaming/src/main/scala/org/apache/spark/streaming/ui/BatchPage.scala
@@ -244,7 +244,7 @@ private[ui] class BatchPage(parent: StreamingTab) extends 
WebUIPage("batch") {
     <div>
       {outputOp.name}
       <span
-        
onclick="this.parentNode.querySelector('.stage-details').classList.toggle('collapsed')"
+        data-toggle-details=".stage-details"
         class="expand-details">
           +details
       </span>
diff --git 
a/streaming/src/main/scala/org/apache/spark/streaming/ui/StreamingPage.scala 
b/streaming/src/main/scala/org/apache/spark/streaming/ui/StreamingPage.scala
index 3a9b2c10da18..8dbe5df5af12 100644
--- a/streaming/src/main/scala/org/apache/spark/streaming/ui/StreamingPage.scala
+++ b/streaming/src/main/scala/org/apache/spark/streaming/ui/StreamingPage.scala
@@ -25,7 +25,7 @@ import scala.xml.{Node, Unparsed}
 import jakarta.servlet.http.HttpServletRequest
 
 import org.apache.spark.internal.Logging
-import org.apache.spark.ui.{GraphUIData, JsCollector, UIUtils => SparkUIUtils, 
WebUIPage}
+import org.apache.spark.ui.{CspNonce, GraphUIData, JsCollector, UIUtils => 
SparkUIUtils, WebUIPage}
 import org.apache.spark.util.Utils
 
 /**
@@ -117,7 +117,7 @@ private[ui] class StreamingPage(parent: StreamingTab)
          |
          |onClickTimeline = getOnClickTimelineFunction();
          |""".stripMargin
-    <script type="module">{Unparsed(js)}</script>
+    <script type="module" nonce={CspNonce.get}>{Unparsed(js)}</script>
   }
 
   /** Generate basic information of the streaming program */
@@ -156,7 +156,7 @@ private[ui] class StreamingPage(parent: StreamingTab)
       s"timeFormat[$time] = '$formattedTime';"
     }.mkString("\n")
 
-    <script>{Unparsed(js)}</script>
+    <script nonce={CspNonce.get}>{Unparsed(js)}</script>
   }
 
   private def generateTimeTipStrings(times: Seq[Long]): Seq[Node] = {
@@ -166,7 +166,7 @@ private[ui] class StreamingPage(parent: StreamingTab)
       s"timeTipStrings[$time] = timeFormat[$time];"
     }.mkString("\n")
 
-    <script>{Unparsed(js)}</script>
+    <script nonce={CspNonce.get}>{Unparsed(js)}</script>
   }
 
   private def generateStatTable(request: HttpServletRequest): Seq[Node] = {
@@ -481,8 +481,8 @@ private[ui] class StreamingPage(parent: StreamingTab)
         <div class="row">
           <div class="col-12">
             <span id="runningBatches" 
class="collapse-aggregated-runningBatches collapse-table"
-                  onClick="collapseTable('collapse-aggregated-runningBatches',
-                  'aggregated-runningBatches')">
+                  data-collapse-name="collapse-aggregated-runningBatches"
+                  data-collapse-table="aggregated-runningBatches">
               <h4>
                 <span class="collapse-table-arrow arrow-open"></span>
                 <a>Running Batches ({runningBatches.size})</a>
@@ -500,8 +500,8 @@ private[ui] class StreamingPage(parent: StreamingTab)
         <div class="row">
           <div class="col-12">
             <span id="waitingBatches" 
class="collapse-aggregated-waitingBatches collapse-table"
-                  onClick="collapseTable('collapse-aggregated-waitingBatches',
-                  'aggregated-waitingBatches')">
+                  data-collapse-name="collapse-aggregated-waitingBatches"
+                  data-collapse-table="aggregated-waitingBatches">
               <h4>
                 <span class="collapse-table-arrow arrow-open"></span>
                 <a>Waiting Batches ({waitingBatches.size})</a>
@@ -519,8 +519,8 @@ private[ui] class StreamingPage(parent: StreamingTab)
         <div class="row">
           <div class="col-12">
             <span id="completedBatches" 
class="collapse-aggregated-completedBatches collapse-table"
-                  
onClick="collapseTable('collapse-aggregated-completedBatches',
-                  'aggregated-completedBatches')">
+                  data-collapse-name="collapse-aggregated-completedBatches"
+                  data-collapse-table="aggregated-completedBatches">
               <h4>
                 <span class="collapse-table-arrow arrow-open"></span>
                 <a>Completed Batches (last {completedBatches.size}
diff --git a/ui-test/package.json b/ui-test/package.json
index c2a8c3fc196a..61af05004233 100644
--- a/ui-test/package.json
+++ b/ui-test/package.json
@@ -2,6 +2,9 @@
   "name": "ui-test",
   "license": "Apache License 2.0",
   "type": "module",
+  "scripts": {
+    "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
+  },
   "devDependencies": {
     "glob": "^13.0.3",
     "jest": "^30.0.0",
@@ -9,6 +12,7 @@
     "jquery": "^3.7.1"
   },
   "jest": {
-    "testEnvironment": "jsdom"
+    "testEnvironment": "jsdom",
+    "transform": {}
   }
 }
diff --git a/ui-test/tests/csp-compliance.test.js 
b/ui-test/tests/csp-compliance.test.js
new file mode 100644
index 000000000000..e27fa1a8f65c
--- /dev/null
+++ b/ui-test/tests/csp-compliance.test.js
@@ -0,0 +1,96 @@
+/*
+ * 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.
+ */
+
+import { readFileSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+import { glob } from 'glob';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+test('CSS files with data URI images do not violate CSP img-src policy', async 
function () {
+  const cssFiles = await glob('**/*.css', {
+    cwd: join(__dirname, '../..'),
+    ignore: ['**/target/**', '**/node_modules/**']
+  });
+
+  const dataUriPattern = /url\(['"]?data:image\//;
+  let hasDataUri = false;
+
+  for (const file of cssFiles) {
+    const filePath = join(__dirname, '../..', file);
+    const content = readFileSync(filePath, 'utf8');
+    
+    if (dataUriPattern.test(content)) {
+      hasDataUri = true;
+      break;
+    }
+  }
+
+  // If any CSS uses data URIs, verify our CSP policy allows them
+  if (hasDataUri) {
+    const httpSecurityFilterPath = join(__dirname, 
'../../core/src/main/scala/org/apache/spark/ui/HttpSecurityFilter.scala');
+    const httpSecurityFilter = readFileSync(httpSecurityFilterPath, 'utf8');
+
+    // Verify that CSP includes img-src with data: support
+    expect(httpSecurityFilter).toMatch(/img-src\s+[^;]*data:/);
+  }
+});
+
+test('All inline scripts use CSP nonce', async function () {
+  const scalaFiles = await glob('**/*.scala', {
+    cwd: join(__dirname, '../..'),
+    ignore: ['**/target/**', '**/node_modules/**']
+  });
+
+  const violations = [];
+
+  for (const file of scalaFiles) {
+    const filePath = join(__dirname, '../..', file);
+    const content = readFileSync(filePath, 'utf8');
+
+    // Find inline script tags: <script ...>...</script> or <script .../>
+    const scriptTagPattern = /<script\s+([^>]*)>/g;
+    let match;
+
+    while ((match = scriptTagPattern.exec(content)) !== null) {
+      const attributes = match[1];
+      
+      // Skip external scripts (those with src attribute)
+      if (/src\s*=/.test(attributes)) {
+        continue;
+      }
+
+      // Inline script must have nonce attribute
+      if (!/nonce\s*=\s*\{CspNonce\.get\}/.test(attributes)) {
+        violations.push({
+          file: file,
+          line: content.substring(0, match.index).split('\n').length,
+          tag: match[0]
+        });
+      }
+    }
+  }
+
+  if (violations.length > 0) {
+    const message = violations.map(v => 
+      `${v.file}:${v.line} - Missing nonce: ${v.tag}`
+    ).join('\n');
+    throw new Error(`Found inline scripts without CSP nonce:\n${message}`);
+  }
+});


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

Reply via email to