Skip to content

Commit

Permalink
[2.6.x] Cherry pick JSON parsing (#202)
Browse files Browse the repository at this point in the history
* Avoid parsing large big decimals (#200)

* Avoid parsing large big decimals

Parsing large big decimals (thing tens of hundred digits) and operating on these numbers
can be very CPU demanding. While play-json currently supports handling large numbers, it
is not practical on real-world applications and can expose them to DoS of service attacks.

This changes the way parsing happens to limit the size of such numbers based on
MathContext.DECIMAL128.

* Format details

* Fix typo

* Remove tests duplication

* Add breadcrumbs detailing where precision is defined

* Improve parsing readability

* Improve test readability

* Make it possible to configure the parsing for large big decimals (#191)

Fixes #187

Parsing large big decimals (thing tens of hundred digits) and operating on these numbers
can be very CPU demanding. While play-json currently supports handling large numbers, it
is not practical on real-world applications and can expose them to DoS of service attacks.

This changes the way parsing happens to limit the size of such numbers based on
MathContext.DECIMAL128.

* Fix binary compatibility issues

* Codec for BigInt (#122)

* Codec for BigInt

* MiMa

* More tests

* Add small comment about bincompat filter

* Fix Scala 2.10 compatibility issue
  • Loading branch information
dwijnand authored Nov 27, 2018
1 parent 5b6f2dc commit 50a6b6f
Show file tree
Hide file tree
Showing 9 changed files with 581 additions and 108 deletions.
12 changes: 11 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,17 @@ lazy val `play-json` = crossProject.crossType(CrossType.Full)
mimaBinaryIssueFilters ++= Seq(
// AbstractFunction1 is in scala.runtime and isn't meant to be used by end users
ProblemFilters.exclude[MissingTypesProblem]("play.api.libs.json.JsArray$"),
ProblemFilters.exclude[MissingTypesProblem]("play.api.libs.json.JsObject$")
ProblemFilters.exclude[MissingTypesProblem]("play.api.libs.json.JsObject$"),

// Codec for BigInt/BigInteger
ProblemFilters.exclude[ReversedMissingMethodProblem]("play.api.libs.json.DefaultWrites.BigIntWrites"),
ProblemFilters.exclude[ReversedMissingMethodProblem]("play.api.libs.json.DefaultWrites.BigIntegerWrites"),
ProblemFilters.exclude[ReversedMissingMethodProblem]("play.api.libs.json.DefaultReads.BigIntReads"),
ProblemFilters.exclude[ReversedMissingMethodProblem]("play.api.libs.json.DefaultReads.BigIntegerReads"),
ProblemFilters.exclude[ReversedMissingMethodProblem]("play.api.libs.json.DefaultWrites.BigIntWrites"),
ProblemFilters.exclude[ReversedMissingMethodProblem]("play.api.libs.json.DefaultWrites.BigIntegerWrites"),
ProblemFilters.exclude[ReversedMissingMethodProblem]("play.api.libs.json.DefaultReads.BigIntReads"),
ProblemFilters.exclude[ReversedMissingMethodProblem]("play.api.libs.json.DefaultReads.BigIntegerReads")
),
libraryDependencies ++= jsonDependencies(scalaVersion.value) ++ Seq(
"org.scalatest" %%% "scalatest" % "3.0.5-M1" % Test,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Copyright (C) 2009-2018 Lightbend Inc. <https://www.lightbend.com>
*/

package play.api.libs.json

import java.math.MathContext

import scala.util.control.NonFatal

/**
* Parse settings for BigDecimals. Defines limits that will be used when parsing the BigDecimals, like how many digits
* are accepted.
*
* @param mathContext the [[MathContext]] used when parsing.
* @param scaleLimit limit the scale, and it is related to the math context used.
* @param digitsLimit how many digits are accepted, also related to the math context used.
*/
final case class BigDecimalParseSettings(
mathContext: MathContext = MathContext.DECIMAL128,
scaleLimit: Int,
digitsLimit: Int
)

final case class BigDecimalSerializerSettings(
minPlain: BigDecimal,
maxPlain: BigDecimal
)

final case class JsonParserSettings(bigDecimalParseSettings: BigDecimalParseSettings, bigDecimalSerializerSettings: BigDecimalSerializerSettings)

object JsonParserSettings {

val defaultMathContext: MathContext = MathContext.DECIMAL128

// Limit for the scale considering the MathContext of 128
// limit for scale for decimal128: BigDecimal("0." + "0" * 33 + "1e-6143", java.math.MathContext.DECIMAL128).scale + 1
val defaultScaleLimit: Int = 6178

// 307 digits should be the correct value for 128 bytes. But we are using 310
// because Play JSON uses BigDecimal to parse any number including Doubles and
// Doubles max value has 309 digits, so we are using 310 here
val defaultDigitsLimit: Int = 310

// Maximum magnitude of BigDecimal to write out as a plain string
val MaxPlain: BigDecimal = 1e20

// Minimum magnitude of BigDecimal to write out as a plain string
val MinPlain: BigDecimal = 1e-10

def apply(): JsonParserSettings = JsonParserSettings(
BigDecimalParseSettings(defaultMathContext, defaultScaleLimit, defaultDigitsLimit),
BigDecimalSerializerSettings(minPlain = MinPlain, maxPlain = MaxPlain)
)

// BigDecimal.exact does not exists for Scala 2.10. So we are copying the implementation from 2.12.
// The difference is that we are using our own `defaultMathContext`, but by default, is matches the
// same one used by Scala's BigDecimal, which is MathContext.DECIMAL128.
private def exact(s: String): BigDecimal = {
import java.math.{ BigDecimal => BigDec }
val repr = new BigDec(s)
val mc =
if (repr.precision <= defaultMathContext.getPrecision) defaultMathContext
else new MathContext(repr.precision, java.math.RoundingMode.HALF_EVEN)
new BigDecimal(repr, mc)
}

/**
* Return the parse settings that are configured.
*/
val settings: JsonParserSettings = {
// Initialize the parser settings from System properties. This way it is possible to users
// to easily replace the default values.
val scaleLimit = parseNum("play.json.parser.scaleLimit", defaultScaleLimit)(_.toInt)
val digitsLimit = parseNum("play.json.parser.digitsLimit", defaultDigitsLimit)(_.toInt)
val mathContext = parseMathContext("play.json.parser.mathContext")

val minPlain: BigDecimal = parseNum("play.json.serializer.minPlain", MinPlain)(exact)
val maxPlain: BigDecimal = parseNum("play.json.serializer.maxPlain", MaxPlain)(exact)

JsonParserSettings(
BigDecimalParseSettings(
mathContext,
scaleLimit,
digitsLimit
),
BigDecimalSerializerSettings(
minPlain,
maxPlain
)
)
}

private def parseMathContext(key: String): MathContext = sys.props.get(key).map(_.toLowerCase) match {
case Some("decimal128") => MathContext.DECIMAL128
case Some("decimal64") => MathContext.DECIMAL64
case Some("decimal32") => MathContext.DECIMAL32
case Some("unlimited") => MathContext.UNLIMITED
case _ => defaultMathContext
}

private def parseNum[T](key: String, default: T)(f: String => T): T = try {
sys.props.get(key).map(f).getOrElse(default)
} catch {
case NonFatal(_) => default
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@ import java.io.{ InputStream, StringWriter }
import com.fasterxml.jackson.core._
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter
import com.fasterxml.jackson.databind.Module.SetupContext
import com.fasterxml.jackson.databind._
import com.fasterxml.jackson.databind.`type`.TypeFactory
import com.fasterxml.jackson.databind.deser.Deserializers
import com.fasterxml.jackson.databind._
import com.fasterxml.jackson.databind.node.{ ArrayNode, ObjectNode }
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.databind.ser.Serializers
import play.api.libs.json._
Expand All @@ -30,38 +29,37 @@ import scala.collection.mutable.{ ArrayBuffer, ListBuffer }
* {{{
* import com.fasterxml.jackson.databind.ObjectMapper
*
* val mapper = new ObjectMapper().registerModule(PlayJsonModule)
* val jsonParseSettings = JsonParserSettings()
* val mapper = new ObjectMapper().registerModule(PlayJsonModule(jsonParseSettings))
* val jsValue = mapper.readValue("""{"foo":"bar"}""", classOf[JsValue])
* }}}
*/
object PlayJsonModule extends SimpleModule("PlayJson", Version.unknownVersion()) {
override def setupModule(context: SetupContext) {
context.addDeserializers(new PlayDeserializers)
context.addSerializers(new PlaySerializers)
sealed class PlayJsonModule private[jackson] (parserSettings: JsonParserSettings) extends SimpleModule("PlayJson", Version.unknownVersion()) {
override def setupModule(context: SetupContext): Unit = {
context.addDeserializers(new PlayDeserializers(parserSettings))
context.addSerializers(new PlaySerializers(parserSettings))
}
}

// -- Serializers.
@deprecated("Use PlayJsonModule class instead", "2.6.11")
object PlayJsonModule extends PlayJsonModule(JsonParserSettings())

private[jackson] object JsValueSerializer extends JsonSerializer[JsValue] {
import java.math.{ BigDecimal => JBigDec, BigInteger }
import com.fasterxml.jackson.databind.node.{ BigIntegerNode, DecimalNode }
// -- Serializers.

// Maximum magnitude of BigDecimal to write out as a plain string
val MaxPlain: BigDecimal = 1e20
private[jackson] class JsValueSerializer(val parserSettings: JsonParserSettings) extends JsonSerializer[JsValue] {
import java.math.{ BigInteger, BigDecimal => JBigDec }

// Minimum magnitude of BigDecimal to write out as a plain string
val MinPlain: BigDecimal = 1e-10
import com.fasterxml.jackson.databind.node.{ BigIntegerNode, DecimalNode }

override def serialize(value: JsValue, json: JsonGenerator, provider: SerializerProvider) {
override def serialize(value: JsValue, json: JsonGenerator, provider: SerializerProvider): Unit = {
value match {
case JsNumber(v) => {
// Workaround #3784: Same behaviour as if JsonGenerator were
// configured with WRITE_BIGDECIMAL_AS_PLAIN, but forced as this
// configuration is ignored when called from ObjectMapper.valueToTree
val shouldWritePlain = {
val va = v.abs
va < MaxPlain && va > MinPlain
va < parserSettings.bigDecimalSerializerSettings.maxPlain && va > parserSettings.bigDecimalSerializerSettings.minPlain
}
val stripped = v.bigDecimal.stripTrailingZeros
val raw = if (shouldWritePlain) stripped.toPlainString else stripped.toString
Expand Down Expand Up @@ -97,6 +95,12 @@ private[jackson] object JsValueSerializer extends JsonSerializer[JsValue] {
}
}

// Exists for binary compatibility
private[jackson] object JsValueSerializer extends JsValueSerializer(JsonParserSettings()) {
val MinPlain: BigDecimal = parserSettings.bigDecimalSerializerSettings.minPlain
val MaxPlain: BigDecimal = parserSettings.bigDecimalSerializerSettings.maxPlain
}

private[jackson] sealed trait DeserializerContext {
def addValue(value: JsValue): DeserializerContext
}
Expand All @@ -120,7 +124,10 @@ private[jackson] case class ReadingMap(content: ListBuffer[(String, JsValue)]) e

}

private[jackson] class JsValueDeserializer(factory: TypeFactory, klass: Class[_]) extends JsonDeserializer[Object] {
private[jackson] class JsValueDeserializer(factory: TypeFactory, klass: Class[_], parserSettings: JsonParserSettings) extends JsonDeserializer[Object] {

// Exists for binary compatibility
def this(factory: TypeFactory, klass: Class[_]) = this(factory, klass, JsonParserSettings())

override def isCachable: Boolean = true

Expand All @@ -133,6 +140,28 @@ private[jackson] class JsValueDeserializer(factory: TypeFactory, klass: Class[_]
value
}

private def parseBigDecimal(jp: JsonParser, parserContext: List[DeserializerContext]): (Some[JsNumber], List[DeserializerContext]) = {
val inputText = jp.getText
val inputLength = inputText.length

// There is a limit of how large the numbers can be since parsing extremely
// large numbers (think thousand of digits) and operating on the parsed values
// can potentially cause a DDoS.
if (inputLength > parserSettings.bigDecimalParseSettings.digitsLimit) {
throw new IllegalArgumentException(s"""Number is larger than supported for field "${jp.getCurrentName}"""")
}

// Must create the BigDecimal with a MathContext that is consistent with the limits used.
val bigDecimal = BigDecimal(inputText, parserSettings.bigDecimalParseSettings.mathContext)

// We should also avoid numbers with scale that are out of a safe limit
if (Math.abs(bigDecimal.scale) > parserSettings.bigDecimalParseSettings.scaleLimit) {
throw new IllegalArgumentException(s"""Number scale (${bigDecimal.scale}) is out of limits for field "${jp.getCurrentName}"""")
}

(Some(JsNumber(bigDecimal)), parserContext)
}

@tailrec
final def deserialize(jp: JsonParser, ctxt: DeserializationContext, parserContext: List[DeserializerContext]): JsValue = {
if (jp.getCurrentToken == null) {
Expand All @@ -141,7 +170,7 @@ private[jackson] class JsValueDeserializer(factory: TypeFactory, klass: Class[_]

val (maybeValue, nextContext) = (jp.getCurrentToken.id(): @switch) match {

case JsonTokenId.ID_NUMBER_INT | JsonTokenId.ID_NUMBER_FLOAT => (Some(JsNumber(jp.getDecimalValue)), parserContext)
case JsonTokenId.ID_NUMBER_INT | JsonTokenId.ID_NUMBER_FLOAT => parseBigDecimal(jp, parserContext)

case JsonTokenId.ID_STRING => (Some(JsString(jp.getText)), parserContext)

Expand Down Expand Up @@ -199,19 +228,27 @@ private[jackson] class JsValueDeserializer(factory: TypeFactory, klass: Class[_]
override val getNullValue = JsNull
}

private[jackson] class PlayDeserializers extends Deserializers.Base {
private[jackson] class PlayDeserializers(parserSettings: JsonParserSettings) extends Deserializers.Base {

// Exists for binary compatibility
def this() = this(JsonParserSettings())

override def findBeanDeserializer(javaType: JavaType, config: DeserializationConfig, beanDesc: BeanDescription) = {
val klass = javaType.getRawClass
if (classOf[JsValue].isAssignableFrom(klass) || klass == JsNull.getClass) {
new JsValueDeserializer(config.getTypeFactory, klass)
new JsValueDeserializer(config.getTypeFactory, klass, parserSettings)
} else null
}
}

private[jackson] class PlaySerializers extends Serializers.Base {
private[jackson] class PlaySerializers(parserSettings: JsonParserSettings) extends Serializers.Base {

// Exists for binary compatibility
def this() = this(JsonParserSettings())

override def findSerializer(config: SerializationConfig, javaType: JavaType, beanDesc: BeanDescription) = {
val ser: Object = if (classOf[JsValue].isAssignableFrom(beanDesc.getBeanClass)) {
JsValueSerializer
new JsValueSerializer(parserSettings)
} else {
null
}
Expand All @@ -221,9 +258,9 @@ private[jackson] class PlaySerializers extends Serializers.Base {

private[json] object JacksonJson {

private val mapper = (new ObjectMapper).registerModule(PlayJsonModule)
private lazy val mapper = (new ObjectMapper).registerModule(new PlayJsonModule(JsonParserSettings.settings))

private val jsonFactory = new JsonFactory(mapper)
private lazy val jsonFactory = new JsonFactory(mapper)

private def stringJsonGenerator(out: java.io.StringWriter) =
jsonFactory.createGenerator(out)
Expand Down
Loading

0 comments on commit 50a6b6f

Please sign in to comment.