This is an automated email from the ASF dual-hosted git repository.
ggal pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-livy.git
The following commit(s) were added to refs/heads/master by this push:
new f580e71b [LIVY-785] Enabled adding security related HTTP headers
f580e71b is described below
commit f580e71b5805c0b2ed2b93005fce9bc6396354e7
Author: nileshrathi345 <[email protected]>
AuthorDate: Mon Apr 14 19:25:39 2025 +0530
[LIVY-785] Enabled adding security related HTTP headers
## What changes were proposed in this pull request?
This change introduces a new configuration option
`livy.server.security-headers.enabled`.
When this property is set to true, the following security headers are added
to HTTP
responses by default:
* X-XSS-Protection
* X-Frame-Options
* X-Content-Type-Options
* Strict-Transport-Security
* Content-Security-Policy
Also, adds content type information to all responses as required when using
content type option nosniff
## How was this patch tested?
Tested manually
---
conf/livy.conf.template | 14 +++
.../src/main/scala/org/apache/livy/LivyConf.scala | 15 +++
.../scala/org/apache/livy/server/LivyServer.scala | 6 ++
.../apache/livy/server/SecurityHeadersFilter.scala | 56 ++++++++++++
.../livy/server/SecurityHeadersFilterSpec.scala | 101 +++++++++++++++++++++
5 files changed, 192 insertions(+)
diff --git a/conf/livy.conf.template b/conf/livy.conf.template
index ddf69ee2..fe257c93 100644
--- a/conf/livy.conf.template
+++ b/conf/livy.conf.template
@@ -102,6 +102,20 @@
# http-header "X-Requested-By" in request if the http method is
POST/DELETE/PUT/PATCH.
# livy.server.csrf-protection.enabled =
+# Whether to add security related HTTP headers to responses, by default true.
If enabled,
+# Livy server adds HTTP headers to responses based on below configuration
parameters starting with
+# `Livy.server.http.header.`
+# livy.server.security-headers.enabled = true
+
+# Security headers added to responses by default when
+# configuration `livy.server.security-headers.enabled` is set to true.
+# STS header is only added if TLS is enabled.
+# livy.server.http.header.X-XSS-Protection = 1; mode=block
+# livy.server.http.header.X-Frame-Options = SAMEORIGIN
+# livy.server.http.header.X-Content-Type-Options = nosniff
+# livy.server.http.header.Strict-Transport-Security = max-age=31536000;
includeSubDomains
+# livy.server.http.header.Content-Security-Policy = default-src 'self';
script-src 'self' 'unsafe-inline'; img-src 'self'; frame-src 'self';
+
# Whether to enable HiveContext in livy interpreter, if it is true
hive-site.xml will be detected
# on user request and then livy server classpath automatically.
# livy.repl.enable-hive-context =
diff --git a/server/src/main/scala/org/apache/livy/LivyConf.scala
b/server/src/main/scala/org/apache/livy/LivyConf.scala
index e798012e..8346b4b5 100644
--- a/server/src/main/scala/org/apache/livy/LivyConf.scala
+++ b/server/src/main/scala/org/apache/livy/LivyConf.scala
@@ -71,6 +71,21 @@ object LivyConf {
val CSRF_PROTECTION = Entry("livy.server.csrf-protection.enabled", false)
+ val SECURITY_HEADERS_ENABLED = Entry("livy.server.security-headers.enabled",
true)
+ val SECURITY_HEADERS_XSS_PROTECTION =
+ Entry("livy.server.http.header.X-XSS-Protection", "1; mode=block")
+ val SECURITY_HEADERS_FRAME_OPTIONS =
+ Entry("livy.server.http.header.X-Frame-Options", "SAMEORIGIN")
+ val SECURITY_HEADERS_CONTENT_TYPE_OPTIONS =
+ Entry("livy.server.http.header.X-Content-Type-Options", "nosniff")
+ val SECURITY_HEADERS_STRICT_TRANSPORT_SECURITY =
+ Entry("livy.server.http.header.Strict-Transport-Security",
+ "max-age=31536000; includeSubDomains")
+ val SECURITY_HEADERS_CONTENT_SECURITY_POLICY =
+ Entry("livy.server.http.header.Content-Security-Policy",
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; img-src 'self';
" +
+ "frame-src 'self';")
+
val IMPERSONATION_ENABLED = Entry("livy.impersonation.enabled", false)
val SUPERUSERS = Entry("livy.superusers", null)
diff --git a/server/src/main/scala/org/apache/livy/server/LivyServer.scala
b/server/src/main/scala/org/apache/livy/server/LivyServer.scala
index c7c7fe75..049dae04 100644
--- a/server/src/main/scala/org/apache/livy/server/LivyServer.scala
+++ b/server/src/main/scala/org/apache/livy/server/LivyServer.scala
@@ -251,6 +251,12 @@ class LivyServer extends Logging {
})
+ if (livyConf.getBoolean(SECURITY_HEADERS_ENABLED)) {
+ info("Adding security headers is enabled.")
+ val securityHeadersHolder = new FilterHolder(new
SecurityHeadersFilter(livyConf))
+ server.context.addFilter(securityHeadersHolder, "/*",
EnumSet.allOf(classOf[DispatcherType]))
+ }
+
livyConf.get(AUTH_TYPE) match {
case authType @ KerberosAuthenticationHandler.TYPE =>
val principal =
SecurityUtil.getServerPrincipal(livyConf.get(AUTH_KERBEROS_PRINCIPAL),
diff --git
a/server/src/main/scala/org/apache/livy/server/SecurityHeadersFilter.scala
b/server/src/main/scala/org/apache/livy/server/SecurityHeadersFilter.scala
new file mode 100644
index 00000000..3d8a148e
--- /dev/null
+++ b/server/src/main/scala/org/apache/livy/server/SecurityHeadersFilter.scala
@@ -0,0 +1,56 @@
+/*
+ * 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.livy.server
+
+import javax.servlet.{Filter, FilterChain, FilterConfig, ServletRequest,
ServletResponse}
+import javax.servlet.http.HttpServletResponse
+
+import org.apache.livy.LivyConf
+
+/**
+ * Adds security related headers to HTTP responses.
+ */
+class SecurityHeadersFilter(livyConf: LivyConf) extends Filter {
+
+ private def isSslEnabled: Boolean = {
+ Option(livyConf.get(LivyConf.SSL_KEYSTORE)).exists(_.length > 0)
+ }
+
+ val headers : Map[String, String] = Map(
+ "X-Content-Type-Options" ->
livyConf.get(LivyConf.SECURITY_HEADERS_CONTENT_TYPE_OPTIONS),
+ "X-Frame-Options" -> livyConf.get(LivyConf.SECURITY_HEADERS_FRAME_OPTIONS),
+ "X-XSS-Protection" ->
livyConf.get(LivyConf.SECURITY_HEADERS_XSS_PROTECTION),
+ "Strict-Transport-Security" ->
+ (if (isSslEnabled)
livyConf.get(LivyConf.SECURITY_HEADERS_STRICT_TRANSPORT_SECURITY) else ""),
+ "Content-Security-Policy" ->
livyConf.get(LivyConf.SECURITY_HEADERS_CONTENT_SECURITY_POLICY))
+ .filter(e => e._2 != null && e._2.trim().length > 0)
+
+ override def init(filterConfig: FilterConfig): Unit = {}
+
+ override def doFilter(request: ServletRequest,
+ response: ServletResponse,
+ chain: FilterChain): Unit = {
+ val servletResponse = response.asInstanceOf[HttpServletResponse]
+ for ((k, v) <- headers) {
+ servletResponse.addHeader(k, v)
+ }
+ chain.doFilter(request, response)
+ }
+
+ override def destroy(): Unit = {}
+}
diff --git
a/server/src/test/scala/org/apache/livy/server/SecurityHeadersFilterSpec.scala
b/server/src/test/scala/org/apache/livy/server/SecurityHeadersFilterSpec.scala
new file mode 100644
index 00000000..03ff6239
--- /dev/null
+++
b/server/src/test/scala/org/apache/livy/server/SecurityHeadersFilterSpec.scala
@@ -0,0 +1,101 @@
+/*
+ * 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.livy.server
+
+import javax.servlet.FilterChain
+import javax.servlet.http.{HttpServletRequest, HttpServletResponse}
+
+import org.mockito.ArgumentCaptor
+import org.mockito.Mockito.{atLeastOnce, verify}
+import org.scalatest.{FunSpec, Matchers}
+import org.scalatestplus.mockito.MockitoSugar.mock
+
+import org.apache.livy.{LivyBaseUnitTestSuite, LivyConf}
+
+class SecurityHeadersFilterSpec extends FunSpec with Matchers with
LivyBaseUnitTestSuite {
+
+ val requiredHeaders = Set("X-Content-Type-Options", "X-Frame-Options",
"X-XSS-Protection",
+ "Content-Security-Policy")
+
+ private def runFilterAndGetResponseHeaders(configEntries:
Set[(LivyConf.Entry, String)]):
+ List[(String, String)] = {
+ import scala.collection.JavaConverters._
+ val livyConf = createLivyConf(configEntries)
+ val securityHeadersFilter = new SecurityHeadersFilter(livyConf)
+ val response = mock[HttpServletResponse]
+ val request = mock[HttpServletRequest]
+ val chain = mock[FilterChain]
+ val keyCaptor = ArgumentCaptor.forClass(classOf[String])
+ val valueCaptor = ArgumentCaptor.forClass(classOf[String])
+
+ securityHeadersFilter.doFilter(request, response, chain)
+ verify(response, atLeastOnce()).addHeader(keyCaptor.capture(),
valueCaptor.capture())
+
keyCaptor.getAllValues.asScala.toList.zip(valueCaptor.getAllValues.asScala.toList)
+ }
+
+ private def createLivyConf(entries: Set[(LivyConf.Entry, String)]) = {
+ val livyConf = new LivyConf(false)
+ entries.foreach({ case (key, value) => livyConf.set(key, value) })
+ livyConf
+ }
+
+ describe("SecurityHeadersFilter") {
+
+ it("should add security headers with overrides") {
+ val responseHeaders = runFilterAndGetResponseHeaders(Set(
+ (LivyConf.SECURITY_HEADERS_XSS_PROTECTION, "xss"),
+ (LivyConf.SECURITY_HEADERS_CONTENT_SECURITY_POLICY, "csp")))
+ assert(requiredHeaders.subsetOf(responseHeaders.map(_._1).toSet))
+ assert(responseHeaders.contains(("X-XSS-Protection", "xss")))
+ assert(responseHeaders.contains(("Content-Security-Policy", "csp")))
+ }
+
+ it("should not add headers that are overridden with empty values") {
+ val responseHeaders = runFilterAndGetResponseHeaders(Set(
+ (LivyConf.SECURITY_HEADERS_XSS_PROTECTION, ""),
+ (LivyConf.SECURITY_HEADERS_CONTENT_SECURITY_POLICY, "")))
+ assert(!responseHeaders.exists(_._1 == "X-XSS-Protection"))
+ assert(!responseHeaders.exists(_._1 == "Content-Security-Policy"))
+ }
+
+ it("should not set HSTS header if TLS is not enabled") {
+ val responseHeaders = runFilterAndGetResponseHeaders(Set.empty)
+ assert(!responseHeaders.exists(_._1 == "Strict-Transport-Security"))
+ }
+
+ it("should set HSTS header if TLS is enabled, value not overridden") {
+ val responseHeaders = runFilterAndGetResponseHeaders(Set(
+ (LivyConf.SSL_KEYSTORE, "/tmp")))
+ assert(responseHeaders.exists(_._1 == "Strict-Transport-Security"))
+ }
+
+ it("should set HSTS header if TLS is enabled, value overridden") {
+ val responseHeaders = runFilterAndGetResponseHeaders(Set(
+ (LivyConf.SSL_KEYSTORE, "/tmp"),
+ (LivyConf.SECURITY_HEADERS_STRICT_TRANSPORT_SECURITY, "sts")))
+ assert(responseHeaders.contains(("Strict-Transport-Security", "sts")))
+ }
+
+ it("should not set HSTS header if TLS is enabled, value overridden with
empty string") {
+ val responseHeaders = runFilterAndGetResponseHeaders(Set(
+ (LivyConf.SSL_KEYSTORE, "/tmp"),
+ (LivyConf.SECURITY_HEADERS_STRICT_TRANSPORT_SECURITY, "")))
+ assert(!responseHeaders.exists(_._1 == "Strict-Transport-Security"))
+ }
+ }
+}