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]