diff --git a/docs/guide/client-api.md b/docs/guide/client-api.md
index 0ca4fac4..da5daef6 100644
--- a/docs/guide/client-api.md
+++ b/docs/guide/client-api.md
@@ -127,7 +127,7 @@ A `SignalMessage` is a session description (`OFFER`, `ANSWER`) or an ICE candida
{"candidate":"candidate:842163049 1 udp 1677729535 203.0.113.7 49203 typ srflx raddr 0.0.0.0 rport 0","sdpMid":"0","sdpMLineIndex":0}
```
-`SignalMessage.fromJson(String)` reads these, what a browser's `JSON.stringify(pc.localDescription)` and `JSON.stringify(candidate)` write, and the `{"description": ...}` and `{"candidate": {...}}` envelopes of the perfect negotiation examples. Routing, such as room names or peer ids, belongs in an envelope of your own around this JSON.
+`SignalMessage.fromJson(String)` reads these, what a browser's `JSON.stringify(pc.localDescription)` and `JSON.stringify(candidate)` write, and the `{"description": ...}` and `{"candidate": {...}}` envelopes of the perfect negotiation examples. Anything else throws an `IllegalArgumentException`, as does text longer than a MiB or nested deeper than 64 levels, so a message from the remote side cannot exhaust the stack. Routing, such as room names or peer ids, belongs in an envelope of your own around this JSON.
A browser peer that only answers needs no more than this:
diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/Json.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/Json.java
index 4b60096b..09b43b34 100644
--- a/webrtc/src/main/java/dev/onvoid/webrtc/client/Json.java
+++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/Json.java
@@ -25,19 +25,36 @@
* Just enough JSON for signaling messages, so that the module needs no JSON
* library. Objects become {@code Map}s, arrays {@code List}s, numbers {@code
* Double}s.
+ *
+ * Text comes from the remote side, so it is bounded: in length, like the
+ * answers of WHIP and WHEP endpoints, and in nesting, which a signaling
+ * message needs only a few levels of and the recursive parser would otherwise
+ * run out of stack on.
*/
final class Json {
+ /** The longest text read, in chars. */
+ static final int MAX_LENGTH = 1 << 20;
+
+ /** The deepest nesting of arrays and objects read. */
+ static final int MAX_DEPTH = 64;
+
private final String text;
private int pos;
+ private int depth;
+
private Json(String text) {
this.text = text;
}
static Object parse(String text) {
+ if (text.length() > MAX_LENGTH) {
+ throw new IllegalArgumentException("The JSON is longer than " + MAX_LENGTH + " chars");
+ }
+
Json json = new Json(text);
json.skipWhitespace();
@@ -102,9 +119,15 @@ private Object readValue() {
switch (c) {
case '{':
- return readObject();
+ enter();
+ Map object = readObject();
+ depth--;
+ return object;
case '[':
- return readArray();
+ enter();
+ List