This is an automated email from the ASF dual-hosted git repository.
sarutak pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new 9272286dd918 [SPARK-57614][UI] Use CSP `frame-ancestors` instead of
deprecated `X-Frame-Options: ALLOW-FROM`
9272286dd918 is described below
commit 9272286dd91813b16af34995ace77b96170271ad
Author: Kousuke Saruta <[email protected]>
AuthorDate: Wed Jul 8 23:38:30 2026 +0900
[SPARK-57614][UI] Use CSP `frame-ancestors` instead of deprecated
`X-Frame-Options: ALLOW-FROM`
### What changes were proposed in this pull request?
This PR fixes the `spark.ui.allowFramingFrom` configuration to use CSP
`frame-ancestors` directive instead of the deprecated `X-Frame-Options:
ALLOW-FROM`.
According to [MDN Web
Docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Frame-Options#allow-from_origin):
> **`ALLOW-FROM origin`** — This is an obsolete directive. Modern browsers
that encounter response headers with this direc
tive will ignore the header completely. The `Content-Security-Policy` HTTP
header has a `frame-ancestors` directive which
you should use instead.
The fix:
* When `spark.ui.contentSecurityPolicy.enabled=true`, uses CSP
`frame-ancestors 'self' <uri>` to enforce the `allowFramingFrom` setting
* Adds `spark.ui.contentSecurityPolicy.frameAncestors.enabled` (default
`true`) as a sub-config to control whether `frame-ancestors` is included in the
CSP header. Can be set to `false` as a fallback if `frame-ancestors` causes
issues
* When CSP is disabled (the default), no CSP header is emitted and
`X-Frame-Options: SAMEORIGIN` is used
* Sanitizes the config value to prevent CSP directive injection via
semicolons or newlines
* Moves `X-Frame-Options: SAMEORIGIN` header before access control checks
so that even 403 error responses include clickjacking protection
### Why are the changes needed?
The `spark.ui.allowFramingFrom` config has been non-functional in all
modern browsers since `ALLOW-FROM` was obsoleted. Users relying on this setting
for embedding Spark UI in iframes from specific origins have no working
clickjacking protection. CSP `frame-ancestors` is the W3C-recommended
replacement.
### Does this PR introduce _any_ user-facing change?
Yes.
* `spark.ui.allowFramingFrom` now works correctly when CSP is enabled, via
`frame-ancestors`
* `spark.ui.allowFramingFrom` requires
`spark.ui.contentSecurityPolicy.enabled=true` to take effect. When CSP is
disabled, `X-Frame-Options: SAMEORIGIN` is always used regardless of the
`allowFramingFrom` value
* New config `spark.ui.contentSecurityPolicy.frameAncestors.enabled`
(default `true`) controls whether `frame-ancestors` is included in the CSP
header
* `X-Frame-Options` header is always `SAMEORIGIN` (previously it was
`ALLOW-FROM <uri>` which browsers ignored)
* Migration guide updated
### How was this patch tested?
* Unit tests in `HttpSecurityFilterSuite` verify:
* CSP `frame-ancestors` contains the configured URI when CSP is enabled
* No CSP header when CSP is disabled
* `frame-ancestors` excluded when `frameAncestors.enabled=false`
* `DENY` maps to `frame-ancestors 'none'`
* `SAMEORIGIN` maps to `frame-ancestors 'self'`
* CSP directive injection prevention via semicolons
* `X-Frame-Options: SAMEORIGIN` is always set, even on 403 responses
* Integration test in `UISuite` verifies Jetty sanitizes newlines in the
CSP header
### Was this patch authored or co-authored using generative AI tooling?
Kiro CLI / Claude
Closes #56669 from sarutak/fix-allow-from-frame-ancestors.
Authored-by: Kousuke Saruta <[email protected]>
Signed-off-by: Kousuke Saruta <[email protected]>
(cherry picked from commit 575dfb2afabbb181e4d51f68451bedcfa57f5191)
Signed-off-by: Kousuke Saruta <[email protected]>
---
.../org/apache/spark/internal/config/UI.scala | 11 ++
.../org/apache/spark/ui/HttpSecurityFilter.scala | 39 +++++--
.../apache/spark/ui/HttpSecurityFilterSuite.scala | 118 ++++++++++++++++++++-
.../test/scala/org/apache/spark/ui/UISuite.scala | 27 +++--
docs/core-migration-guide.md | 2 +
docs/security.md | 8 +-
6 files changed, 180 insertions(+), 25 deletions(-)
diff --git a/core/src/main/scala/org/apache/spark/internal/config/UI.scala
b/core/src/main/scala/org/apache/spark/internal/config/UI.scala
index fe8d7ef4b273..7a222247f311 100644
--- a/core/src/main/scala/org/apache/spark/internal/config/UI.scala
+++ b/core/src/main/scala/org/apache/spark/internal/config/UI.scala
@@ -149,6 +149,17 @@ private[spark] object UI {
.booleanConf
.createWithDefault(false)
+ val UI_CONTENT_SECURITY_POLICY_FRAME_ANCESTORS_ENABLED =
+ ConfigBuilder("spark.ui.contentSecurityPolicy.frameAncestors.enabled")
+ .doc("Whether to include the frame-ancestors directive in the CSP header
" +
+ "when spark.ui.contentSecurityPolicy.enabled is true. When enabled,
the " +
+ "frame-ancestors directive enforces the spark.ui.allowFramingFrom
setting. " +
+ "This setting is ignored when spark.ui.contentSecurityPolicy.enabled
is false.")
+ .version("4.3.0")
+ .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+ .booleanConf
+ .createWithDefault(true)
+
val UI_REQUEST_HEADER_SIZE = ConfigBuilder("spark.ui.requestHeaderSize")
.doc("Value for HTTP request header size in bytes.")
.version("2.2.3")
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 d06f2162d2f6..edfa3581d9ab 100644
--- a/core/src/main/scala/org/apache/spark/ui/HttpSecurityFilter.scala
+++ b/core/src/main/scala/org/apache/spark/ui/HttpSecurityFilter.scala
@@ -52,12 +52,40 @@ private class HttpSecurityFilter(
val cspNonce = CspNonce.generate()
try {
if (conf.get(UI_CONTENT_SECURITY_POLICY_ENABLED)) {
+ // Use CSP frame-ancestors as the primary clickjacking protection
mechanism.
+ // X-Frame-Options ALLOW-FROM is deprecated and ignored by modern
browsers
+ // (Chrome, Firefox, Edge, Safari), so frame-ancestors is used instead.
+ val frameAncestorsDirective =
+ if (conf.get(UI_CONTENT_SECURITY_POLICY_FRAME_ANCESTORS_ENABLED)) {
+ val frameAncestors = conf.get(UI_ALLOW_FRAMING_FROM)
+ .filterNot(v => v.equalsIgnoreCase("SAMEORIGIN"))
+ .map { uri =>
+ if (uri.equalsIgnoreCase("DENY")) {
+ "frame-ancestors 'none'"
+ } else {
+ // Sanitize the URI: truncate at semicolons to prevent CSP
directive
+ // injection, and strip newlines to prevent header injection.
+ val sanitized = uri.replaceAll("[\\r\\n]+", "").split(";",
2)(0).trim
+ if (sanitized.isEmpty) "frame-ancestors 'self'"
+ else s"frame-ancestors 'self' $sanitized"
+ }
+ }
+ .getOrElse("frame-ancestors 'self'")
+ s" $frameAncestors;"
+ } else {
+ ""
+ }
+
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';")
+ s"object-src 'none'; base-uri 'self';$frameAncestorsDirective")
}
+ // X-Frame-Options is set before access control so that even error
responses
+ // include clickjacking protection.
+ hres.setHeader("X-Frame-Options", "SAMEORIGIN")
+
val requestUser = hreq.getRemoteUser()
// The doAs parameter allows proxy servers (e.g. Knox) to impersonate
other users. For
@@ -79,15 +107,6 @@ private class HttpSecurityFilter(
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")
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 0892c0e3ffc1..16c170fa2736 100644
--- a/core/src/test/scala/org/apache/spark/ui/HttpSecurityFilterSuite.scala
+++ b/core/src/test/scala/org/apache/spark/ui/HttpSecurityFilterSuite.scala
@@ -130,7 +130,7 @@ 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
+ // CSP header contains a dynamic nonce and frame-ancestors with the
configured URI
val cspCaptor = ArgumentCaptor.forClass(classOf[String])
verify(res).setHeader(meq("Content-Security-Policy"), cspCaptor.capture())
val cspValue = cspCaptor.getValue
@@ -139,9 +139,12 @@ class HttpSecurityFilterSuite extends SparkFunSuite {
assert(cspValue.contains("img-src 'self' data:"))
assert(cspValue.contains("object-src 'none'"))
assert(cspValue.contains("base-uri 'self'"))
+ assert(cspValue.contains("frame-ancestors 'self' example.com"))
+ // X-Frame-Options is always SAMEORIGIN as a fallback for legacy browsers.
+ // The allowFramingFrom config is honored via CSP frame-ancestors instead.
Map(
- "X-Frame-Options" -> "ALLOW-FROM example.com",
+ "X-Frame-Options" -> "SAMEORIGIN",
"X-XSS-Protection" -> "xssProtection",
"X-Content-Type-Options" -> "nosniff",
"Strict-Transport-Security" -> "tsec"
@@ -150,7 +153,7 @@ class HttpSecurityFilterSuite extends SparkFunSuite {
}
}
- test("Content-Security-Policy header is not set by default") {
+ test("no CSP header when CSP is disabled regardless of frameAncestors
setting") {
val conf = new SparkConf(false)
val secMgr = new SecurityManager(conf)
val req = mockRequest()
@@ -160,6 +163,7 @@ class HttpSecurityFilterSuite extends SparkFunSuite {
val filter = new HttpSecurityFilter(conf, secMgr)
filter.doFilter(req, res, chain)
+ // CSP is disabled by default, so no CSP header should be emitted
verify(res, times(0)).setHeader(meq("Content-Security-Policy"), any())
}
@@ -176,6 +180,40 @@ class HttpSecurityFilterSuite extends SparkFunSuite {
verify(res).setHeader(meq("X-XSS-Protection"), meq("0"))
}
+ test("frame-ancestors is included in CSP when both CSP and frameAncestors
are enabled") {
+ val conf = new SparkConf(false)
+ .set(UI_CONTENT_SECURITY_POLICY_ENABLED, true)
+ .set(UI_ALLOW_FRAMING_FROM, "https://example.com")
+ val secMgr = new SecurityManager(conf)
+ val req = mockRequest()
+ val res = mock(classOf[HttpServletResponse])
+ val chain = mock(classOf[FilterChain])
+
+ val filter = new HttpSecurityFilter(conf, secMgr)
+ filter.doFilter(req, res, chain)
+
+ val cspCaptor = ArgumentCaptor.forClass(classOf[String])
+ verify(res).setHeader(meq("Content-Security-Policy"), cspCaptor.capture())
+ assert(cspCaptor.getValue.contains("frame-ancestors 'self'
https://example.com"))
+ }
+
+ test("frame-ancestors is excluded from CSP when frameAncestors is disabled")
{
+ val conf = new SparkConf(false)
+ .set(UI_CONTENT_SECURITY_POLICY_ENABLED, true)
+ .set(UI_CONTENT_SECURITY_POLICY_FRAME_ANCESTORS_ENABLED, false)
+ val secMgr = new SecurityManager(conf)
+ val req = mockRequest()
+ val res = mock(classOf[HttpServletResponse])
+ val chain = mock(classOf[FilterChain])
+
+ val filter = new HttpSecurityFilter(conf, secMgr)
+ filter.doFilter(req, res, chain)
+
+ val cspCaptor = ArgumentCaptor.forClass(classOf[String])
+ verify(res).setHeader(meq("Content-Security-Policy"), cspCaptor.capture())
+ assert(!cspCaptor.getValue.contains("frame-ancestors"))
+ }
+
test("doAs impersonation") {
val conf = new SparkConf(false)
.set(ACLS_ENABLE, true)
@@ -280,6 +318,80 @@ class HttpSecurityFilterSuite extends SparkFunSuite {
assert(nonces.distinct.size === 3)
}
+ test("allowFramingFrom value is sanitized to prevent CSP directive
injection") {
+ val conf = new SparkConf(false)
+ .set(UI_ALLOW_FRAMING_FROM, "evil.com; script-src 'unsafe-inline'")
+ .set(UI_CONTENT_SECURITY_POLICY_ENABLED, true)
+ val secMgr = new SecurityManager(conf)
+ val req = mockRequest()
+ val res = mock(classOf[HttpServletResponse])
+ val chain = mock(classOf[FilterChain])
+
+ val filter = new HttpSecurityFilter(conf, secMgr)
+ filter.doFilter(req, res, chain)
+
+ val cspCaptor = ArgumentCaptor.forClass(classOf[String])
+ verify(res).setHeader(meq("Content-Security-Policy"), cspCaptor.capture())
+ val cspValue = cspCaptor.getValue
+ // Semicolons should be stripped, preventing directive injection
+ assert(!cspValue.contains("script-src 'unsafe-inline'"))
+ assert(cspValue.contains("frame-ancestors 'self' evil.com"))
+ }
+
+ test("X-Frame-Options is set 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())
+ // But X-Frame-Options should still be set
+ verify(res).setHeader(meq("X-Frame-Options"), meq("SAMEORIGIN"))
+ }
+
+ test("allowFramingFrom=DENY maps to frame-ancestors 'none'") {
+ val conf = new SparkConf(false)
+ .set(UI_ALLOW_FRAMING_FROM, "DENY")
+ .set(UI_CONTENT_SECURITY_POLICY_ENABLED, true)
+ val secMgr = new SecurityManager(conf)
+ val req = mockRequest()
+ val res = mock(classOf[HttpServletResponse])
+ val chain = mock(classOf[FilterChain])
+
+ val filter = new HttpSecurityFilter(conf, secMgr)
+ filter.doFilter(req, res, chain)
+
+ val cspCaptor = ArgumentCaptor.forClass(classOf[String])
+ verify(res).setHeader(meq("Content-Security-Policy"), cspCaptor.capture())
+ assert(cspCaptor.getValue.contains("frame-ancestors 'none'"))
+ }
+
+ test("allowFramingFrom=SAMEORIGIN maps to frame-ancestors 'self'") {
+ val conf = new SparkConf(false)
+ .set(UI_ALLOW_FRAMING_FROM, "SAMEORIGIN")
+ .set(UI_CONTENT_SECURITY_POLICY_ENABLED, true)
+ val secMgr = new SecurityManager(conf)
+ val req = mockRequest()
+ val res = mock(classOf[HttpServletResponse])
+ val chain = mock(classOf[FilterChain])
+
+ val filter = new HttpSecurityFilter(conf, secMgr)
+ filter.doFilter(req, res, chain)
+
+ val cspCaptor = ArgumentCaptor.forClass(classOf[String])
+ verify(res).setHeader(meq("Content-Security-Policy"), cspCaptor.capture())
+ assert(cspCaptor.getValue.contains("frame-ancestors 'self'"))
+ assert(!cspCaptor.getValue.contains("SAMEORIGIN"))
+ }
+
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/UISuite.scala
b/core/src/test/scala/org/apache/spark/ui/UISuite.scala
index e506c0a7ab8d..9bd649994258 100644
--- a/core/src/test/scala/org/apache/spark/ui/UISuite.scala
+++ b/core/src/test/scala/org/apache/spark/ui/UISuite.scala
@@ -467,12 +467,13 @@ class UISuite extends SparkFunSuite {
}
}
- test("SPARK-54563: Jetty sanitizes newlines in X-Frame-Options header") {
+ test("SPARK-54563: Jetty sanitizes newlines in CSP frame-ancestors header") {
val valueWithNewlines = "example.com\nmalicious\nheader"
val (conf, securityMgr, sslOptions) = sslDisabledConf()
// Set the config value directly to bypass validation, simulating what
could happen
// if someone bypasses the config validation (e.g., through direct
property setting)
conf.set("spark.ui.allowFramingFrom", valueWithNewlines)
+ conf.set("spark.ui.contentSecurityPolicy.enabled", "true")
val serverInfo = JettyUtils.startJettyServer("0.0.0.0", 0, sslOptions,
conf)
try {
@@ -482,16 +483,20 @@ class UISuite extends SparkFunSuite {
val url = new
URI(s"http://$localhost:${serverInfo.boundPort}/test/root").toURL
TestUtils.withHttpConnection(url) { conn =>
val xFrameOptions = conn.getHeaderField("X-Frame-Options")
- // Jetty should sanitize newlines by replacing them with spaces
- assert(xFrameOptions !== null, "X-Frame-Options header should be
present")
- assert(!xFrameOptions.contains("\n"),
- "X-Frame-Options header should not contain newlines")
- assert(!xFrameOptions.contains("\r"),
- "X-Frame-Options header should not contain carriage returns")
- // The header value should have newlines replaced with spaces
- val expectedValue = "ALLOW-FROM " +
valueWithNewlines.replaceAll("[\r\n]+", " ")
- assert(xFrameOptions === expectedValue,
- s"X-Frame-Options header should have newlines replaced with spaces")
+ // X-Frame-Options is always SAMEORIGIN as a legacy fallback
+ assert(xFrameOptions === "SAMEORIGIN",
+ "X-Frame-Options should always be SAMEORIGIN")
+
+ // The allowFramingFrom value is now in the CSP frame-ancestors
directive.
+ // Jetty should sanitize newlines in the CSP header.
+ val csp = conn.getHeaderField("Content-Security-Policy")
+ assert(csp !== null, "Content-Security-Policy header should be
present")
+ assert(csp.contains("frame-ancestors"),
+ "CSP should contain frame-ancestors directive")
+ assert(!csp.contains("\n"),
+ "CSP header should not contain newlines")
+ assert(!csp.contains("\r"),
+ "CSP header should not contain carriage returns")
}
} finally {
stopServer(serverInfo)
diff --git a/docs/core-migration-guide.md b/docs/core-migration-guide.md
index d6ce5523f696..5fdd0fcf6088 100644
--- a/docs/core-migration-guide.md
+++ b/docs/core-migration-guide.md
@@ -32,6 +32,8 @@ license: |
- Since Spark 4.3, the default value of `spark.ui.xXssProtection` has been
changed from `1; mode=block` to `0`. The XSS Auditor has been removed from
Chrome and Edge, and was never implemented in Firefox. It can introduce
side-channel vulnerabilities in browsers that still support it (Safari). To
restore the legacy behavior, you can set `spark.ui.xXssProtection` to `1;
mode=block`. For modern XSS protection, consider enabling
`spark.ui.contentSecurityPolicy.enabled`.
+- Since Spark 4.3, `spark.ui.allowFramingFrom` now uses CSP `frame-ancestors`
instead of the deprecated `X-Frame-Options: ALLOW-FROM` (which was ignored by
all modern browsers). This setting only takes effect when
`spark.ui.contentSecurityPolicy.enabled=true`. When CSP is disabled (the
default), `X-Frame-Options: SAMEORIGIN` is always used regardless of the
`allowFramingFrom` value. To allow framing from a specific origin, set both
`spark.ui.contentSecurityPolicy.enabled=true` and `spark [...]
+
## Upgrading from Core 4.1 to 4.2
- Since Spark 4.2, Spark Master REST API uses Java 21 virtual threads by
default when running on Java 21 or later. To restore the legacy behavior, you
can set `spark.master.rest.virtualThread.enabled` to `false`.
diff --git a/docs/security.md b/docs/security.md
index 9e28352b2010..4691e339480d 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -348,9 +348,15 @@ The following options control the authentication of Web
UIs:
<tr>
<td><code>spark.ui.allowFramingFrom</code></td>
<td><code>SAMEORIGIN</code></td>
- <td>Allow framing for a specific named URI via <code>X-Frame-Options</code>.
By default, allow only from the same origin.</td>
+ <td>Allow framing for a specific named URI via CSP
<code>frame-ancestors</code> directive. By default, allow only from the same
origin. Requires <code>spark.ui.contentSecurityPolicy.enabled=true</code> to
take effect. When CSP is disabled, <code>X-Frame-Options: SAMEORIGIN</code> is
used regardless of this setting.</td>
<td>1.6.0</td>
</tr>
+<tr>
+ <td><code>spark.ui.contentSecurityPolicy.frameAncestors.enabled</code></td>
+ <td><code>true</code></td>
+ <td>Whether to include the <code>frame-ancestors</code> directive in the CSP
header when <code>spark.ui.contentSecurityPolicy.enabled=true</code>. When
enabled, the <code>frame-ancestors</code> directive enforces the
<code>spark.ui.allowFramingFrom</code> setting. This setting is ignored when
CSP is disabled.</td>
+ <td>4.3.0</td>
+</tr>
<tr>
<td><code>spark.ui.filters</code></td>
<td>None</td>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]