Skip to content

Commit f580e71

Browse files
[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
1 parent 826ec29 commit f580e71

File tree

5 files changed

+192
-0
lines changed

5 files changed

+192
-0
lines changed

conf/livy.conf.template

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,20 @@
102102
# http-header "X-Requested-By" in request if the http method is POST/DELETE/PUT/PATCH.
103103
# livy.server.csrf-protection.enabled =
104104

105+
# Whether to add security related HTTP headers to responses, by default true. If enabled,
106+
# Livy server adds HTTP headers to responses based on below configuration parameters starting with
107+
# `Livy.server.http.header.`
108+
# livy.server.security-headers.enabled = true
109+
110+
# Security headers added to responses by default when
111+
# configuration `livy.server.security-headers.enabled` is set to true.
112+
# STS header is only added if TLS is enabled.
113+
# livy.server.http.header.X-XSS-Protection = 1; mode=block
114+
# livy.server.http.header.X-Frame-Options = SAMEORIGIN
115+
# livy.server.http.header.X-Content-Type-Options = nosniff
116+
# livy.server.http.header.Strict-Transport-Security = max-age=31536000; includeSubDomains
117+
# livy.server.http.header.Content-Security-Policy = default-src 'self'; script-src 'self' 'unsafe-inline'; img-src 'self'; frame-src 'self';
118+
105119
# Whether to enable HiveContext in livy interpreter, if it is true hive-site.xml will be detected
106120
# on user request and then livy server classpath automatically.
107121
# livy.repl.enable-hive-context =

server/src/main/scala/org/apache/livy/LivyConf.scala

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,21 @@ object LivyConf {
7171

7272
val CSRF_PROTECTION = Entry("livy.server.csrf-protection.enabled", false)
7373

74+
val SECURITY_HEADERS_ENABLED = Entry("livy.server.security-headers.enabled", true)
75+
val SECURITY_HEADERS_XSS_PROTECTION =
76+
Entry("livy.server.http.header.X-XSS-Protection", "1; mode=block")
77+
val SECURITY_HEADERS_FRAME_OPTIONS =
78+
Entry("livy.server.http.header.X-Frame-Options", "SAMEORIGIN")
79+
val SECURITY_HEADERS_CONTENT_TYPE_OPTIONS =
80+
Entry("livy.server.http.header.X-Content-Type-Options", "nosniff")
81+
val SECURITY_HEADERS_STRICT_TRANSPORT_SECURITY =
82+
Entry("livy.server.http.header.Strict-Transport-Security",
83+
"max-age=31536000; includeSubDomains")
84+
val SECURITY_HEADERS_CONTENT_SECURITY_POLICY =
85+
Entry("livy.server.http.header.Content-Security-Policy",
86+
"default-src 'self'; script-src 'self' 'unsafe-inline'; img-src 'self'; " +
87+
"frame-src 'self';")
88+
7489
val IMPERSONATION_ENABLED = Entry("livy.impersonation.enabled", false)
7590
val SUPERUSERS = Entry("livy.superusers", null)
7691

server/src/main/scala/org/apache/livy/server/LivyServer.scala

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,12 @@ class LivyServer extends Logging {
251251

252252
})
253253

254+
if (livyConf.getBoolean(SECURITY_HEADERS_ENABLED)) {
255+
info("Adding security headers is enabled.")
256+
val securityHeadersHolder = new FilterHolder(new SecurityHeadersFilter(livyConf))
257+
server.context.addFilter(securityHeadersHolder, "/*", EnumSet.allOf(classOf[DispatcherType]))
258+
}
259+
254260
livyConf.get(AUTH_TYPE) match {
255261
case authType @ KerberosAuthenticationHandler.TYPE =>
256262
val principal = SecurityUtil.getServerPrincipal(livyConf.get(AUTH_KERBEROS_PRINCIPAL),
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.livy.server
19+
20+
import javax.servlet.{Filter, FilterChain, FilterConfig, ServletRequest, ServletResponse}
21+
import javax.servlet.http.HttpServletResponse
22+
23+
import org.apache.livy.LivyConf
24+
25+
/**
26+
* Adds security related headers to HTTP responses.
27+
*/
28+
class SecurityHeadersFilter(livyConf: LivyConf) extends Filter {
29+
30+
private def isSslEnabled: Boolean = {
31+
Option(livyConf.get(LivyConf.SSL_KEYSTORE)).exists(_.length > 0)
32+
}
33+
34+
val headers : Map[String, String] = Map(
35+
"X-Content-Type-Options" -> livyConf.get(LivyConf.SECURITY_HEADERS_CONTENT_TYPE_OPTIONS),
36+
"X-Frame-Options" -> livyConf.get(LivyConf.SECURITY_HEADERS_FRAME_OPTIONS),
37+
"X-XSS-Protection" -> livyConf.get(LivyConf.SECURITY_HEADERS_XSS_PROTECTION),
38+
"Strict-Transport-Security" ->
39+
(if (isSslEnabled) livyConf.get(LivyConf.SECURITY_HEADERS_STRICT_TRANSPORT_SECURITY) else ""),
40+
"Content-Security-Policy" -> livyConf.get(LivyConf.SECURITY_HEADERS_CONTENT_SECURITY_POLICY))
41+
.filter(e => e._2 != null && e._2.trim().length > 0)
42+
43+
override def init(filterConfig: FilterConfig): Unit = {}
44+
45+
override def doFilter(request: ServletRequest,
46+
response: ServletResponse,
47+
chain: FilterChain): Unit = {
48+
val servletResponse = response.asInstanceOf[HttpServletResponse]
49+
for ((k, v) <- headers) {
50+
servletResponse.addHeader(k, v)
51+
}
52+
chain.doFilter(request, response)
53+
}
54+
55+
override def destroy(): Unit = {}
56+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.livy.server
19+
20+
import javax.servlet.FilterChain
21+
import javax.servlet.http.{HttpServletRequest, HttpServletResponse}
22+
23+
import org.mockito.ArgumentCaptor
24+
import org.mockito.Mockito.{atLeastOnce, verify}
25+
import org.scalatest.{FunSpec, Matchers}
26+
import org.scalatestplus.mockito.MockitoSugar.mock
27+
28+
import org.apache.livy.{LivyBaseUnitTestSuite, LivyConf}
29+
30+
class SecurityHeadersFilterSpec extends FunSpec with Matchers with LivyBaseUnitTestSuite {
31+
32+
val requiredHeaders = Set("X-Content-Type-Options", "X-Frame-Options", "X-XSS-Protection",
33+
"Content-Security-Policy")
34+
35+
private def runFilterAndGetResponseHeaders(configEntries: Set[(LivyConf.Entry, String)]):
36+
List[(String, String)] = {
37+
import scala.collection.JavaConverters._
38+
val livyConf = createLivyConf(configEntries)
39+
val securityHeadersFilter = new SecurityHeadersFilter(livyConf)
40+
val response = mock[HttpServletResponse]
41+
val request = mock[HttpServletRequest]
42+
val chain = mock[FilterChain]
43+
val keyCaptor = ArgumentCaptor.forClass(classOf[String])
44+
val valueCaptor = ArgumentCaptor.forClass(classOf[String])
45+
46+
securityHeadersFilter.doFilter(request, response, chain)
47+
verify(response, atLeastOnce()).addHeader(keyCaptor.capture(), valueCaptor.capture())
48+
keyCaptor.getAllValues.asScala.toList.zip(valueCaptor.getAllValues.asScala.toList)
49+
}
50+
51+
private def createLivyConf(entries: Set[(LivyConf.Entry, String)]) = {
52+
val livyConf = new LivyConf(false)
53+
entries.foreach({ case (key, value) => livyConf.set(key, value) })
54+
livyConf
55+
}
56+
57+
describe("SecurityHeadersFilter") {
58+
59+
it("should add security headers with overrides") {
60+
val responseHeaders = runFilterAndGetResponseHeaders(Set(
61+
(LivyConf.SECURITY_HEADERS_XSS_PROTECTION, "xss"),
62+
(LivyConf.SECURITY_HEADERS_CONTENT_SECURITY_POLICY, "csp")))
63+
assert(requiredHeaders.subsetOf(responseHeaders.map(_._1).toSet))
64+
assert(responseHeaders.contains(("X-XSS-Protection", "xss")))
65+
assert(responseHeaders.contains(("Content-Security-Policy", "csp")))
66+
}
67+
68+
it("should not add headers that are overridden with empty values") {
69+
val responseHeaders = runFilterAndGetResponseHeaders(Set(
70+
(LivyConf.SECURITY_HEADERS_XSS_PROTECTION, ""),
71+
(LivyConf.SECURITY_HEADERS_CONTENT_SECURITY_POLICY, "")))
72+
assert(!responseHeaders.exists(_._1 == "X-XSS-Protection"))
73+
assert(!responseHeaders.exists(_._1 == "Content-Security-Policy"))
74+
}
75+
76+
it("should not set HSTS header if TLS is not enabled") {
77+
val responseHeaders = runFilterAndGetResponseHeaders(Set.empty)
78+
assert(!responseHeaders.exists(_._1 == "Strict-Transport-Security"))
79+
}
80+
81+
it("should set HSTS header if TLS is enabled, value not overridden") {
82+
val responseHeaders = runFilterAndGetResponseHeaders(Set(
83+
(LivyConf.SSL_KEYSTORE, "/tmp")))
84+
assert(responseHeaders.exists(_._1 == "Strict-Transport-Security"))
85+
}
86+
87+
it("should set HSTS header if TLS is enabled, value overridden") {
88+
val responseHeaders = runFilterAndGetResponseHeaders(Set(
89+
(LivyConf.SSL_KEYSTORE, "/tmp"),
90+
(LivyConf.SECURITY_HEADERS_STRICT_TRANSPORT_SECURITY, "sts")))
91+
assert(responseHeaders.contains(("Strict-Transport-Security", "sts")))
92+
}
93+
94+
it("should not set HSTS header if TLS is enabled, value overridden with empty string") {
95+
val responseHeaders = runFilterAndGetResponseHeaders(Set(
96+
(LivyConf.SSL_KEYSTORE, "/tmp"),
97+
(LivyConf.SECURITY_HEADERS_STRICT_TRANSPORT_SECURITY, "")))
98+
assert(!responseHeaders.exists(_._1 == "Strict-Transport-Security"))
99+
}
100+
}
101+
}

0 commit comments

Comments
 (0)