Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[2.8.x] add depth limit for JSON form binding #11301

Merged
merged 1 commit into from May 31, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
129 changes: 94 additions & 35 deletions core/play/src/main/scala/play/api/data/Form.scala
Expand Up @@ -89,7 +89,7 @@ case class Form[T](mapping: Mapping[T], data: Map[String, String], errors: Seq[F
logger.warn(
s"Binding json field from form with a hardcoded max size of ${Form.FromJsonMaxChars} bytes. This is deprecated. Use bind(JsValue, Int) instead."
)
bind(FormUtils.fromJson(data, Form.FromJsonMaxChars))
bind(FormUtils.fromJson(data, Form.FromJsonMaxChars, Form.FromJsonMaxDepth))
}

/**
Expand All @@ -100,7 +100,18 @@ case class Form[T](mapping: Mapping[T], data: Map[String, String], errors: Seq[F
* of the JSON. `parse.DefaultMaxTextLength` is recommended to passed for this parameter.
* @return a copy of this form, filled with the new data
*/
def bind(data: JsValue, maxChars: Long): Form[T] = bind(FormUtils.fromJson(data, maxChars))
def bind(data: JsValue, maxChars: Long): Form[T] = bind(FormUtils.fromJson(data, maxChars, Form.FromJsonMaxDepth))

/**
* Binds data to this form, i.e. handles form submission.
*
* @param data Json data to submit
* @param maxChars The maximum number of chars allowed to be used in the intermediate map representation
* of the JSON. `parse.DefaultMaxTextLength` is recommended to passed for this parameter.
* @param maxDepth The maximum level of nesting for JSON objects and arrays.
* @return a copy of this form, filled with the new data
*/
def bind(data: JsValue, maxChars: Long, maxDepth: Int): Form[T] = bind(FormUtils.fromJson(data, maxChars, maxDepth))

/**
* Binds request data to this form, i.e. handles form submission.
Expand Down Expand Up @@ -358,7 +369,15 @@ object Form {
* JSON. Defaults to 100k which is the default of parser.maxMemoryBuffer
*/
@InternalApi
val FromJsonMaxChars: Long = 102400
final val FromJsonMaxChars: Long = 102400

/**
* INTERNAL API
*
* Default maximum depth of objects and arrays supported in JSON forms
*/
@InternalApi
final val FromJsonMaxDepth: Int = 64

/**
* Creates a new form from a mapping.
Expand Down Expand Up @@ -408,54 +427,87 @@ object Form {
}

private[data] object FormUtils {
def fromJson(js: JsValue, maxChars: Long): Map[String, String] = doFromJson(FromJsonRoot(js), Map.empty, 0, maxChars)
@deprecated("Use fromJson with maxDepth parameter", "2.8.16")
def fromJson(js: JsValue, maxChars: Long): Map[String, String] =
doFromJson(FromJsonRoot(js), Map.empty, 0, maxChars, Form.FromJsonMaxDepth)

def fromJson(js: JsValue, maxChars: Long, maxDepth: Int): Map[String, String] =
doFromJson(FromJsonRoot(js), Map.empty, 0, maxChars, maxDepth)

@annotation.tailrec
private def doFromJson(
context: FromJsonContext,
form: Map[String, String],
cumulativeChars: Int,
maxChars: Long
): Map[String, String] = context match {
case FromJsonTerm => form
case ctx: FromJsonContextValue =>
// Ensure this contexts next is initialised, this prevents unbounded recursion.
ctx.next
ctx.value match {
case obj: JsObject if obj.fields.nonEmpty =>
doFromJson(FromJsonObject(ctx, obj.fields.toIndexedSeq, 0), form, cumulativeChars, maxChars)
case JsArray(values) if values.nonEmpty =>
doFromJson(FromJsonArray(ctx, values, 0), form, cumulativeChars, maxChars)
case JsNull | JsArray(_) | JsObject(_) =>
doFromJson(ctx.next, form, cumulativeChars, maxChars)
case simple =>
val value = simple match {
case JsString(v) => v
case JsNumber(v) => v.toString
case JsBoolean(v) => v.toString
}
val prefix = ctx.prefix
val newCumulativeChars = cumulativeChars + prefix.length + value.length
if (newCumulativeChars > maxChars) {
throw FormJsonExpansionTooLarge(maxChars)
}
doFromJson(ctx.next, form.updated(prefix, value), newCumulativeChars, maxChars)
}
maxChars: Long,
maxDepth: Int,
): Map[String, String] = {
if (cumulativeChars > maxChars)
throw FormJsonExpansionTooLarge(maxChars)
if (context.depth > maxDepth)
throw FormJsonExpansionTooDeep(maxDepth)
context match {
case FromJsonTerm => form
case ctx: FromJsonContextValue =>
// Ensure this contexts next is initialised, this prevents unbounded recursion.
ctx.next
ctx.value match {
case obj: JsObject if obj.fields.nonEmpty =>
doFromJson(
FromJsonObject(ctx, obj.fields.toIndexedSeq, 0),
form,
cumulativeChars,
maxChars,
maxDepth
)
case JsArray(values) if values.nonEmpty =>
doFromJson(
FromJsonArray(ctx, values, 0),
form,
cumulativeChars,
maxChars,
maxDepth
)
case JsNull | JsArray(_) | JsObject(_) =>
doFromJson(
ctx.next,
form,
cumulativeChars,
maxChars,
maxDepth
)
case simple =>
val value = simple match {
case JsString(v) => v
case JsNumber(v) => v.toString
case JsBoolean(v) => v.toString
}
val prefix = ctx.prefix
val newCumulativeChars = cumulativeChars + prefix.length + value.length
doFromJson(ctx.next, form.updated(prefix, value), newCumulativeChars, maxChars, maxDepth)
}
}
}

private sealed trait FromJsonContext
private sealed trait FromJsonContext {
def depth: Int
}
private sealed trait FromJsonContextValue extends FromJsonContext {
def value: JsValue
def prefix: String
def next: FromJsonContext
}
private case object FromJsonTerm extends FromJsonContext
private case object FromJsonTerm extends FromJsonContext {
override def depth: Int = 0
}
private case class FromJsonRoot(value: JsValue) extends FromJsonContextValue {
override def depth: Int = 0
override def prefix = ""
override def next: FromJsonContext = FromJsonTerm
}
private case class FromJsonArray(parent: FromJsonContextValue, values: scala.collection.IndexedSeq[JsValue], idx: Int)
extends FromJsonContextValue {
override val depth: Int = parent.depth + 1
override def value: JsValue = values(idx)
override val prefix: String = s"${parent.prefix}[$idx]"
override lazy val next: FromJsonContext = if (idx + 1 < values.length) {
Expand All @@ -466,6 +518,7 @@ private[data] object FormUtils {
}
private case class FromJsonObject(parent: FromJsonContextValue, fields: IndexedSeq[(String, JsValue)], idx: Int)
extends FromJsonContextValue {
override val depth: Int = parent.depth + 1
override def value: JsValue = fields(idx)._2
override val prefix: String = if (parent.prefix.isEmpty) {
fields(idx)._1
Expand Down Expand Up @@ -1053,6 +1106,10 @@ case class FormJsonExpansionTooLarge(limit: Long)
extends RuntimeException(s"Binding form from JSON exceeds form expansion limit of $limit")
with NoStackTrace

case class FormJsonExpansionTooDeep(limit: Int)
extends RuntimeException(s"Binding form from JSON exceeds depth limit of $limit")
with NoStackTrace

trait FormBinding {
def apply(request: play.api.mvc.Request[_]): Map[String, Seq[String]]
}
Expand All @@ -1066,11 +1123,13 @@ object FormBinding {
*
* Prefer using a FormBinding provided by PlayBodyParsers#formBinding since that honours play.http.parser.maxMemoryBuffer limits.
*/
implicit val formBinding: FormBinding = new DefaultFormBinding(Form.FromJsonMaxChars)
implicit val formBinding: FormBinding = new DefaultFormBinding(Form.FromJsonMaxChars, Form.FromJsonMaxDepth)
}
}

class DefaultFormBinding(maxChars: Long) extends FormBinding {
class DefaultFormBinding(maxChars: Long, maxDepth: Int) extends FormBinding {
def this(maxChars: Long) = this(maxChars, Form.FromJsonMaxDepth)

def apply(request: play.api.mvc.Request[_]): Map[String, Seq[String]] = {
import play.api.mvc.MultipartFormData
val unwrap = request.body match {
Expand All @@ -1093,5 +1152,5 @@ class DefaultFormBinding(maxChars: Long) extends FormBinding {
}
private def multipartFormParse(body: MultipartFormData[_]) = body.asFormUrlEncoded

private def jsonParse(jsValue: JsValue) = FormUtils.fromJson(jsValue, maxChars).mapValues(Seq(_))
private def jsonParse(jsValue: JsValue) = FormUtils.fromJson(jsValue, maxChars, maxDepth).mapValues(Seq(_))
}
3 changes: 2 additions & 1 deletion core/play/src/main/scala/play/api/mvc/BodyParsers.scala
Expand Up @@ -460,7 +460,8 @@ trait PlayBodyParsers extends BodyParserUtils {

// -- General purpose

def formBinding(maxChars: Long = DefaultMaxTextLength): FormBinding = new DefaultFormBinding(maxChars)
def formBinding(maxChars: Long = DefaultMaxTextLength, maxDepth: Int = Form.FromJsonMaxDepth): FormBinding =
new DefaultFormBinding(maxChars, maxDepth)

// -- Text parser

Expand Down
58 changes: 52 additions & 6 deletions core/play/src/test/scala/play/api/data/FormUtilsSpec.scala
Expand Up @@ -51,13 +51,13 @@ class FormUtilsSpec extends Specification {
"j[0][0]" -> "40",
)

val map = FormUtils.fromJson(json, 1000)
val map = FormUtils.fromJson(json, 1000, 100)
map.toSeq must containTheSameElementsAs(expected)
}

"not stack overflow when converting heavily nested arrays" in {
try {
FormUtils.fromJson(Json.parse("{\"arr\":" + ("[" * 10000) + "1" + ("]" * 10000) + "}"), 1000000)
FormUtils.fromJson(Json.parse("{\"arr\":" + ("[" * 10000) + "1" + ("]" * 10000) + "}"), 1000000, 30000)
} catch {
case e: StackOverflowError =>
ko("StackOverflowError thrown")
Expand All @@ -69,10 +69,12 @@ class FormUtilsSpec extends Specification {
val keyLength = 10
val itemCount = 10
val maxChars = 500 * 1000 // a limit we're not reaching
val maxDepth = 100
try {
FormUtils.fromJson(
Json.obj("a" * keyLength -> Json.arr(0 to itemCount)),
maxChars
maxChars,
maxDepth
)
} catch {
case _: OutOfMemoryError =>
Expand All @@ -86,10 +88,12 @@ class FormUtilsSpec extends Specification {
val keyLength = 10
val itemCount = 10
val maxChars = 3 // yeah, maxChars is only 3 chars. We want to hit the limit.
val maxDepth = 10
(try {
FormUtils.fromJson(
Json.obj("a" * keyLength -> Json.arr(0 to itemCount)),
maxChars
maxChars,
maxDepth
)
} catch {
case _: OutOfMemoryError =>
Expand All @@ -102,11 +106,13 @@ class FormUtilsSpec extends Specification {
val keyLength = 10
val itemCount = 10
val maxChars = 3 // yeah, maxChars is only 3 chars. We want to hit the limit.
val maxDepth = 10
(try {
val jsString = Json.parse(s""" "${"a" * keyLength}" """)
FormUtils.fromJson(
jsString,
maxChars
maxChars,
maxDepth
)
} catch {
case _: OutOfMemoryError =>
Expand All @@ -119,13 +125,15 @@ class FormUtilsSpec extends Specification {
val keyLength = 10000
val itemCount = 100000
val maxChars = keyLength // some value we're likely to exceed. We want this limit to kick in.
val maxDepth = 100
(try {
FormUtils.fromJson(
// A JSON object with a key of length 10000, pointing to a list with 100000 elements.
// In memory, this will consume at most a few MB of space. When expanded, will consume
// many GB of space.
Json.obj("a" * keyLength -> Json.arr(0 to itemCount)),
maxChars
maxChars,
maxDepth
)
} catch {
case _: OutOfMemoryError =>
Expand All @@ -135,6 +143,44 @@ class FormUtilsSpec extends Specification {
}) must throwA[FormJsonExpansionTooLarge]
}

"allow parsing array up to max depth" in {
try {
FormUtils.fromJson(Json.parse("{\"arr\":" + ("[" * 4) + "1" + ("]" * 4) + "}"), 1000000, 5)
} catch {
case e: StackOverflowError =>
ko("StackOverflowError thrown")
}
ok
}

"abort parsing array when max depth is exceeded" in {
(try {
FormUtils.fromJson(Json.parse("{\"arr\":" + ("[" * 5) + "1" + ("]" * 5) + "}"), 1000000, 5)
} catch {
case e: StackOverflowError =>
ko("StackOverflowError thrown")
}) must throwA[FormJsonExpansionTooDeep]
}

"allow parsing object up to max depth" in {
try {
FormUtils.fromJson(Json.parse(("{\"obj\":" * 5) + "1" + ("}" * 5)), 1000000, 5)
} catch {
case e: StackOverflowError =>
ko("StackOverflowError thrown")
}
ok
}

"abort parsing object when max depth is exceeded" in {
(try {
FormUtils.fromJson(Json.parse(("{\"obj\":" * 6) + "1" + ("}" * 6)), 1000000, 5)
} catch {
case e: StackOverflowError =>
ko("StackOverflowError thrown")
}) must throwA[FormJsonExpansionTooDeep]
}

}

}
4 changes: 3 additions & 1 deletion web/play-java-forms/src/main/java/play/data/DynamicForm.java
Expand Up @@ -292,7 +292,9 @@ public DynamicForm bind(
attrs,
play.libs.Scala.asJava(
play.api.data.FormUtils.fromJson(
play.api.libs.json.Json.parse(play.libs.Json.stringify(data)), maxChars)),
play.api.libs.json.Json.parse(play.libs.Json.stringify(data)),
maxChars,
maxJsonDepth())),
allowedFields);
}

Expand Down