diff --git a/docs/guide/client-api.md b/docs/guide/client-api.md
index e1926e33..1460d4df 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. 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.
+`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` that says what is wrong and where, as does text longer than a MiB or nested deeper than 64 levels, so a message from the remote side cannot exhaust the stack. The JSON must be strict (RFC 8259): no raw line breaks or other control characters inside strings, no member name twice in one object, no numbers like `01`, `1.` or `1e999`. An `sdpMLineIndex` must be an integer from 0 to 65535; `1.9` is rejected rather than truncated to 1. 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 09b43b34..d6ebaf9d 100644
--- a/webrtc/src/main/java/dev/onvoid/webrtc/client/Json.java
+++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/Json.java
@@ -26,6 +26,12 @@
* library. Objects become {@code Map}s, arrays {@code List}s, numbers {@code
* Double}s.
*
+ * The grammar is that of RFC 8259 and nothing more lenient: no control
+ * characters inside strings, numbers without leading zeros or bare points,
+ * only the four whitespace characters JSON has. A member name must not appear
+ * twice in an object, which would leave open which of the values counts, and
+ * a number must fit a {@code double}, since JSON has no infinities.
+ *
* 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
@@ -143,7 +149,7 @@ private Object readValue() {
if (c == '-' || (c >= '0' && c <= '9')) {
return readNumber();
}
- throw error("Unexpected character '" + c + "'");
+ throw error("Unexpected character " + describe(c));
}
}
@@ -164,7 +170,13 @@ private Map readObject() {
throw error("Expected a member name");
}
+ int start = pos;
String name = readString();
+
+ if (object.containsKey(name)) {
+ throw error("Duplicate member name " + quote(name), start);
+ }
+
skipWhitespace();
if (peek() != ':') {
@@ -229,6 +241,9 @@ private String readString() {
if (c == '"') {
return out.toString();
}
+ if (c < 0x20) {
+ throw error("Unescaped control character " + describe(c) + " in a string", pos - 1);
+ }
if (c != '\\') {
out.append(c);
continue;
@@ -264,12 +279,20 @@ private String readString() {
if (pos + 4 > text.length()) {
throw error("Incomplete unicode escape");
}
- try {
- out.append((char) Integer.parseInt(text.substring(pos, pos + 4), 16));
- }
- catch (NumberFormatException ex) {
- throw error("Invalid unicode escape");
+
+ int code = 0;
+
+ for (int i = 0; i < 4; i++) {
+ int digit = hexDigit(text.charAt(pos + i));
+
+ if (digit < 0) {
+ throw error("Invalid unicode escape", pos + i);
+ }
+
+ code = code << 4 | digit;
}
+
+ out.append((char) code);
pos += 4;
break;
default:
@@ -281,16 +304,59 @@ private String readString() {
private Double readNumber() {
int start = pos;
- while (pos < text.length() && "+-0123456789.eE".indexOf(text.charAt(pos)) >= 0) {
+ if (next('-')) {
pos++;
}
+ if (next('0')) {
+ pos++;
- try {
- return Double.valueOf(text.substring(start, pos));
+ if (nextIsDigit()) {
+ throw error("Leading zero in a number");
+ }
}
- catch (NumberFormatException e) {
- throw error("Invalid number");
+ else {
+ readDigits("Expected a digit");
}
+ if (next('.')) {
+ pos++;
+ readDigits("Expected a digit after the decimal point");
+ }
+ if (next('e') || next('E')) {
+ pos++;
+
+ if (next('+') || next('-')) {
+ pos++;
+ }
+
+ readDigits("Expected a digit in the exponent");
+ }
+
+ // The grammar above is a subset of what Double.valueOf reads.
+ Double value = Double.valueOf(text.substring(start, pos));
+
+ if (value.isInfinite()) {
+ throw error("Number out of range", start);
+ }
+
+ return value;
+ }
+
+ private void readDigits(String message) {
+ if (!nextIsDigit()) {
+ throw error(message);
+ }
+
+ while (nextIsDigit()) {
+ pos++;
+ }
+ }
+
+ private boolean next(char c) {
+ return pos < text.length() && text.charAt(pos) == c;
+ }
+
+ private boolean nextIsDigit() {
+ return pos < text.length() && text.charAt(pos) >= '0' && text.charAt(pos) <= '9';
}
private void enter() {
@@ -316,12 +382,55 @@ private char peek() {
}
private void skipWhitespace() {
- while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) {
+ while (pos < text.length() && isWhitespace(text.charAt(pos))) {
pos++;
}
}
private IllegalArgumentException error(String message) {
- return new IllegalArgumentException(message + " at position " + pos);
+ return error(message, pos);
+ }
+
+ private static IllegalArgumentException error(String message, int position) {
+ return new IllegalArgumentException(message + " at position " + position);
+ }
+
+ private static boolean isWhitespace(char c) {
+ return c == ' ' || c == '\t' || c == '\n' || c == '\r';
+ }
+
+ private static int hexDigit(char c) {
+ if (c >= '0' && c <= '9') {
+ return c - '0';
+ }
+ if (c >= 'a' && c <= 'f') {
+ return c - 'a' + 10;
+ }
+ if (c >= 'A' && c <= 'F') {
+ return c - 'A' + 10;
+ }
+
+ return -1;
+ }
+
+ /**
+ * @return the character for a message, readable whatever it is.
+ */
+ private static String describe(char c) {
+ if (c >= 0x20 && c < 0x7f) {
+ return "'" + c + "'";
+ }
+
+ return String.format("U+%04X", (int) c);
+ }
+
+ /**
+ * @return the text as a JSON string for a message, cut short if long.
+ */
+ private static String quote(String text) {
+ StringBuilder out = new StringBuilder();
+ writeString(out, text.length() > 32 ? text.substring(0, 32) + "..." : text);
+
+ return out.toString();
}
}
diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/SignalMessage.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/SignalMessage.java
index 42431cd7..fe3533b7 100644
--- a/webrtc/src/main/java/dev/onvoid/webrtc/client/SignalMessage.java
+++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/SignalMessage.java
@@ -64,6 +64,12 @@ public enum Type {
private static final SignalMessage END_OF_CANDIDATES =
new SignalMessage(Type.END_OF_CANDIDATES, null, null);
+ /**
+ * The largest {@code sdpMLineIndex}, which the browser API types as an
+ * {@code unsigned short}.
+ */
+ private static final int MAX_M_LINE_INDEX = 0xFFFF;
+
private final Type type;
private final RTCSessionDescription description;
@@ -104,6 +110,11 @@ public static SignalMessage of(RTCSessionDescription description) {
* @param candidate a local ICE candidate.
*
* @return a message that carries the candidate.
+ *
+ * @throws IllegalArgumentException if the candidate's {@code
+ * sdpMLineIndex} is not from 0 to
+ * 65535, which {@link #fromJson} would
+ * not read back.
*/
public static SignalMessage of(RTCIceCandidate candidate) {
requireNonNull(candidate, "candidate");
@@ -113,6 +124,8 @@ public static SignalMessage of(RTCIceCandidate candidate) {
return END_OF_CANDIDATES;
}
+ checkMLineIndex(candidate.sdpMLineIndex);
+
return new SignalMessage(Type.CANDIDATE, null, candidate);
}
@@ -130,10 +143,12 @@ public static SignalMessage endOfCandidates() {
*
* @return the message.
*
- * @throws IllegalArgumentException if the text is no JSON or no signaling
- * message, is longer than a MiB, or
- * nests arrays and objects deeper than
- * 64 levels.
+ * @throws IllegalArgumentException if the text is no strict JSON or no
+ * signaling message, is longer than a
+ * MiB, nests arrays and objects deeper
+ * than 64 levels, or has an {@code
+ * sdpMLineIndex} that is not an integer
+ * from 0 to 65535.
*/
public static SignalMessage fromJson(String json) {
requireNonNull(json, "json");
@@ -274,7 +289,7 @@ private static SignalMessage fromObject(Map, ?> object) {
}
return new SignalMessage(Type.CANDIDATE, null, new RTCIceCandidate(
- (String) mid, index == null ? 0 : ((Number) index).intValue(), sdp));
+ (String) mid, index == null ? 0 : mLineIndex((Number) index), sdp));
}
Object type = object.get("type");
@@ -296,4 +311,32 @@ private static SignalMessage fromObject(Map, ?> object) {
throw new IllegalArgumentException("Unsupported description type \"" + type + "\"");
}
}
+
+ /**
+ * @param number the {@code sdpMLineIndex} of a JSON message.
+ *
+ * @return the index, if the number is an integer from 0 to 65535.
+ */
+ private static int mLineIndex(Number number) {
+ double index = number.doubleValue();
+
+ // Checked as a double, not after a cast, which would truncate 1.9 to 1
+ // and clamp 1e300 to Integer.MAX_VALUE.
+ if (index != Math.rint(index)) {
+ throw new IllegalArgumentException("\"sdpMLineIndex\" is not an integer: " + number);
+ }
+ if (index < 0 || index > MAX_M_LINE_INDEX) {
+ throw new IllegalArgumentException("\"sdpMLineIndex\" is not from 0 to " + MAX_M_LINE_INDEX
+ + ": " + (Math.abs(index) < 1e15 ? Long.toString((long) index) : number.toString()));
+ }
+
+ return (int) index;
+ }
+
+ private static void checkMLineIndex(int index) {
+ if (index < 0 || index > MAX_M_LINE_INDEX) {
+ throw new IllegalArgumentException("\"sdpMLineIndex\" is not from 0 to " + MAX_M_LINE_INDEX
+ + ": " + index);
+ }
+ }
}
diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/client/JsonTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/client/JsonTests.java
new file mode 100644
index 00000000..837992a9
--- /dev/null
+++ b/webrtc/src/test/java/dev/onvoid/webrtc/client/JsonTests.java
@@ -0,0 +1,393 @@
+/*
+ * Copyright 2026 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.client;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.regex.Pattern;
+
+import org.junit.jupiter.api.DynamicTest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestFactory;
+
+class JsonTests {
+
+ /** The number grammar of RFC 8259, section 6. */
+ private static final Pattern NUMBER = Pattern.compile("-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?");
+
+ @TestFactory
+ List invalidJsonRejected() {
+ // Input, then the start of the message it fails with.
+ return rejected(
+ "\"a\nb\"", "Unescaped control character U+000A in a string at position 2",
+ "\"\r\n\"", "Unescaped control character U+000D",
+ "\"\t\"", "Unescaped control character U+0009",
+ "\"\u0000\"", "Unescaped control character U+0000",
+ "\"\u001F\"", "Unescaped control character U+001F",
+ "{\"a\nb\":1}", "Unescaped control character U+000A",
+ "\"\\u+041\"", "Invalid unicode escape at position 3",
+ "\"\\u-041\"", "Invalid unicode escape",
+ "\"\\u00G0\"", "Invalid unicode escape at position 5",
+ "\"\\u\uFF10041\"", "Invalid unicode escape",
+ "\"\\u12\"", "Incomplete unicode escape",
+ "\"\\x\"", "Invalid escape '\\x'",
+ "\"abc", "Unterminated string",
+ "01", "Leading zero in a number at position 1",
+ "-01", "Leading zero in a number",
+ "00", "Leading zero in a number",
+ "1.", "Expected a digit after the decimal point at position 2",
+ "1.e5", "Expected a digit after the decimal point",
+ ".5", "Unexpected character '.'",
+ "+1", "Unexpected character '+'",
+ "-", "Expected a digit at position 1",
+ "--1", "Expected a digit",
+ "-a", "Expected a digit",
+ "1e", "Expected a digit in the exponent",
+ "1e+", "Expected a digit in the exponent",
+ "1E-", "Expected a digit in the exponent",
+ "[0,1e999]", "Number out of range at position 3",
+ "-1e999", "Number out of range",
+ "Infinity", "Unexpected character 'I'",
+ "NaN", "Unexpected character 'N'",
+ "0x10", "Unexpected text after the JSON value",
+ "\f1", "Unexpected character U+000C",
+ "\u000B1", "Unexpected character U+000B",
+ "\u00A01", "Unexpected character U+00A0",
+ "\u20281", "Unexpected character U+2028",
+ "[1,\u001C2]", "Unexpected character U+001C",
+ "{\"a\":1,\"a\":2}", "Duplicate member name \"a\" at position 7",
+ "{\"\\u0061\":1,\"a\":2}", "Duplicate member name \"a\"",
+ "{\"a\":1,\"b\":{},\"a\":null}", "Duplicate member name \"a\"",
+ "[1,]", "Unexpected character ']'",
+ "{\"a\":1,}", "Expected a member name",
+ "{'a':1}", "Expected a member name",
+ "{\"a\" 1}", "Expected ':'",
+ "[1 2]", "Expected ',' or ']'",
+ "[1]//", "Unexpected text after the JSON value",
+ "tru", "Expected 'true'",
+ "", "Unexpected end of JSON",
+ " ", "Unexpected end of JSON");
+ }
+
+ @TestFactory
+ List validJsonAccepted() {
+ Map object = new LinkedHashMap<>();
+ object.put("a", Collections.singletonMap("a", 1.0));
+
+ // Input, then the value it is read as.
+ return accepted(
+ "0", 0.0,
+ "-0", -0.0,
+ "1.5e3", 1500.0,
+ "1E-2", 0.01,
+ "-12.25E+1", -122.5,
+ "1e-400", 0.0,
+ "1.7976931348623157e308", Double.MAX_VALUE,
+ "\"\\u0041\\u00e9\\uD83D\\uDE00\\/\"", "A\u00e9\uD83D\uDE00/",
+ "\"\u007F\u00e4\u2028\"", "\u007F\u00e4\u2028",
+ " \t\n\r[ 1 , {} ] \r\n", Arrays.asList(1.0, Collections.emptyMap()),
+ "{\"a\":{\"a\":1}}", object,
+ "null", null);
+ }
+
+ @Test
+ void numbersFollowGrammar() {
+ // Every text of up to six of these chars, which make up every part of
+ // the grammar and most of the ways to get it wrong.
+ char[] alphabet = "-+.eE019".toCharArray();
+ List texts = new ArrayList<>();
+ texts.add("");
+
+ for (int length = 1, from = 0; length <= 6; length++) {
+ int to = texts.size();
+
+ for (int i = from; i < to; i++) {
+ for (char c : alphabet) {
+ texts.add(texts.get(i) + c);
+ }
+ }
+
+ from = to;
+ }
+
+ // And longer ones, which reach the exponents that overflow a double.
+ Random random = new Random(1);
+
+ for (int i = 0; i < 20_000; i++) {
+ char[] text = new char[7 + random.nextInt(18)];
+
+ for (int j = 0; j < text.length; j++) {
+ text[j] = alphabet[random.nextInt(alphabet.length)];
+ }
+
+ texts.add(new String(text));
+ }
+
+ for (String text : texts) {
+ if (NUMBER.matcher(text).matches() && Double.isFinite(Double.parseDouble(text))) {
+ assertEquals(Double.valueOf(text), Json.parse(text), show(text));
+ }
+ else {
+ assertRejected(text);
+ }
+ }
+ }
+
+ @Test
+ void stringCharsFollowGrammar() {
+ for (int i = 0; i <= Character.MAX_VALUE; i++) {
+ char c = (char) i;
+ String raw = "\"" + c + "\"";
+
+ if (c >= 0x20 && c != '"' && c != '\\') {
+ assertEquals(String.valueOf(c), Json.parse(raw), show(raw));
+ }
+ else {
+ assertRejected(raw);
+ }
+
+ assertEquals(String.valueOf(c), Json.parse(write(String.valueOf(c))), show(raw));
+ }
+ }
+
+ @Test
+ void escapesFollowGrammar() {
+ for (int i = 0; i <= Character.MAX_VALUE; i++) {
+ char c = (char) i;
+ String escape = "\"\\" + c + "\"";
+
+ if ("\"\\/bfnrt".indexOf(c) >= 0) {
+ assertEquals(1, ((String) Json.parse(escape)).length(), show(escape));
+ }
+ else {
+ assertRejected(escape);
+ }
+
+ // Only ASCII hex digits, not signs nor digits of other scripts.
+ String unicode = "\"\\u00" + c + "1\"";
+ int digit = "0123456789abcdef".indexOf(Character.toLowerCase(c));
+
+ if (c < 0x80 && digit >= 0) {
+ assertEquals(String.valueOf((char) (digit << 4 | 1)), Json.parse(unicode), show(unicode));
+ }
+ else {
+ assertRejected(unicode);
+ }
+ }
+ }
+
+ @Test
+ void whitespaceFollowsGrammar() {
+ for (int i = 0; i <= Character.MAX_VALUE; i++) {
+ char c = (char) i;
+ String array = "[" + c + "]";
+
+ if (" \t\n\r".indexOf(c) >= 0) {
+ assertEquals(Collections.emptyList(), Json.parse(array), show(array));
+ }
+ else if (c >= '0' && c <= '9') {
+ assertEquals(Collections.singletonList((double) (c - '0')), Json.parse(array), show(array));
+ }
+ else {
+ assertRejected(array);
+ }
+ }
+ }
+
+ @Test
+ void generatedDocumentsRoundTrip() {
+ Random random = new Random(2);
+
+ for (int i = 0; i < 2_000; i++) {
+ Object value = randomValue(random, 0);
+ StringBuilder out = new StringBuilder();
+ write(out, value, random);
+
+ assertEquals(value, Json.parse(out.toString()), show(out.toString()));
+ }
+ }
+
+ private static List rejected(String... cases) {
+ List tests = new ArrayList<>();
+
+ for (int i = 0; i < cases.length; i += 2) {
+ String text = cases[i];
+ String message = cases[i + 1];
+
+ tests.add(DynamicTest.dynamicTest("`" + show(text) + "`", () -> {
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> Json.parse(text));
+
+ assertTrue(e.getMessage().startsWith(message), e.getMessage());
+ }));
+ }
+
+ return tests;
+ }
+
+ private static List accepted(Object... cases) {
+ List tests = new ArrayList<>();
+
+ for (int i = 0; i < cases.length; i += 2) {
+ String text = (String) cases[i];
+ Object value = cases[i + 1];
+
+ tests.add(DynamicTest.dynamicTest("`" + show(text) + "`", () -> assertEquals(value, Json.parse(text))));
+ }
+
+ return tests;
+ }
+
+ private static void assertRejected(String text) {
+ try {
+ Object value = Json.parse(text);
+
+ fail(show(text) + " was read as " + (value instanceof String ? show((String) value) : value));
+ }
+ catch (IllegalArgumentException e) {
+ // Rejected, as it should be.
+ }
+ }
+
+ private static Object randomValue(Random random, int depth) {
+ switch (random.nextInt(depth < 4 ? 7 : 5)) {
+ case 0:
+ return null;
+ case 1:
+ return random.nextBoolean();
+ case 2:
+ double d = Double.longBitsToDouble(random.nextLong());
+ return Double.isFinite(d) ? d : (double) random.nextInt();
+ case 3:
+ return (double) random.nextInt(1000) - 500;
+ case 4:
+ return randomString(random);
+ case 5:
+ List