Skip to content

Commit 9722c66

Browse files
authored
Avoid running out of memory when parsing heavily nested arrays or objects (#1226)
Just like Jackson 2.15+ we restrict the maximum allowed number of nested arrays or objects (or mixed) to 1000. This default can be changed via a sys property. 1000 should be enough for most real world use cases. Note this is about OutOfMemoryError's, not about StackOverflowError's. StackOverflowError's are not a problem since we use a @tailrec optimized method. Therefore this fix is not 100% about CVE-2025-52999 (which in theory we do not run into) but just an additional precaution.
1 parent 79c984e commit 9722c66

File tree

2 files changed

+89
-6
lines changed

2 files changed

+89
-6
lines changed

play-json/jvm/src/main/scala/play/api/libs/json/jackson/JacksonJson.scala

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import com.fasterxml.jackson.databind.ser.Serializers
3030

3131
import play.api.libs.json._
3232

33+
import scala.util.control.NonFatal
34+
3335
/**
3436
* The Play JSON module for Jackson.
3537
*
@@ -155,7 +157,7 @@ private[jackson] class JsValueDeserializer(factory: TypeFactory, klass: Class[_]
155157
override def isCachable: Boolean = true
156158

157159
override def deserialize(jp: JsonParser, ctxt: DeserializationContext): JsValue = {
158-
val value = deserialize(jp, ctxt, List())
160+
val value = deserialize(jp = jp, ctxt = ctxt, parserContext = List())
159161

160162
if (!klass.isAssignableFrom(value.getClass)) {
161163
ctxt.handleUnexpectedToken(klass, jp)
@@ -190,8 +192,11 @@ private[jackson] class JsValueDeserializer(factory: TypeFactory, klass: Class[_]
190192
final def deserialize(
191193
jp: JsonParser,
192194
ctxt: DeserializationContext,
193-
parserContext: List[DeserializerContext]
195+
parserContext: List[DeserializerContext],
196+
unclosedArraysOrObjects: Long = 0,
194197
): JsValue = {
198+
var currentUnclosedArraysOrObjects = unclosedArraysOrObjects
199+
195200
if (jp.getCurrentToken == null) {
196201
jp.nextToken() // happens when using treeToValue (we're not parsing tokens)
197202
}
@@ -207,15 +212,20 @@ private[jackson] class JsValueDeserializer(factory: TypeFactory, klass: Class[_]
207212

208213
case JsonTokenId.ID_NULL => (Some(JsNull), parserContext)
209214

210-
case JsonTokenId.ID_START_ARRAY => (None, ReadingList(ArrayBuffer()) +: parserContext)
215+
case JsonTokenId.ID_START_ARRAY =>
216+
currentUnclosedArraysOrObjects += 1
217+
(None, ReadingList(ArrayBuffer()) +: parserContext)
211218

212219
case JsonTokenId.ID_END_ARRAY =>
220+
currentUnclosedArraysOrObjects -= 1
213221
parserContext match {
214222
case ReadingList(content) :: stack => (Some(JsArray(content)), stack)
215223
case _ => throw new RuntimeException("We should have been reading list, something got wrong")
216224
}
217225

218-
case JsonTokenId.ID_START_OBJECT => (None, ReadingMap(ListBuffer()) +: parserContext)
226+
case JsonTokenId.ID_START_OBJECT =>
227+
currentUnclosedArraysOrObjects += 1
228+
(None, ReadingMap(ListBuffer()) +: parserContext)
219229

220230
case JsonTokenId.ID_FIELD_NAME =>
221231
parserContext match {
@@ -224,6 +234,7 @@ private[jackson] class JsValueDeserializer(factory: TypeFactory, klass: Class[_]
224234
}
225235

226236
case JsonTokenId.ID_END_OBJECT =>
237+
currentUnclosedArraysOrObjects -= 1
227238
parserContext match {
228239
case ReadingMap(content) :: stack => (Some(JsObject(content)), stack)
229240
case _ => throw new RuntimeException("We should have been reading an object, something got wrong")
@@ -236,13 +247,28 @@ private[jackson] class JsValueDeserializer(factory: TypeFactory, klass: Class[_]
236247
throw new RuntimeException("We should have been reading an object, something got wrong")
237248
}
238249

250+
val defaultMaxDepth = 1000 // Same as Jackson's 2.15+ StreamReadConstraints.DEFAULT_MAX_DEPTH
251+
// system property to override the max nesting depth for JSON parsing.
252+
val maxNestingDepth: String = "play.json.parser.maxNestingDepth"
253+
val maxDepth =
254+
try {
255+
sys.props.get(maxNestingDepth).map(Integer.parseInt).getOrElse(defaultMaxDepth)
256+
} catch {
257+
case NonFatal(_) => defaultMaxDepth
258+
}
259+
260+
if (currentUnclosedArraysOrObjects > maxDepth) {
261+
throw new IllegalArgumentException(s"Document nesting depth exceeds the maximum allowed (${maxDepth}).")
262+
}
263+
239264
// Read ahead
240265
jp.nextToken()
241266

242267
valueAndCtx match {
243268
case (Some(v), Nil) => v // done, no more tokens and got a value!
244-
case (Some(v), previous :: stack) => deserialize(jp, ctxt, previous.addValue(v) :: stack)
245-
case (None, nextContext) => deserialize(jp, ctxt, nextContext)
269+
case (Some(v), previous :: stack) =>
270+
deserialize(jp, ctxt, previous.addValue(v) :: stack, currentUnclosedArraysOrObjects)
271+
case (None, nextContext) => deserialize(jp, ctxt, nextContext, currentUnclosedArraysOrObjects)
246272
}
247273
}
248274

play-json/jvm/src/test/scala/play/api/libs/json/JsonSpec.scala

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,5 +511,62 @@ class JsonSpec extends org.specs2.mutable.Specification {
511511
parsed.asInstanceOf[JsObject].fields.mustEqual(original.fields)
512512
Json.stringify(parsed).mustEqual(originalString)
513513
}
514+
515+
"allow parsing objects nested up to max depth" in {
516+
try {
517+
val depth = 1000
518+
Json.parse(("{\"obj\":" * depth) + "1" + ("}" * depth))
519+
} catch {
520+
case _: StackOverflowError =>
521+
ko("StackOverflowError thrown")
522+
case _: OutOfMemoryError =>
523+
ko("OutOfMemoryError thrown")
524+
}
525+
ok
526+
}
527+
528+
"disallow parsing nested objects exceeding max depth" in {
529+
val depth = 1001
530+
Json
531+
.parse(("{\"obj\":" * depth) + "1" + ("}" * depth))
532+
.must(throwA[IllegalArgumentException].like { case e: IllegalArgumentException =>
533+
e.getMessage.must(equalTo("Document nesting depth exceeds the maximum allowed (1000)."))
534+
})
535+
}
536+
537+
"allow parsing heavily nested mixed arrays and objects" in {
538+
try {
539+
val depth = 1000 - 2 // in the string two open objects { are hardcoded
540+
Json.parse("{\"foo\": {\"arr\":" + ("[" * depth) + "1" + ("]" * depth) + "}}")
541+
} catch {
542+
case _: StackOverflowError =>
543+
ko("StackOverflowError thrown")
544+
case _: OutOfMemoryError =>
545+
ko("OutOfMemoryError thrown")
546+
}
547+
ok
548+
}
549+
550+
"disallow parsing heavily nested mixed arrays and objects" in {
551+
val depth = 1001 - 2 // in the string two open objects { are hardcoded
552+
Json
553+
.parse("{\"foo\": {\"arr\":" + ("[" * depth) + "1" + ("]" * depth) + "}}")
554+
.must(throwA[IllegalArgumentException].like { case e: IllegalArgumentException =>
555+
e.getMessage.must(equalTo("Document nesting depth exceeds the maximum allowed (1000)."))
556+
})
557+
}
558+
}
559+
560+
"allow parsing many non-nested arrays and objects, not relevant for depth check" in {
561+
try {
562+
val repeat = 9999 // 10 thousand in total
563+
Json.parse("{\"foo\": [" + ("{\"arr\":[1]}," * repeat) + "{\"arr\":[1]}]}")
564+
} catch {
565+
case _: StackOverflowError =>
566+
ko("StackOverflowError thrown")
567+
case _: OutOfMemoryError =>
568+
ko("OutOfMemoryError thrown")
569+
}
570+
ok
514571
}
515572
}

0 commit comments

Comments
 (0)