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 array = readArray(); + depth--; + return array; case '"': return readString(); case 't': @@ -270,6 +293,12 @@ private Double readNumber() { } } + private void enter() { + if (++depth > MAX_DEPTH) { + throw error("Nested deeper than " + MAX_DEPTH + " levels"); + } + } + private void expect(String word) { if (!text.startsWith(word, pos)) { throw error("Expected '" + word + "'"); 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 f6da6fdd..42431cd7 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/SignalMessage.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/SignalMessage.java @@ -131,7 +131,9 @@ public static SignalMessage endOfCandidates() { * @return the message. * * @throws IllegalArgumentException if the text is no JSON or no signaling - * message. + * message, is longer than a MiB, or + * nests arrays and objects deeper than + * 64 levels. */ public static SignalMessage fromJson(String json) { requireNonNull(json, "json"); diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/Whep.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/Whep.java index cf47f0bd..4c84fc50 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/Whep.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/Whep.java @@ -168,11 +168,15 @@ public CompletableFuture play(WebRtc rtc) { options.iceServer(urls); } + // The builder may change while the session starts; the session keeps + // what it started with, so that its DELETE is authorized like its POST. + String token = bearerToken; + Duration limit = timeout; PeerImpl peer = (PeerImpl) rtc.createPeer(options.build()); try { - return peer.toPublic(HttpSession.negotiate(rtc, peer, endpoint, bearerToken, timeout) - .thenApply(resource -> new Session(rtc, peer, resource, bearerToken, timeout))); + return peer.toPublic(HttpSession.negotiate(rtc, peer, endpoint, token, limit) + .thenApply(resource -> new Session(rtc, peer, resource, token, limit))); } catch (RuntimeException | Error e) { // Until the session has it, no one else would close the peer. diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/client/Whip.java b/webrtc/src/main/java/dev/onvoid/webrtc/client/Whip.java index 5414858a..981b276f 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/client/Whip.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/client/Whip.java @@ -173,6 +173,10 @@ public CompletableFuture publish(WebRtc rtc) { options.iceServer(urls); } + // The builder may change while the session starts; the session keeps + // what it started with, so that its DELETE is authorized like its POST. + String token = bearerToken; + Duration limit = timeout; PeerImpl peer = (PeerImpl) rtc.createPeer(options.build()); try { @@ -180,8 +184,8 @@ public CompletableFuture publish(WebRtc rtc) { peer.addTrack(track); } - return peer.toPublic(HttpSession.negotiate(rtc, peer, endpoint, bearerToken, timeout) - .thenApply(resource -> new Session(rtc, peer, resource, bearerToken, timeout))); + return peer.toPublic(HttpSession.negotiate(rtc, peer, endpoint, token, limit) + .thenApply(resource -> new Session(rtc, peer, resource, token, limit))); } catch (RuntimeException | Error e) { // Until the session has it, no one else would close the peer. diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpSessionTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpSessionTests.java index a9f3167c..6d9babf8 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpSessionTests.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/client/HttpSessionTests.java @@ -305,6 +305,40 @@ void redirectNotFollowed() throws Exception { } } + @Test + void sessionKeepsItsToken() throws Exception { + // The builder is changed while the offer is out. The session must + // delete its resource with the token it created it with. + try (TestHttpEndpoint endpoint = receivingEndpoint()) { + CountDownLatch answer = endpoint.holdAnswers(); + Whip.Builder whip = Whip.to(endpoint.uri()).bearerToken("first").track(video); + CompletableFuture session = whip.publish(rtc); + + endpoint.awaitRequest("POST /live/endpoint", TIMEOUT_SECONDS); + whip.bearerToken("second"); + answer.countDown(); + await(await(session).closeAsync()); + + assertEquals("Bearer first", endpoint.requests.get(0).authorization); + assertEquals("DELETE /live/session/1", endpoint.requests.get(1).toString()); + assertEquals("Bearer first", endpoint.requests.get(1).authorization); + } + + try (TestHttpEndpoint endpoint = sendingEndpoint()) { + CountDownLatch answer = endpoint.holdAnswers(); + Whep.Builder whep = Whep.from(endpoint.uri()).bearerToken("first"); + CompletableFuture session = whep.play(rtc); + + endpoint.awaitRequest("POST /live/endpoint", TIMEOUT_SECONDS); + whep.bearerToken("second"); + answer.countDown(); + await(await(session).closeAsync()); + + assertEquals("DELETE /live/session/1", endpoint.requests.get(1).toString()); + assertEquals("Bearer first", endpoint.requests.get(1).authorization); + } + } + @Test void resourceKeepsTls() { URI endpoint = URI.create("https://media.example/whep"); diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/client/SignalMessageTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/client/SignalMessageTests.java index f5641529..aa0c1534 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/client/SignalMessageTests.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/client/SignalMessageTests.java @@ -20,6 +20,8 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import java.util.Arrays; + import dev.onvoid.webrtc.RTCIceCandidate; import dev.onvoid.webrtc.RTCSdpType; import dev.onvoid.webrtc.RTCSessionDescription; @@ -82,4 +84,43 @@ void invalidMessagesRejected() { () -> SignalMessage.of(new RTCSessionDescription(RTCSdpType.ROLLBACK, ""))); } + @Test + void deepNestingRejected() { + // In a member the message does not know, which is read all the same. + // Without a limit, 10,000 levels overflow the stack. + String message = "{\"type\":\"answer\",\"sdp\":\"v=0\\r\\n\",\"extra\":%s}"; + + assertEquals(SignalMessage.Type.ANSWER, + SignalMessage.fromJson(String.format(message, nested(Json.MAX_DEPTH - 1))).type()); + assertThrows(IllegalArgumentException.class, + () -> SignalMessage.fromJson(String.format(message, nested(Json.MAX_DEPTH)))); + assertThrows(IllegalArgumentException.class, + () -> SignalMessage.fromJson(String.format(message, nested(10_000)))); + } + + @Test + void oversizedMessageRejected() { + char[] sdp = new char[Json.MAX_LENGTH]; + Arrays.fill(sdp, 'a'); + + assertThrows(IllegalArgumentException.class, + () -> SignalMessage.fromJson("{\"type\":\"offer\",\"sdp\":\"" + new String(sdp) + "\"}")); + } + + /** + * @return arrays nested the given number of levels deep. + */ + private static String nested(int levels) { + StringBuilder out = new StringBuilder(); + + for (int i = 0; i < levels; i++) { + out.append('['); + } + for (int i = 0; i < levels; i++) { + out.append(']'); + } + + return out.toString(); + } + }