Skip to content

Commit

Permalink
[SPARK-49090][CORE] Support JWSFilter
Browse files Browse the repository at this point in the history
### What changes were proposed in this pull request?

This PR aims to support `JWSFilter`  which is a servlet filter that requires `JWS`, a cryptographically signed JSON Web Token, in the header via `spark.ui.filters` configuration.

- spark.ui.filters=org.apache.spark.ui.JWSFilter
- spark.org.apache.spark.ui.JWSFilter.param.key=YOUR-BASE64URL-ENCODED-KEY

To simply put, `JWSFilter` will check the following for all requests.
- The HTTP request should have `Authorization: Bearer <jws>` header.
  - `<jws>` is a string with three fields, `<header>.<payload>.<signature>`.
  - `<header>` is supposed to be a base64url-encoded string of `{"alg":"HS256","typ":"JWT"}`.
  - `<payload>` is a base64url-encoded string of fully-user-defined content.
  - `<signature>` is a signature based on `<header>.<payload>` and a user-provided key parameter.

For example, the value of `<header>` will be `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9` always and the value of `payload` can be `e30` if the payload is empty, `{}`. The `<signature>` part is changed by the shared value of `spark.org.apache.spark.ui.JWSFilter.param.key` between the server and client.
```
jshell> java.util.Base64.getUrlEncoder().encodeToString("{\"alg\":\"HS256\",\"typ\":\"JWT\"}".getBytes())
$2 ==> "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"

jshell> java.util.Base64.getUrlEncoder().encodeToString("{}".getBytes())
$3 ==> "e30="
```

### Why are the changes needed?

To provide a little better security on WebUI consistently including Spark Standalone Clusters.

For example,

**SETTING**
```
$ jshell
|  Welcome to JShell -- Version 17.0.12
|  For an introduction type: /help intro

jshell> java.util.Base64.getUrlEncoder().encodeToString("Visit https://spark.apache.org to download Apache Spark.".getBytes())
$1 ==> "VmlzaXQgaHR0cHM6Ly9zcGFyay5hcGFjaGUub3JnIHRvIGRvd25sb2FkIEFwYWNoZSBTcGFyay4="
```

```
$ cat conf/spark-defaults.conf
spark.ui.filters org.apache.spark.ui.JWSFilter
spark.org.apache.spark.ui.JWSFilter.param.key VmlzaXQgaHR0cHM6Ly9zcGFyay5hcGFjaGUub3JnIHRvIGRvd25sb2FkIEFwYWNoZSBTcGFyay4=
```

**SPARK-SHELL**
```
$ build/sbt package
$ cp jjwt-impl-0.12.6.jar assembly/target/scala-2.13/jars
$ cp jjwt-jackson-0.12.6.jar assembly/target/scala-2.13/jars
$ bin/spark-shell
```

Without JWS (ErrorCode: 403 Forbidden)
```
$ curl -v http://localhost:4040/
* Host localhost:4040 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:4040...
* connect to ::1 port 4040 from ::1 port 61313 failed: Connection refused
*   Trying 127.0.0.1:4040...
* Connected to localhost (127.0.0.1) port 4040
> GET / HTTP/1.1
> Host: localhost:4040
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 403 Forbidden
< Date: Fri, 02 Aug 2024 01:27:23 GMT
< Cache-Control: must-revalidate,no-cache,no-store
< Content-Type: text/html;charset=iso-8859-1
< Content-Length: 472
<
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=ISO-8859-1"/>
<title>Error 403 Authorization header is missing.</title>
</head>
<body><h2>HTTP ERROR 403 Authorization header is missing.</h2>
<table>
<tr><th>URI:</th><td>/</td></tr>
<tr><th>STATUS:</th><td>403</td></tr>
<tr><th>MESSAGE:</th><td>Authorization header is missing.</td></tr>
<tr><th>SERVLET:</th><td>org.apache.spark.ui.JettyUtils$$anon$2-3b39bee2</td></tr>
</table>

</body>
</html>
* Connection #0 to host localhost left intact
```

With JWS,
```
$ curl -v -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.4EKWlOkobpaAPR0J4BE0cPQ-ZD1tRQKLZp1vtE7upPw" http://localhost:4040/
* Host localhost:4040 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:4040...
* connect to ::1 port 4040 from ::1 port 61311 failed: Connection refused
*   Trying 127.0.0.1:4040...
* Connected to localhost (127.0.0.1) port 4040
> GET / HTTP/1.1
> Host: localhost:4040
> User-Agent: curl/8.7.1
> Accept: */*
> Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.4EKWlOkobpaAPR0J4BE0cPQ-ZD1tRQKLZp1vtE7upPw
>
* Request completely sent off
< HTTP/1.1 302 Found
< Date: Fri, 02 Aug 2024 01:27:01 GMT
< Cache-Control: no-cache, no-store, must-revalidate
< X-Frame-Options: SAMEORIGIN
< X-XSS-Protection: 1; mode=block
< X-Content-Type-Options: nosniff
< Location: http://localhost:4040/jobs/
< Content-Length: 0
<
* Connection #0 to host localhost left intact
```

**SPARK MASTER**

Without JWS (ErrorCode: 403 Forbidden)
```
$ curl -v http://localhost:8080/json/
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8080...
* connect to ::1 port 8080 from ::1 port 61331 failed: Connection refused
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080
> GET /json/ HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 403 Forbidden
< Date: Fri, 02 Aug 2024 01:34:03 GMT
< Cache-Control: must-revalidate,no-cache,no-store
< Content-Type: text/html;charset=iso-8859-1
< Content-Length: 477
<
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=ISO-8859-1"/>
<title>Error 403 Authorization header is missing.</title>
</head>
<body><h2>HTTP ERROR 403 Authorization header is missing.</h2>
<table>
<tr><th>URI:</th><td>/json/</td></tr>
<tr><th>STATUS:</th><td>403</td></tr>
<tr><th>MESSAGE:</th><td>Authorization header is missing.</td></tr>
<tr><th>SERVLET:</th><td>org.apache.spark.ui.JettyUtils$$anon$1-6c52101f</td></tr>
</table>

</body>
</html>
* Connection #0 to host localhost left intact
```

With JWS
```
$ curl -v -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.4EKWlOkobpaAPR0J4BE0cPQ-ZD1tRQKLZp1vtE7upPw" http://localhost:8080/json/

* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8080...
* connect to ::1 port 8080 from ::1 port 61329 failed: Connection refused
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080
> GET /json/ HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.7.1
> Accept: */*
> Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.4EKWlOkobpaAPR0J4BE0cPQ-ZD1tRQKLZp1vtE7upPw
>
* Request completely sent off
< HTTP/1.1 200 OK
< Date: Fri, 02 Aug 2024 01:33:10 GMT
< Cache-Control: no-cache, no-store, must-revalidate
< X-Frame-Options: SAMEORIGIN
< X-XSS-Protection: 1; mode=block
< X-Content-Type-Options: nosniff
< Content-Type: text/json;charset=utf-8
< Vary: Accept-Encoding
< Content-Length: 320
<
{
  "url" : "spark://M3-Max.local:7077",
  "workers" : [ ],
  "aliveworkers" : 0,
  "cores" : 0,
  "coresused" : 0,
  "memory" : 0,
  "memoryused" : 0,
  "resources" : [ ],
  "resourcesused" : [ ],
  "activeapps" : [ ],
  "completedapps" : [ ],
  "activedrivers" : [ ],
  "completeddrivers" : [ ],
  "status" : "ALIVE"
* Connection #0 to host localhost left intact
}%
```

### Does this PR introduce _any_ user-facing change?

No, this is a new filter.

### How was this patch tested?

Pass the CIs.

### Was this patch authored or co-authored using generative AI tooling?

No.

Closes #47575 from dongjoon-hyun/SPARK-49090.

Lead-authored-by: Dongjoon Hyun <[email protected]>
Co-authored-by: Dongjoon Hyun <[email protected]>
Signed-off-by: Dongjoon Hyun <[email protected]>
  • Loading branch information
dongjoon-hyun and dongjoon-hyun committed Aug 2, 2024
1 parent 080e7eb commit 3da31b0
Show file tree
Hide file tree
Showing 5 changed files with 211 additions and 0 deletions.
1 change: 1 addition & 0 deletions LICENSE-binary
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ io.fabric8:kubernetes-model-scheduling
io.fabric8:kubernetes-model-storageclass
io.fabric8:zjsonpatch
io.github.java-diff-utils:java-diff-utils
io.jsonwebtoken:jjwt-api
io.netty:netty-all
io.netty:netty-buffer
io.netty:netty-codec
Expand Down
17 changes: 17 additions & 0 deletions core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,23 @@
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>test</scope>
</dependency>

<!-- Jetty dependencies promoted to compile here so they are shaded
and inlined into spark-core jar -->
Expand Down
80 changes: 80 additions & 0 deletions core/src/main/scala/org/apache/spark/ui/JWSFilter.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* 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 javax.crypto.SecretKey

import io.jsonwebtoken.{JwtException, Jwts}
import io.jsonwebtoken.io.Decoders
import io.jsonwebtoken.security.Keys
import jakarta.servlet.{Filter, FilterChain, FilterConfig, ServletRequest, ServletResponse}
import jakarta.servlet.http.{HttpServletRequest, HttpServletResponse}

/**
* A servlet filter that requires JWS, a cryptographically signed JSON Web Token, in the header.
*
* Like the other UI filters, the following configurations are required to use this filter.
* {{{
* - spark.ui.filters=org.apache.spark.ui.JWSFilter
* - spark.org.apache.spark.ui.JWSFilter.param.key=BASE64URL-ENCODED-YOUR-PROVIDED-KEY
* }}}
* The HTTP request should have {@code Authorization: Bearer <jws>} header.
* {{{
* - <jws> is a string with three fields, '<header>.<payload>.<signature>'.
* - <header> is supposed to be a base64url-encoded string of '{"alg":"HS256","typ":"JWT"}'.
* - <payload> is a base64url-encoded string of fully-user-defined content.
* - <signature> is a signature based on '<header>.<payload>' and a user-provided key parameter.
* }}}
*/
private class JWSFilter extends Filter {
private val AUTHORIZATION = "Authorization"

private var key: SecretKey = null

/**
* Load and validate the configurtions:
* - IllegalArgumentException will happen if the user didn't provide this argument
* - WeakKeyException will happen if the user-provided value is insufficient
*/
override def init(config: FilterConfig): Unit = {
key = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(config.getInitParameter("key")));
}

override def doFilter(req: ServletRequest, res: ServletResponse, chain: FilterChain): Unit = {
val hreq = req.asInstanceOf[HttpServletRequest]
val hres = res.asInstanceOf[HttpServletResponse]
hres.setHeader("Cache-Control", "no-cache, no-store, must-revalidate")

try {
val header = hreq.getHeader(AUTHORIZATION)
header match {
case null =>
hres.sendError(HttpServletResponse.SC_FORBIDDEN, s"${AUTHORIZATION} header is missing.")
case s"Bearer $token" =>
val claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(token)
chain.doFilter(req, res)
case _ =>
hres.sendError(HttpServletResponse.SC_FORBIDDEN, s"Malformed ${AUTHORIZATION} header.")
}
} catch {
case e: JwtException =>
// We intentionally don't expose the detail of JwtException here
hres.sendError(HttpServletResponse.SC_FORBIDDEN, "JWT Validate Fail")
}
}
}
112 changes: 112 additions & 0 deletions core/src/test/scala/org/apache/spark/ui/JWSFilterSuite.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* 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.{Base64, HashMap => JHashMap}

import scala.jdk.CollectionConverters._

import jakarta.servlet.{FilterChain, FilterConfig, ServletContext}
import jakarta.servlet.http.{HttpServletRequest, HttpServletResponse}
import org.mockito.ArgumentMatchers.{any, eq => meq}
import org.mockito.Mockito.{mock, times, verify, when}

import org.apache.spark._

class JWSFilterSuite extends SparkFunSuite {
// {"alg":"HS256","typ":"JWT"} => eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9, {} => e30
private val TOKEN =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.4EKWlOkobpaAPR0J4BE0cPQ-ZD1tRQKLZp1vtE7upPw"

private val TEST_KEY = Base64.getUrlEncoder.encodeToString(
"Visit https://spark.apache.org to download Apache Spark.".getBytes())

test("Should fail when a parameter is missing") {
val filter = new JWSFilter()
val params = new JHashMap[String, String]
val m = intercept[IllegalArgumentException] {
filter.init(new DummyFilterConfig(params))
}.getMessage()
assert(m.contains("Decode argument cannot be null"))
}

test("Succeed to initialize") {
val filter = new JWSFilter()
val params = new JHashMap[String, String]
params.put("key", TEST_KEY)
filter.init(new DummyFilterConfig(params))
}

test("Should response with SC_FORBIDDEN when it cannot verify JWS") {
val req = mockRequest()
val res = mock(classOf[HttpServletResponse])
val chain = mock(classOf[FilterChain])

val filter = new JWSFilter()
val params = new JHashMap[String, String]
params.put("key", TEST_KEY)
val conf = new DummyFilterConfig(params)
filter.init(conf)

// 'Authorization' header is missing
filter.doFilter(req, res, chain)
verify(res).sendError(meq(HttpServletResponse.SC_FORBIDDEN),
meq("Authorization header is missing."))
verify(chain, times(0)).doFilter(any(), any())

// The value of Authorization field is not 'Bearer <token>' style.
when(req.getHeader("Authorization")).thenReturn("Invalid")
filter.doFilter(req, res, chain)
verify(res).sendError(meq(HttpServletResponse.SC_FORBIDDEN),
meq("Malformed Authorization header."))
verify(chain, times(0)).doFilter(any(), any())
}

test("Should succeed on valid JWS") {
val req = mockRequest()
val res = mock(classOf[HttpServletResponse])
val chain = mock(classOf[FilterChain])

val filter = new JWSFilter()
val params = new JHashMap[String, String]
params.put("key", TEST_KEY)
val conf = new DummyFilterConfig(params)
filter.init(conf)

when(req.getHeader("Authorization")).thenReturn(s"Bearer $TOKEN")
filter.doFilter(req, res, chain)
verify(chain, times(1)).doFilter(any(), any())
}

private def mockRequest(params: Map[String, Array[String]] = Map()): HttpServletRequest = {
val req = mock(classOf[HttpServletRequest])
when(req.getParameterMap()).thenReturn(params.asJava)
req
}

class DummyFilterConfig (val map: java.util.Map[String, String]) extends FilterConfig {
override def getFilterName: String = "dummy"

override def getInitParameter(arg0: String): String = map.get(arg0)

override def getInitParameterNames: java.util.Enumeration[String] =
java.util.Collections.enumeration(map.keySet)

override def getServletContext: ServletContext = null
}
}
1 change: 1 addition & 0 deletions dev/deps/spark-deps-hadoop-3-hive-2.3
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ jersey-server/3.0.12//jersey-server-3.0.12.jar
jettison/1.5.4//jettison-1.5.4.jar
jetty-util-ajax/11.0.21//jetty-util-ajax-11.0.21.jar
jetty-util/11.0.21//jetty-util-11.0.21.jar
jjwt-api/0.12.6//jjwt-api-0.12.6.jar
jline/2.14.6//jline-2.14.6.jar
jline/3.25.1//jline-3.25.1.jar
jna/5.14.0//jna-5.14.0.jar
Expand Down

0 comments on commit 3da31b0

Please sign in to comment.