diff --git a/core/play/src/main/scala/play/api/data/Form.scala b/core/play/src/main/scala/play/api/data/Form.scala index 7b16530a99d..505bacda1b5 100644 --- a/core/play/src/main/scala/play/api/data/Form.scala +++ b/core/play/src/main/scala/play/api/data/Form.scala @@ -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)) } /** @@ -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. @@ -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. @@ -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) { @@ -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 @@ -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]] } @@ -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 { @@ -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(_)) } diff --git a/core/play/src/main/scala/play/api/mvc/BodyParsers.scala b/core/play/src/main/scala/play/api/mvc/BodyParsers.scala index d738a92e9e4..fd1691d86f2 100644 --- a/core/play/src/main/scala/play/api/mvc/BodyParsers.scala +++ b/core/play/src/main/scala/play/api/mvc/BodyParsers.scala @@ -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 diff --git a/core/play/src/test/scala/play/api/data/FormUtilsSpec.scala b/core/play/src/test/scala/play/api/data/FormUtilsSpec.scala index 127582e17d1..5531553295d 100644 --- a/core/play/src/test/scala/play/api/data/FormUtilsSpec.scala +++ b/core/play/src/test/scala/play/api/data/FormUtilsSpec.scala @@ -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") @@ -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 => @@ -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 => @@ -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 => @@ -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 => @@ -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] + } + } } diff --git a/web/play-java-forms/src/main/java/play/data/DynamicForm.java b/web/play-java-forms/src/main/java/play/data/DynamicForm.java index 7a295654f97..1dce230e8fd 100644 --- a/web/play-java-forms/src/main/java/play/data/DynamicForm.java +++ b/web/play-java-forms/src/main/java/play/data/DynamicForm.java @@ -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); } diff --git a/web/play-java-forms/src/main/java/play/data/Form.java b/web/play-java-forms/src/main/java/play/data/Form.java index 59d0f31bd6e..906b7ee72ef 100644 --- a/web/play-java-forms/src/main/java/play/data/Form.java +++ b/web/play-java-forms/src/main/java/play/data/Form.java @@ -572,10 +572,16 @@ public Form( this.directFieldAccess = directFieldAccess; } + /** The default maximum number of chars to support when binding a form from JSON. */ protected long maxJsonChars() { return config.getMemorySize("play.http.parser.maxMemoryBuffer").toBytes(); } + /** The default maximum depth of JSON objects and arrays when binding a form from JSON. */ + protected int maxJsonDepth() { + return play.api.data.Form$.MODULE$.FromJsonMaxDepth(); + } + protected Map requestData(Http.Request request) { Map urlFormEncoded = new HashMap<>(); @@ -594,7 +600,8 @@ protected Map requestData(Http.Request request) { play.libs.Scala.asJava( play.api.data.FormUtils.fromJson( play.api.libs.json.Json.parse(play.libs.Json.stringify(request.body().asJson())), - maxJsonChars())); + maxJsonChars(), + maxJsonDepth())); } Map data = new HashMap<>(); @@ -762,7 +769,7 @@ public Form bind(Lang lang, TypedMap attrs, JsonNode data, String... allowedF * For these methods the lang can be change via {@link #withLang(Lang)}. * @param attrs will be passed to validators via {@link ValidationPayload} * @param data data to submit - * @param maxChars The maximum number of chars allowed to be used in the intermediate map + * @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 allowedFields the fields that should be bound to the form, all fields if not specified. @@ -775,7 +782,41 @@ public Form 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); + } + + /** + * Binds Json data to this form - that is, handles form submission. + * + * @param lang used for validators and formatters during binding and is part of {@link + * ValidationPayload}. Later also used for formatting when retrieving a field (via {@link + * #field(String)} or {@link #apply(String)}) and for translations in {@link #errorsAsJson()}. + * For these methods the lang can be change via {@link #withLang(Lang)}. + * @param attrs will be passed to validators via {@link ValidationPayload} + * @param data 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 depth allowed for JSON objects and arrays. + * @param allowedFields the fields that should be bound to the form, all fields if not specified. + * @return a copy of this form filled with the new data + */ + public Form bind( + Lang lang, + TypedMap attrs, + JsonNode data, + long maxChars, + int maxDepth, + String... allowedFields) { + return bind( + lang, + attrs, + play.libs.Scala.asJava( + play.api.data.FormUtils.fromJson( + play.api.libs.json.Json.parse(play.libs.Json.stringify(data)), maxChars, maxDepth)), allowedFields); }