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.7.x] Fix Json issues: parse on form and tailrec deser #10496

Merged
merged 6 commits into from Oct 26, 2020
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
@@ -0,0 +1,88 @@
/*
* Copyright (C) Lightbend Inc. <https://www.lightbend.com>
*/

package play.it.http.parsing

import java.util.concurrent.TimeUnit

import akka.stream.Materializer
import akka.stream.scaladsl.Source
import akka.util.ByteString
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import org.specs2.execute.Failure
import org.specs2.matcher.Matchers
import play.api.Application
import play.api.Configuration
import play.api.test._
import play.libs.F
import play.mvc.BodyParser
import play.mvc.Http
import play.mvc.Result
import play.test.Helpers

class JacksonJsonBodyParserSpec extends PlaySpecification with Matchers {
// Jackson Json support in Play relies on static
// global variables so these tests must run sequentially
sequential

private def tolerantJsonBodyParser(implicit app: Application): BodyParser[JsonNode] =
app.injector.instanceOf(classOf[BodyParser.TolerantJson])

"The JSON body parser" should {
def parse(json: String)(
implicit mat: Materializer,
app: Application
): F.Either[Result, JsonNode] = {
val encoding: String = "utf-8"
val bodyParser = tolerantJsonBodyParser
val fakeRequest: Http.RequestHeader = Helpers.fakeRequest().header(CONTENT_TYPE, "application/json").build()
await(
bodyParser(fakeRequest).asScala().run(Source.single(ByteString(json.getBytes(encoding))))
)
}

"uses JacksonJsonNodeModule" in new WithApplication() {
private val mapper: ObjectMapper = implicitly[Application].injector.instanceOf[ObjectMapper]
mapper.getRegisteredModuleIds.contains("play.utils.JacksonJsonNodeModule") must_== true
}

"parse a simple JSON body with custom Jackson json-read-features" in new WithApplication() {
val mapper: ObjectMapper = implicitly[Application].injector.instanceOf[ObjectMapper]

mapper.configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true)

val either: F.Either[Result, JsonNode] = parse("""{ 'field1':'value1' }""")
either.left.ifPresent(verboseFailure)
either.right.get().get("field1").asText() must_=== "value1"
}

"parse very deep JSON bodies" in new WithApplication() {
val depth = 50000
private val either: F.Either[Result, JsonNode] = parse(s"""{"foo": ${"[" * depth} "asdf" ${"]" * depth} }""")
private var node: JsonNode = either.right.get().at("/foo")
while (node.isArray) {
node = node.get(0)
}

node.asText() must_== "asdf"
}
}

def verboseFailure(result: Result)(implicit mat: Materializer): Failure = {
val errorMessage = s"""Parse failure. Play-produced error HTML page:
| ${resultToString(result)}
|""".stripMargin
failure(errorMessage)
}

def resultToString(r: Result)(implicit mat: Materializer): String = {
r.body()
.consumeData(mat)
.toCompletableFuture
.get(6, TimeUnit.SECONDS)
.utf8String
}
}
10 changes: 6 additions & 4 deletions core/play-java/src/main/scala/play/core/ObjectMapperModule.scala
Expand Up @@ -4,14 +4,13 @@

package play.core

import scala.concurrent.Future

import com.fasterxml.jackson.databind.ObjectMapper
import javax.inject._
import play.api.inject._
import play.libs.Json

import javax.inject._

import scala.concurrent.Future

/**
* Module that injects an object mapper to the JSON library on start and on stop.
*
Expand All @@ -23,6 +22,9 @@ class ObjectMapperModule
bind[ObjectMapper].toProvider[ObjectMapperProvider].eagerly()
)

object ObjectMapperProvider {
val BINDING_NAME = "play"
}
@Singleton
class ObjectMapperProvider @Inject() (lifecycle: ApplicationLifecycle) extends Provider[ObjectMapper] {
lazy val get: ObjectMapper = {
Expand Down
14 changes: 7 additions & 7 deletions core/play/src/main/java/play/libs/Json.java
Expand Up @@ -4,18 +4,17 @@

package play.libs;

import java.io.IOException;

import com.fasterxml.jackson.core.JsonGenerator.Feature;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import play.utils.JacksonJsonNodeModule;
import play.utils.JsonNodeDeserializer;

import java.io.IOException;

/** Helper functions to handle JsonNode values. */
public class Json {
Expand All @@ -26,6 +25,7 @@ public static ObjectMapper newDefaultMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new Jdk8Module());
mapper.registerModule(new JavaTimeModule());
mapper.registerModule(new JacksonJsonNodeModule());
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return mapper;
}
Expand Down
131 changes: 111 additions & 20 deletions core/play/src/main/scala/play/api/data/Form.scala
Expand Up @@ -5,11 +5,17 @@
package play.api.data

import scala.language.existentials
import akka.annotation.InternalApi
import org.slf4j.Logger
import org.slf4j.LoggerFactory

import format._
import scala.language.existentials
import play.api.data.format._
import play.api.data.validation._
import play.api.http.HttpVerbs
import play.api.libs.json.JsValue
import play.api.templates.PlayMagic.translate
import scala.util.control.NoStackTrace

/**
* Helper to manage HTML form description, submission and validation.
Expand All @@ -36,6 +42,8 @@ import play.api.templates.PlayMagic.translate
* @param value a concrete value of type `T` if the form submission was successful
*/
case class Form[T](mapping: Mapping[T], data: Map[String, String], errors: Seq[FormError], value: Option[T]) {
private val logger: Logger = LoggerFactory.getLogger(classOf[Form[T]])

/**
* Constraints associated with this form, indexed by field name.
*/
Expand Down Expand Up @@ -77,7 +85,26 @@ case class Form[T](mapping: Mapping[T], data: Map[String, String], errors: Seq[F
* @param data Json data to submit
* @return a copy of this form, filled with the new data
*/
def bind(data: play.api.libs.json.JsValue): Form[T] = bind(FormUtils.fromJson(js = data))
@deprecated(
"Use bind(JsValue, Int) instead to specify the maximum chars that should be consumed by the flattened form representation of the JSON",
"2.8.3"
)
def bind(data: JsValue): Form[T] = {
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))
}

/**
* 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.
* @return a copy of this form, filled with the new data
*/
def bind(data: JsValue, maxChars: Int): Form[T] = bind(FormUtils.fromJson(data, maxChars))

/**
* Binds request data to this form, i.e. handles form submission.
Expand All @@ -91,15 +118,15 @@ case class Form[T](mapping: Mapping[T], data: Map[String, String], errors: Seq[F
case body: play.api.mvc.AnyContent if body.asMultipartFormData.isDefined =>
body.asMultipartFormData.get.asFormUrlEncoded
case body: play.api.mvc.AnyContent if body.asJson.isDefined =>
FormUtils.fromJson(js = body.asJson.get).mapValues(Seq(_))
FormUtils.fromJson(body.asJson.get, Form.FromJsonMaxChars).mapValues(Seq(_))
case body: Map[_, _] => body.asInstanceOf[Map[String, Seq[String]]]
case body: play.api.mvc.MultipartFormData[_] => body.asFormUrlEncoded
case body: Either[_, play.api.mvc.MultipartFormData[_]] =>
body match {
case Right(b) => b.asFormUrlEncoded
case Left(_) => Map.empty[String, Seq[String]]
}
case body: play.api.libs.json.JsValue => FormUtils.fromJson(js = body).mapValues(Seq(_))
case body: play.api.libs.json.JsValue => FormUtils.fromJson(body, Form.FromJsonMaxChars).mapValues(Seq(_))
case _ => Map.empty[String, Seq[String]]
}) ++ {
request.method.toUpperCase match {
Expand Down Expand Up @@ -353,6 +380,15 @@ case class Field(
* Provides a set of operations for creating `Form` values.
*/
object Form {
/**
* INTERNAL API
*
* Default maximum number of chars allowed to be used in the intermediate map representation of the
* JSON. Defaults to 100k which is the default of parser.maxMemoryBuffer
*/
@InternalApi
val FromJsonMaxChars: Long = 102400

/**
* Creates a new form from a mapping.
*
Expand Down Expand Up @@ -403,24 +439,75 @@ object Form {
private[data] object FormUtils {
import play.api.libs.json._

def fromJson(prefix: String = "", js: JsValue): Map[String, String] = js match {
case JsObject(fields) => {
fields
.map {
case (key, value) => fromJson(Option(prefix).filterNot(_.isEmpty).map(_ + ".").getOrElse("") + key, value)
}
.foldLeft(Map.empty[String, String])(_ ++ _)
def fromJson(js: JsValue, maxChars: Long): Map[String, String] = doFromJson(FromJsonRoot(js), Map.empty, 0, maxChars)

@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)
}
}

private sealed trait FromJsonContext
private sealed trait FromJsonContextValue extends FromJsonContext {
def value: JsValue
def prefix: String
def next: FromJsonContext
}
private case object FromJsonTerm extends FromJsonContext
private case class FromJsonRoot(value: JsValue) extends FromJsonContextValue {
override def prefix = ""
override def next: FromJsonContext = FromJsonTerm
}
private case class FromJsonArray(parent: FromJsonContextValue, values: scala.collection.IndexedSeq[JsValue], idx: Int)
extends FromJsonContextValue {
override def value: JsValue = values(idx)
override val prefix: String = s"${parent.prefix}[$idx]"
override lazy val next: FromJsonContext = if (idx + 1 < values.length) {
FromJsonArray(parent, values, idx + 1)
} else {
parent.next
}
case JsArray(values) => {
values.zipWithIndex
.map { case (value, i) => fromJson(prefix + "[" + i + "]", value) }
.foldLeft(Map.empty[String, String])(_ ++ _)
}
private case class FromJsonObject(parent: FromJsonContextValue, fields: IndexedSeq[(String, JsValue)], idx: Int)
extends FromJsonContextValue {
override def value: JsValue = fields(idx)._2
override val prefix: String = if (parent.prefix.isEmpty) {
fields(idx)._1
} else {
parent.prefix + "." + fields(idx)._1
}
override lazy val next: FromJsonContext = if (idx + 1 < fields.length) {
FromJsonObject(parent, fields, idx + 1)
} else {
parent.next
}
case JsNull => Map.empty
case JsUndefined() => Map.empty
case JsBoolean(value) => Map(prefix -> value.toString)
case JsNumber(value) => Map(prefix -> value.toString)
case JsString(value) => Map(prefix -> value.toString)
}
}

Expand Down Expand Up @@ -1002,3 +1089,7 @@ trait ObjectMapping {
}
}
}

case class FormJsonExpansionTooLarge(limit: Long)
extends RuntimeException(s"Binding form from JSON exceeds form expansion limit of $limit")
with NoStackTrace