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

CVE-2018-18854 Use TreeMap instead of HashMap for JsObject key/value pairs, fixes #277 #280

Merged
merged 1 commit into from
Nov 7, 2018
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/main/scala/spray/json/JsValue.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
package spray.json

import collection.immutable
import scala.collection.immutable.TreeMap

/**
* The general type of a JSON AST node.
Expand Down Expand Up @@ -53,10 +54,10 @@ case class JsObject(fields: Map[String, JsValue]) extends JsValue {
def getFields(fieldNames: String*): immutable.Seq[JsValue] = fieldNames.toIterator.flatMap(fields.get).toList
}
object JsObject {
val empty = JsObject(Map.empty[String, JsValue])
def apply(members: JsField*) = new JsObject(Map(members: _*))
val empty = JsObject(TreeMap.empty[String, JsValue])
def apply(members: JsField*): JsObject = new JsObject(TreeMap(members: _*))
@deprecated("Use JsObject(JsValue*) instead", "1.3.0")
def apply(members: List[JsField]) = new JsObject(Map(members: _*))
def apply(members: List[JsField]): JsObject = apply(members: _*)
}

/**
Expand Down
7 changes: 4 additions & 3 deletions src/main/scala/spray/json/JsonParser.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ package spray.json

import scala.annotation.{switch, tailrec}
import java.lang.{StringBuilder => JStringBuilder}
import java.nio.{CharBuffer, ByteBuffer}
import java.nio.{ByteBuffer, CharBuffer}
import java.nio.charset.Charset

import scala.collection.immutable.TreeMap

/**
* Fast, no-dependency parser for JSON as defined by http://tools.ietf.org/html/rfc4627.
*/
Expand Down Expand Up @@ -86,8 +88,7 @@ class JsonParser(input: ParserInput) {
val nextMap = map.updated(key, jsValue)
if (ws(',')) members(nextMap) else nextMap
}
var map = Map.empty[String, JsValue]
map = members(map)
val map = members(TreeMap.empty[String, JsValue])
require('}')
JsObject(map)
} else {
Expand Down
26 changes: 26 additions & 0 deletions src/test/scala/spray/json/HashCodeCollider.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package spray.json

/**
* Helper that creates strings that all share the same hashCode == 0.
*
* Adapted from MIT-licensed code by Andriy Plokhotnyuk
* at https://github.com/plokhotnyuk/jsoniter-scala/blob/26b5ecdd4f8c2ab7e97bd8106cefdda4c1e701ce/jsoniter-scala-benchmark/src/main/scala/com/github/plokhotnyuk/jsoniter_scala/macros/HashCodeCollider.scala#L6.
*/
object HashCodeCollider {
val visibleChars = (33 until 127).filterNot(c => c == '\\' || c == '"')
def asciiChars: Iterator[Int] = visibleChars.toIterator
def asciiCharsAndHash(previousHash: Int): Iterator[(Int, Int)] = visibleChars.toIterator.map(c => c -> (previousHash + c) * 31)

/** Creates an iterator of Strings that all have hashCode == 0 */
def zeroHashCodeIterator(): Iterator[String] =
for {
(i0, h0) <- asciiCharsAndHash(0)
(i1, h1) <- asciiCharsAndHash(h0)
(i2, h2) <- asciiCharsAndHash(h1) if (((h2 + 32) * 923521) ^ ((h2 + 127) * 923521)) < 0
(i3, h3) <- asciiCharsAndHash(h2) if (((h3 + 32) * 29791) ^ ((h3 + 127) * 29791)) < 0
(i4, h4) <- asciiCharsAndHash(h3) if (((h4 + 32) * 961) ^ ((h4 + 127) * 961)) < 0
(i5, h5) <- asciiCharsAndHash(h4) if (((h5 + 32) * 31) ^ ((h5 + 127) * 31)) < 0
(i6, h6) <- asciiCharsAndHash(h5) if ((h6 + 32) ^ (h6 + 127)) < 0
(i7, h7) <- asciiCharsAndHash(h6) if h6 + i7 == 0
} yield new String(Array(i0, i1, i2, i3, i4, i5, i6, i7).map(_.toChar))
}
30 changes: 30 additions & 0 deletions src/test/scala/spray/json/JsonParserSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,36 @@ class JsonParserSpec extends Specification {
)
list.map(_.asInstanceOf[JsObject].fields("questions").asInstanceOf[JsArray].elements.size) === List.fill(20)(100)
}
"not show bad performance characteristics when object keys' hashCodes collide" in {
val numKeys = 10000
val value = "null"

val regularKeys = Iterator.from(1).map(i => s"key_$i").take(numKeys)
val collidingKeys = HashCodeCollider.zeroHashCodeIterator().take(numKeys)

def createJson(keys: Iterator[String]): String = keys.mkString("""{"""", s"""":$value,"""", s"""":$value}""")

def nanoBench(block: => Unit): Long = {
// great microbenchmark (the comment must be kept, otherwise it's not true)
val f = block _

// warmup
(1 to 10).foreach(_ => f())

val start = System.nanoTime()
f()
val end = System.nanoTime()
end - start
}

val regularJson = createJson(regularKeys)
val collidingJson = createJson(collidingKeys)

val regularTime = nanoBench { JsonParser(regularJson) }
val collidingTime = nanoBench { JsonParser(collidingJson) }

collidingTime / regularTime must be < 2L // speed must be in same order of magnitude
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you undo the fix, the ratio is ~ 100x slowdown for this test on my machine.

}

"produce proper error messages" in {
def errorMessage(input: String) =
Expand Down
19 changes: 12 additions & 7 deletions src/test/scala/spray/json/PrettyPrinterSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class PrettyPrinterSpec extends Specification {

"The PrettyPrinter" should {
"print a more complicated JsObject nicely aligned" in {
val JsObject(fields) = JsonParser {
val js = JsonParser {
"""{
| "Boolean no": false,
| "Boolean yes":true,
Expand All @@ -40,7 +40,12 @@ class PrettyPrinterSpec extends Specification {
| "zero": 0
|}""".stripMargin
}
PrettyPrinter(JsObject(ListMap(fields.toSeq.sortBy(_._1):_*))) mustEqual {
def fixedFieldOrder(js: JsValue): JsValue = js match {
case JsObject(fields) => JsObject(ListMap(fields.toSeq.sortBy(_._1).map { case (k, v) => (k, fixedFieldOrder(v)) }:_*))
case x => x
}

PrettyPrinter(fixedFieldOrder(js)) mustEqual {
"""{
| "Boolean no": false,
| "Boolean yes": true,
Expand All @@ -50,17 +55,17 @@ class PrettyPrinterSpec extends Specification {
| "number": -0.000012323424,
| "simpleKey": "some value",
| "sub object": {
| "sub key": 26.5,
| "a": "b",
| "array": [1, 2, {
| "yes": 1,
| "no": 0
| }, ["a", "b", null], false]
| "no": 0,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New rendering output is now ordered by the key Strings. However, that's not guaranteed anywhere so I fixed the test not to assume anything.

| "yes": 1
| }, ["a", "b", null], false],
| "sub key": 26.5
| },
| "zero": 0
|}""".stripMargin
}
}
}

}