This is an automated email from the ASF dual-hosted git repository.
dongjoon-hyun 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 29ce4448ef4b [SPARK-58049][CORE] Limit the request body size of the
Master REST submission server
29ce4448ef4b is described below
commit 29ce4448ef4b7ee16f259af0c49ded76991c1bf4
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Thu Jul 9 08:00:43 2026 -0700
[SPARK-58049][CORE] Limit the request body size of the Master REST
submission server
### What changes were proposed in this pull request?
`SubmitRequestServlet.doPost` in the Master REST submission server used to
read the
entire request body into a `String` before any validation. This PR bounds
that read
by a new config `spark.master.rest.maxRequestBodySize` (default `100m`): it
rejects
early when `Content-Length` exceeds the limit, and otherwise reads at most
`limit + 1`
bytes so an oversized body is caught even when `Content-Length` is missing
or wrong.
Over-limit requests get HTTP `413` (`SC_REQUEST_ENTITY_TOO_LARGE`).
### Why are the changes needed?
To improve the performance and robustness of Spark Master REST API.
- **Performance**: Reading raw bytes in bulk 8 KiB chunks and decoding once
via `new String(bytes, UTF_8)` is faster than `scala.io.Source`, which iterates
and decodes character-by-character through a `StringBuilder`. It also decodes
as UTF-8 explicitly rather than relying on the platform default charset.
- **Memory**: The body read is now bounded instead of unbounded. Previously
`Source.fromInputStream(...).mkString` buffered the entire request body
regardless of size, so a multi-GB request could OOM the Master. Now at most
limit + 1 bytes (default 100 MiB) are read before an over-limit request is
rejected and discarded. Note that `readNBytes(len)` does not pre-allocate `len`
bytes — it accumulates 8 KiB chunks proportional to the bytes actually read —
so passing Int.MaxValue as the c [...]
### Does this PR introduce _any_ user-facing change?
- The default is generous enough that legitimate submissions are unaffected.
- However, yes, technically. A submit request whose body exceeds
`spark.master.rest.maxRequestBodySize`
(default 100 MiB) is now rejected with HTTP 413. So, the migration guide is
updated.
### How was this patch tested?
Pass the CIs.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 4.8
Closes #57138 from dongjoon-hyun/SPARK-58049.
Authored-by: Dongjoon Hyun <[email protected]>
Signed-off-by: Dongjoon Hyun <[email protected]>
(cherry picked from commit f2346e1327cadc8d5e8208f87aa9886699488532)
Signed-off-by: Dongjoon Hyun <[email protected]>
---
.../spark/deploy/rest/RestSubmissionServer.scala | 33 +++++++++--
.../spark/deploy/rest/StandaloneRestServer.scala | 4 ++
.../org/apache/spark/internal/config/package.scala | 11 ++++
.../deploy/rest/StandaloneRestSubmitSuite.scala | 69 ++++++++++++++++++++--
docs/core-migration-guide.md | 2 +
5 files changed, 111 insertions(+), 8 deletions(-)
diff --git
a/core/src/main/scala/org/apache/spark/deploy/rest/RestSubmissionServer.scala
b/core/src/main/scala/org/apache/spark/deploy/rest/RestSubmissionServer.scala
index ee404ac11a96..2957be2c4372 100644
---
a/core/src/main/scala/org/apache/spark/deploy/rest/RestSubmissionServer.scala
+++
b/core/src/main/scala/org/apache/spark/deploy/rest/RestSubmissionServer.scala
@@ -17,11 +17,10 @@
package org.apache.spark.deploy.rest
+import java.nio.charset.StandardCharsets
import java.util.EnumSet
import java.util.concurrent.{Executors, ExecutorService}
-import scala.io.Source
-
import com.fasterxml.jackson.core.JsonProcessingException
import jakarta.servlet.DispatcherType
import jakarta.servlet.http.{HttpServlet, HttpServletRequest,
HttpServletResponse}
@@ -34,7 +33,7 @@ import org.json4s.jackson.JsonMethods._
import org.apache.spark.{SPARK_VERSION => sparkVersion, SparkConf}
import org.apache.spark.internal.Logging
import org.apache.spark.internal.LogKeys._
-import org.apache.spark.internal.config.{MASTER_REST_SERVER_FILTERS,
MASTER_REST_SERVER_MAX_THREADS, MASTER_REST_SERVER_VIRTUAL_THREADS}
+import org.apache.spark.internal.config.{MASTER_REST_SERVER_FILTERS,
MASTER_REST_SERVER_MAX_REQUEST_BODY_SIZE, MASTER_REST_SERVER_MAX_THREADS,
MASTER_REST_SERVER_VIRTUAL_THREADS}
import org.apache.spark.util.Utils
/**
@@ -43,6 +42,7 @@ import org.apache.spark.util.Utils
* This server responds with different HTTP codes depending on the situation:
* 200 OK - Request was processed successfully
* 400 BAD REQUEST - Request was malformed, not successfully validated, or
of unexpected type
+ * 413 REQUEST ENTITY TOO LARGE - Request body exceeded the configured
maximum size
* 468 UNKNOWN PROTOCOL VERSION - Request specified a protocol this server
does not understand
* 500 INTERNAL SERVER ERROR - Server throws an exception internally while
processing the request
*
@@ -358,6 +358,24 @@ private[rest] abstract class StatusRequestServlet extends
RestServlet {
*/
private[rest] abstract class SubmitRequestServlet extends RestServlet {
+ /** The maximum size in bytes of a request body this servlet will read. */
+ protected def maxRequestSizeBytes: Long =
+ MASTER_REST_SERVER_MAX_REQUEST_BODY_SIZE.defaultValue.get
+
+ /**
+ * Read the request body as a UTF-8 string, or return `None` if it exceeds
`limit` bytes.
+ * The read is bounded so an oversized body cannot exhaust memory even when
the
+ * Content-Length header is missing or understates the actual size.
+ */
+ private def readRequestBody(request: HttpServletRequest, limit: Long):
Option[String] = {
+ if (request.getContentLengthLong > limit) {
+ None
+ } else {
+ val bytes = request.getInputStream.readNBytes(math.min(limit + 1,
Int.MaxValue).toInt)
+ if (bytes.length > limit) None else Some(new String(bytes,
StandardCharsets.UTF_8))
+ }
+ }
+
/**
* Submit an application to the Master with parameters specified in the
request.
*
@@ -368,9 +386,16 @@ private[rest] abstract class SubmitRequestServlet extends
RestServlet {
protected override def doPost(
requestServlet: HttpServletRequest,
responseServlet: HttpServletResponse): Unit = {
+ val limit = maxRequestSizeBytes
+ val requestMessageJson = readRequestBody(requestServlet, limit).getOrElse {
+
responseServlet.setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE)
+ val msg = s"The request body exceeds the maximum allowed size ($limit
bytes). " +
+ s"Adjust ${MASTER_REST_SERVER_MAX_REQUEST_BODY_SIZE.key} to change the
limit."
+ sendResponse(handleError(msg), responseServlet)
+ return
+ }
val responseMessage =
try {
- val requestMessageJson =
Source.fromInputStream(requestServlet.getInputStream).mkString
val requestMessage =
SubmitRestProtocolMessage.fromJson(requestMessageJson)
// The response should have already been validated on the client.
// In case this is not true, validate it ourselves to avoid potential
NPEs.
diff --git
a/core/src/main/scala/org/apache/spark/deploy/rest/StandaloneRestServer.scala
b/core/src/main/scala/org/apache/spark/deploy/rest/StandaloneRestServer.scala
index bb598b042958..400eef2fd3e5 100644
---
a/core/src/main/scala/org/apache/spark/deploy/rest/StandaloneRestServer.scala
+++
b/core/src/main/scala/org/apache/spark/deploy/rest/StandaloneRestServer.scala
@@ -174,6 +174,10 @@ private[rest] class StandaloneSubmitRequestServlet(
conf: SparkConf)
extends SubmitRequestServlet {
+ override protected def maxRequestSizeBytes: Long =
+ Option(conf).map(_.get(config.MASTER_REST_SERVER_MAX_REQUEST_BODY_SIZE))
+ .getOrElse(super.maxRequestSizeBytes)
+
private def replacePlaceHolder(variable: String) = variable match {
case s"{{$name}}" if System.getenv(name) != null => System.getenv(name)
case _ => variable
diff --git a/core/src/main/scala/org/apache/spark/internal/config/package.scala
b/core/src/main/scala/org/apache/spark/internal/config/package.scala
index 79ac503e69be..b658c31753e3 100644
--- a/core/src/main/scala/org/apache/spark/internal/config/package.scala
+++ b/core/src/main/scala/org/apache/spark/internal/config/package.scala
@@ -2106,6 +2106,17 @@ package object config {
.toSequence
.createWithDefault(Nil)
+ private[spark] val MASTER_REST_SERVER_MAX_REQUEST_BODY_SIZE =
+ ConfigBuilder("spark.master.rest.maxRequestBodySize")
+ .doc("The maximum size of the request body accepted by the Spark Master
REST API. " +
+ "Requests whose body exceeds this size are rejected with HTTP 413 " +
+ "(Request Entity Too Large).")
+ .version("4.3.0")
+ .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+ .bytesConf(ByteUnit.BYTE)
+ .checkValue(_ > 0, "The max request body size must be positive.")
+ .createWithDefaultString("100m")
+
private[spark] val MASTER_UI_PORT = ConfigBuilder("spark.master.ui.port")
.version("1.1.0")
.intConf
diff --git
a/core/src/test/scala/org/apache/spark/deploy/rest/StandaloneRestSubmitSuite.scala
b/core/src/test/scala/org/apache/spark/deploy/rest/StandaloneRestSubmitSuite.scala
index bf4a7c4f4d9d..a912926e64d5 100644
---
a/core/src/test/scala/org/apache/spark/deploy/rest/StandaloneRestSubmitSuite.scala
+++
b/core/src/test/scala/org/apache/spark/deploy/rest/StandaloneRestSubmitSuite.scala
@@ -35,7 +35,7 @@ import org.apache.spark.deploy.{SparkSubmit,
SparkSubmitArguments}
import org.apache.spark.deploy.DeployMessages._
import org.apache.spark.deploy.master.DriverState._
import org.apache.spark.deploy.master.RecoveryState
-import
org.apache.spark.internal.config.{MASTER_REST_SERVER_ALLOWED_APP_RESOURCE_PATTERNS,
MASTER_REST_SERVER_FILTERS, MASTER_REST_SERVER_MAX_THREADS,
MASTER_REST_SERVER_VIRTUAL_THREADS}
+import
org.apache.spark.internal.config.{MASTER_REST_SERVER_ALLOWED_APP_RESOURCE_PATTERNS,
MASTER_REST_SERVER_FILTERS, MASTER_REST_SERVER_MAX_REQUEST_BODY_SIZE,
MASTER_REST_SERVER_MAX_THREADS, MASTER_REST_SERVER_VIRTUAL_THREADS}
import org.apache.spark.rpc._
import org.apache.spark.util.ArrayImplicits._
import org.apache.spark.util.Utils
@@ -599,6 +599,62 @@ class StandaloneRestSubmitSuite extends SparkFunSuite {
}
}
+ test("SPARK-58049: Reject an over-sized request body with
SC_REQUEST_ENTITY_TOO_LARGE") {
+ val conf = new SparkConf()
+ conf.set(MASTER_REST_SERVER_MAX_REQUEST_BODY_SIZE.key, "1k")
+ val localhost = Utils.localHostName()
+ val securityManager = new SecurityManager(conf)
+ rpcEnv =
+ Some(RpcEnv.create("rest-with-maxRequestBodySize", localhost, 0, conf,
securityManager))
+ val fakeMasterRef = rpcEnv.get.setupEndpoint("fake-master", new
DummyMaster(rpcEnv.get))
+ val _server = new StandaloneRestServer(localhost, 0, conf, fakeMasterRef,
"spark://fake:7077")
+ server = Some(_server)
+ val port = _server.start()
+ val v = RestSubmissionServer.PROTOCOL_VERSION
+ val path = s"http://$localhost:$port/$v/submissions/create"
+ val (response, code) = sendHttpRequestWithResponse(path, "POST", "x" *
4096)
+ assert(code === HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE)
+ getErrorResponse(response)
+ }
+
+ test("SPARK-58049: Reject an over-sized request body without
Content-Length") {
+ val conf = new SparkConf()
+ conf.set(MASTER_REST_SERVER_MAX_REQUEST_BODY_SIZE.key, "1k")
+ val localhost = Utils.localHostName()
+ val securityManager = new SecurityManager(conf)
+ rpcEnv =
+ Some(RpcEnv.create("rest-with-maxRequestBodySize", localhost, 0, conf,
securityManager))
+ val fakeMasterRef = rpcEnv.get.setupEndpoint("fake-master", new
DummyMaster(rpcEnv.get))
+ val _server = new StandaloneRestServer(localhost, 0, conf, fakeMasterRef,
"spark://fake:7077")
+ server = Some(_server)
+ val port = _server.start()
+ val v = RestSubmissionServer.PROTOCOL_VERSION
+ val path = s"http://$localhost:$port/$v/submissions/create"
+ val (response, code) = sendHttpRequestWithResponse(path, "POST", "x" *
4096, chunked = true)
+ assert(code === HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE)
+ getErrorResponse(response)
+ }
+
+ test("SPARK-58049: Accept a request body exactly at the limit") {
+ val json = constructSubmitRequest("spark://localhost:7077").toJson
+ val limit = json.getBytes(StandardCharsets.UTF_8).length
+ val conf = new SparkConf()
+ conf.set(MASTER_REST_SERVER_MAX_REQUEST_BODY_SIZE.key, limit.toString)
+ val localhost = Utils.localHostName()
+ val securityManager = new SecurityManager(conf)
+ rpcEnv =
+ Some(RpcEnv.create("rest-with-maxRequestBodySize", localhost, 0, conf,
securityManager))
+ val fakeMasterRef = rpcEnv.get.setupEndpoint("fake-master", new
DummyMaster(rpcEnv.get))
+ val _server = new StandaloneRestServer(localhost, 0, conf, fakeMasterRef,
"spark://fake:7077")
+ server = Some(_server)
+ val port = _server.start()
+ val v = RestSubmissionServer.PROTOCOL_VERSION
+ val path = s"http://$localhost:$port/$v/submissions/create"
+ val (response, code) = sendHttpRequestWithResponse(path, "POST", json)
+ assert(code === HttpServletResponse.SC_OK)
+ getSubmitResponse(response)
+ }
+
/* --------------------- *
| Helper methods |
* --------------------- */
@@ -740,11 +796,15 @@ class StandaloneRestSubmitSuite extends SparkFunSuite {
private def sendHttpRequest(
url: String,
method: String,
- body: String = ""): HttpURLConnection = {
+ body: String = "",
+ chunked: Boolean = false): HttpURLConnection = {
val conn = new
URI(url).toURL.openConnection().asInstanceOf[HttpURLConnection]
conn.setRequestMethod(method)
if (body.nonEmpty) {
conn.setDoOutput(true)
+ if (chunked) {
+ conn.setChunkedStreamingMode(0)
+ }
val out = new DataOutputStream(conn.getOutputStream)
out.write(body.getBytes(StandardCharsets.UTF_8))
out.close()
@@ -759,8 +819,9 @@ class StandaloneRestSubmitSuite extends SparkFunSuite {
private def sendHttpRequestWithResponse(
url: String,
method: String,
- body: String = ""): (SubmitRestProtocolResponse, Int) = {
- val conn = sendHttpRequest(url, method, body)
+ body: String = "",
+ chunked: Boolean = false): (SubmitRestProtocolResponse, Int) = {
+ val conn = sendHttpRequest(url, method, body, chunked)
(new RestSubmissionClient("spark://host:port").readResponse(conn),
conn.getResponseCode)
}
}
diff --git a/docs/core-migration-guide.md b/docs/core-migration-guide.md
index e80d04238ca0..f91abaa9ddca 100644
--- a/docs/core-migration-guide.md
+++ b/docs/core-migration-guide.md
@@ -36,6 +36,8 @@ license: |
- 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` (the default). When CSP is
disabled, `X-Frame-Options: SAMEORIGIN` is always used regardless of the
`allowFramingFrom` value.
+- Since Spark 4.3, the Spark Master REST API rejects a submission whose
request body exceeds `spark.master.rest.maxRequestBodySize` (default `100m`)
with HTTP 413. To allow larger request bodies, increase
`spark.master.rest.maxRequestBodySize`.
+
## 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`.
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]