Skip to content
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
2 changes: 1 addition & 1 deletion docs/guide/client-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
33 changes: 31 additions & 2 deletions webrtc/src/main/java/dev/onvoid/webrtc/client/Json.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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();

Expand Down Expand Up @@ -102,9 +119,15 @@ private Object readValue() {

switch (c) {
case '{':
return readObject();
enter();
Map<String, Object> object = readObject();
depth--;
return object;
case '[':
return readArray();
enter();
List<Object> array = readArray();
depth--;
return array;
case '"':
return readString();
case 't':
Expand Down Expand Up @@ -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 + "'");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
8 changes: 6 additions & 2 deletions webrtc/src/main/java/dev/onvoid/webrtc/client/Whep.java
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,15 @@ public CompletableFuture<Session> 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.
Expand Down
8 changes: 6 additions & 2 deletions webrtc/src/main/java/dev/onvoid/webrtc/client/Whip.java
Original file line number Diff line number Diff line change
Expand Up @@ -173,15 +173,19 @@ public CompletableFuture<Session> 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 {
for (LocalTrack track : tracks) {
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Whip.Session> 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<Whep.Session> 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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}

}
Loading