Skip to content

Commit

Permalink
add depth limit for JSON form binding
Browse files Browse the repository at this point in the history
  • Loading branch information
gmethvin committed May 31, 2022
1 parent 580d41c commit 825ff02
Show file tree
Hide file tree
Showing 5 changed files with 195 additions and 46 deletions.
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 @@ -359,7 +370,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 @@ -409,54 +428,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 @@ -467,6 +519,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).view.mapValues(Seq(_))
private def jsonParse(jsValue: JsValue) = FormUtils.fromJson(jsValue, maxChars, maxDepth).view.mapValues(Seq(_))
}
3 changes: 2 additions & 1 deletion core/play/src/main/scala/play/api/mvc/BodyParsers.scala
Expand Up @@ -466,7 +466,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

0 comments on commit 825ff02

Please sign in to comment.