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]

Reply via email to