Skip to content

Commit

Permalink
Make it possible to configure the parsing for large big decimals (#191)
Browse files Browse the repository at this point in the history
## Fixes

Fixes #187 

## Purpose

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.
  • Loading branch information
marcospereira authored and dwijnand committed Nov 14, 2018
1 parent 0a1ccdf commit 0c8a568
Show file tree
Hide file tree
Showing 4 changed files with 127 additions and 47 deletions.
7 changes: 6 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,13 @@ lazy val `play-json` = crossProject(JVMPlatform, JSPlatform).crossType(CrossType
ProblemFilters.exclude[IncompatibleMethTypeProblem]("play.api.libs.json.LowPriorityDefaultReads.traversableReads"),
ProblemFilters.exclude[IncompatibleMethTypeProblem]("play.api.libs.json.Reads.traversableReads"),
ProblemFilters.exclude[DirectMissingMethodProblem]("play.api.libs.json.LowPriorityDefaultReads.traversableReads"),
ProblemFilters.exclude[ReversedMissingMethodProblem]("play.api.libs.json.LowPriorityDefaultReads.traversableReads")
ProblemFilters.exclude[ReversedMissingMethodProblem]("play.api.libs.json.LowPriorityDefaultReads.traversableReads"),

// Add JsonParseSettings, these are all private[jackson] classes
ProblemFilters.exclude[DirectMissingMethodProblem]("play.api.libs.json.jackson.JsValueDeserializer.this"),
ProblemFilters.exclude[DirectMissingMethodProblem]("play.api.libs.json.jackson.PlayDeserializers.this"),
ProblemFilters.exclude[DirectMissingMethodProblem]("play.api.libs.json.jackson.PlaySerializers.this"),
ProblemFilters.exclude[MissingClassProblem]("play.api.libs.json.jackson.JsValueSerializer$")
),
libraryDependencies ++= jsonDependencies(scalaVersion.value) ++ Seq(
"org.scalatest" %%% "scalatest" % "3.0.6-SNAP4" % Test,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* 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)
)

/**
* 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)(BigDecimal.exact)
val maxPlain: BigDecimal = parseNum("play.json.serializer.maxPlain", MaxPlain)(BigDecimal.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 @@ -5,7 +5,6 @@
package play.api.libs.json.jackson

import java.io.{ InputStream, StringWriter }
import java.math.MathContext

import com.fasterxml.jackson.core._
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter
Expand All @@ -30,30 +29,28 @@ 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()) {
sealed class PlayJsonModule private[jackson] (parserSettings: JsonParserSettings) extends SimpleModule("PlayJson", Version.unknownVersion()) {
override def setupModule(context: SetupContext): Unit = {
context.addDeserializers(new PlayDeserializers)
context.addSerializers(new PlaySerializers)
context.addDeserializers(new PlayDeserializers(parserSettings))
context.addSerializers(new PlaySerializers(parserSettings))
}
}

@deprecated("Use PlayJsonModule class instead", "2.6.11")
object PlayJsonModule extends PlayJsonModule(JsonParserSettings())

// -- Serializers.

private[jackson] object JsValueSerializer extends JsonSerializer[JsValue] {
private[jackson] class JsValueSerializer(parserSettings: JsonParserSettings) extends JsonSerializer[JsValue] {
import java.math.{ BigInteger, BigDecimal => JBigDec }

import com.fasterxml.jackson.databind.node.{ BigIntegerNode, DecimalNode }

// 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

override def serialize(value: JsValue, json: JsonGenerator, provider: SerializerProvider): Unit = {
value match {
case JsNumber(v) => {
Expand All @@ -62,7 +59,7 @@ private[jackson] object JsValueSerializer extends JsonSerializer[JsValue] {
// 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 @@ -121,7 +118,7 @@ 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] {

override def isCachable: Boolean = true

Expand All @@ -141,15 +138,15 @@ private[jackson] class JsValueDeserializer(factory: TypeFactory, klass: Class[_]
// 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 > JacksonJson.BigDecimalLimits.DigitsLimit) {
if (inputLength > parserSettings.bigDecimalParseSettings.digitsLimit) {
throw new IllegalArgumentException(s"""Number is larger than supported for field "${jp.currentName()}"""")
}

// Must create the BigDecimal with a MathContext that is consistent with the limits used.
val bigDecimal = BigDecimal(inputText, JacksonJson.BigDecimalLimits.DefaultMathContext)
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) > JacksonJson.BigDecimalLimits.ScaleLimit) {
if (Math.abs(bigDecimal.scale) > parserSettings.bigDecimalParseSettings.scaleLimit) {
throw new IllegalArgumentException(s"""Number scale (${bigDecimal.scale}) is out of limits for field "${jp.currentName()}"""")
}

Expand Down Expand Up @@ -222,19 +219,19 @@ 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 {
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 {
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 @@ -244,28 +241,9 @@ private[jackson] class PlaySerializers extends Serializers.Base {

private[json] object JacksonJson {

/**
* Define limits for parsing BigDecimal numbers.
*
* By default, we are using MathContext.DECIMAL128 and then the limits are define in its terms.
*/
object BigDecimalLimits {

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 ScaleLimit: 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 DigitsLimit: Int = 310
}

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
10 changes: 6 additions & 4 deletions play-json/jvm/src/test/scala/play/api/libs/json/JsonSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ class JsonSpec extends org.specs2.mutable.Specification {

"for BigDecimals" should {

val parserSettings = JsonParserSettings.settings

// note: precision refers to `JacksonJson.BigDecimalLimits.DefaultMathContext.getPrecision`
"maintain precision when parsing BigDecimals within precision limit" in {
val n = BigDecimal("12345678901234567890.123456789")
Expand All @@ -285,12 +287,12 @@ class JsonSpec extends org.specs2.mutable.Specification {
}

"success when not exceeding the scale limit for positive numbers" in {
val withinScaleLimit = BigDecimal(2, JacksonJson.BigDecimalLimits.ScaleLimit - 1)
val withinScaleLimit = BigDecimal(2, parserSettings.bigDecimalParseSettings.scaleLimit - 1)
Json.parse(bigNumbersJson(bigDec = withinScaleLimit.toString)).as[BigNumbers].bigDec mustEqual withinScaleLimit
}

"success when not exceeding the scale limit for negative numbers" in {
val withinScaleLimitNegative = BigDecimal(2, JacksonJson.BigDecimalLimits.ScaleLimit - 1).unary_-
val withinScaleLimitNegative = BigDecimal(2, parserSettings.bigDecimalParseSettings.scaleLimit - 1).unary_-
Json.parse(bigNumbersJson(bigDec = withinScaleLimitNegative.toString)).as[BigNumbers].bigDec mustEqual { withinScaleLimitNegative }
}

Expand All @@ -305,12 +307,12 @@ class JsonSpec extends org.specs2.mutable.Specification {
}

"fail when exceeding the scale limit for positive numbers" in {
val exceedsScaleLimit = BigDecimal(2, JacksonJson.BigDecimalLimits.ScaleLimit + 1)
val exceedsScaleLimit = BigDecimal(2, parserSettings.bigDecimalParseSettings.scaleLimit + 1)
Json.parse(bigNumbersJson(bigDec = exceedsScaleLimit.toString)).as[BigNumbers] must throwA[IllegalArgumentException]
}

"fail when exceeding the scale limit for negative numbers" in {
val exceedsScaleLimit = BigDecimal(2, JacksonJson.BigDecimalLimits.ScaleLimit + 1).unary_-
val exceedsScaleLimit = BigDecimal(2, parserSettings.bigDecimalParseSettings.scaleLimit + 1).unary_-
Json.parse(bigNumbersJson(bigDec = exceedsScaleLimit.toString)).as[BigNumbers] must throwA[IllegalArgumentException]
}

Expand Down

0 comments on commit 0c8a568

Please sign in to comment.