Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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
42 changes: 42 additions & 0 deletions examples/demo/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
ParticipantEvent,
RemoteParticipant,
RemoteTrackPublication,
RemoteVideoTrack,
Room,
RoomEvent,
ScreenSharePresets,
Expand All @@ -34,10 +35,12 @@
isLocalTrack,
isRemoteParticipant,
isRemoteTrack,
isVideoTrack,
setLogLevel,
supportsAV1,
supportsVP9,
} from '../../src/index';
import { TrackEvent } from '../../src/room/events';
import { isSVCCodec, sleep, supportsH265 } from '../../src/room/utils';

setLogLevel(LogLevel.debug);
Expand Down Expand Up @@ -238,6 +241,43 @@
appendLog('subscribed to track', pub.trackSid, participant.identity);
renderParticipant(participant);
renderScreenShare(room);

// Display the user timestamp that matches the frame currently on screen.
// Publish & Subscribe timestamps update every frame; latency updates at ~2 Hz
// so it's readable (matching the Rust subscriber's approach).
if (track instanceof RemoteVideoTrack) {
let lastLatencyUpdate = 0;
let cachedUserTimestampUs: number | undefined;
let cachedLatencyStr = '';

track.on(TrackEvent.TimeSyncUpdate, ({ rtpTimestamp }) => {
const timestampUs = track.lookupUserTimestamp(rtpTimestamp);
if (timestampUs !== undefined) {
cachedUserTimestampUs = timestampUs;
}
if (cachedUserTimestampUs === undefined) return;

const now = Date.now();

if (now - lastLatencyUpdate >= 500) {
const nowUs = now * 1000;
const latencyMs = (nowUs - cachedUserTimestampUs) / 1000;
cachedLatencyStr = `${latencyMs.toFixed(1)}ms`;
lastLatencyUpdate = now;
}

const container = getParticipantsAreaElement();
const tsElm = container.querySelector(`#user-ts-${participant.identity}`);
if (tsElm) {
const pubStr = new Date(cachedUserTimestampUs / 1000).toISOString().substring(11, 23);
const subStr = new Date(now).toISOString().substring(11, 23);
tsElm.innerHTML =
`Publish:&nbsp;&nbsp;&nbsp;${pubStr}<br>` +
`Subscribe:&nbsp;${subStr}<br>` +
`Latency:&nbsp;&nbsp;&nbsp;${cachedLatencyStr}`;
}
});
}
})
.on(RoomEvent.TrackUnsubscribed, (_, pub, participant) => {
appendLog('unsubscribed from track', pub.trackSid);
Expand Down Expand Up @@ -811,6 +851,8 @@
<span id="e2ee-${identity}" class="e2ee-on"></span>
</div>
</div>
<div id="user-ts-${identity}" class="user-ts-overlay">

Check warning

Code scanning / CodeQL

Unsafe HTML constructed from library input Medium

This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.
This HTML construction which depends on
library input
might later allow
cross-site scripting
.

Copilot Autofix

AI 14 days ago

General approach: Avoid constructing HTML with untrusted values via innerHTML and instead either (1) construct the DOM tree with document.createElement/appendChild while assigning IDs directly as properties, or (2) sanitize/escape untrusted data before inserting into HTML. Since the only dynamic piece here is identity used for element IDs, the safest, least invasive fix is to build the elements programmatically and assign id/className via DOM properties, which do not interpret the string as HTML.

Best way to fix this case without changing functionality:

  • Replace the block that sets div.innerHTML = \...`` with code that:
    • Creates video, audio, div, span, progress/input elements via document.createElement.
    • Sets id, className, inline style, and other attributes using properties or setAttribute.
    • Uses the existing isLocalParticipant(participant) check to decide whether to append a volume-control <div> with an <input> range or a <progress> element.
  • This preserves all existing element IDs and classes, so the rest of the code that looks them up by ID (#video-${identity}, #user-ts-${identity}, etc.) continues to work.
  • No changes are required in Room.ts, Participant.ts, RemoteParticipant.ts, or LocalParticipant.ts; CodeQL’s taint traces into those files will be cut once the innerHTML sink is removed.

Concretely:

  • In examples/demo/demo.ts, inside renderParticipant, replace lines 833–863 (the template literal assigned to div.innerHTML) with imperative DOM creation code as described above.

No new helper methods or imports are required; we can use the standard DOM API already available.


Suggested changeset 1
examples/demo/demo.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/examples/demo/demo.ts b/examples/demo/demo.ts
--- a/examples/demo/demo.ts
+++ b/examples/demo/demo.ts
@@ -830,37 +830,91 @@
     div = document.createElement('div');
     div.id = `participant-${identity}`;
     div.className = 'participant';
-    div.innerHTML = `
-      <video id="video-${identity}"></video>
-      <audio id="audio-${identity}"></audio>
-      <div class="info-bar">
-        <div id="name-${identity}" class="name">
-        </div>
-        <div style="text-align: center;">
-          <span id="codec-${identity}" class="codec">
-          </span>
-          <span id="size-${identity}" class="size">
-          </span>
-          <span id="bitrate-${identity}" class="bitrate">
-          </span>
-        </div>
-        <div class="right">
-          <span id="signal-${identity}"></span>
-          <span id="mic-${identity}" class="mic-on"></span>
-          <span id="e2ee-${identity}" class="e2ee-on"></span>
-        </div>
-      </div>
-      <div id="user-ts-${identity}" class="user-ts-overlay">
-      </div>
-      ${
-        !isLocalParticipant(participant)
-          ? `<div class="volume-control">
-        <input id="volume-${identity}" type="range" min="0" max="1" step="0.1" value="1" orient="vertical" />
-      </div>`
-          : `<progress id="local-volume" max="1" value="0" />`
-      }
 
-    `;
+    const videoEl = document.createElement('video');
+    videoEl.id = `video-${identity}`;
+    const audioEl = document.createElement('audio');
+    audioEl.id = `audio-${identity}`;
+
+    const infoBar = document.createElement('div');
+    infoBar.className = 'info-bar';
+
+    const nameEl = document.createElement('div');
+    nameEl.id = `name-${identity}`;
+    nameEl.className = 'name';
+    infoBar.appendChild(nameEl);
+
+    const centerInfo = document.createElement('div');
+    centerInfo.style.textAlign = 'center';
+
+    const codecEl = document.createElement('span');
+    codecEl.id = `codec-${identity}`;
+    codecEl.className = 'codec';
+    centerInfo.appendChild(codecEl);
+
+    const sizeElSpan = document.createElement('span');
+    sizeElSpan.id = `size-${identity}`;
+    sizeElSpan.className = 'size';
+    centerInfo.appendChild(sizeElSpan);
+
+    const bitrateEl = document.createElement('span');
+    bitrateEl.id = `bitrate-${identity}`;
+    bitrateEl.className = 'bitrate';
+    centerInfo.appendChild(bitrateEl);
+
+    infoBar.appendChild(centerInfo);
+
+    const rightInfo = document.createElement('div');
+    rightInfo.className = 'right';
+
+    const signalEl = document.createElement('span');
+    signalEl.id = `signal-${identity}`;
+    rightInfo.appendChild(signalEl);
+
+    const micEl = document.createElement('span');
+    micEl.id = `mic-${identity}`;
+    micEl.className = 'mic-on';
+    rightInfo.appendChild(micEl);
+
+    const e2eeEl = document.createElement('span');
+    e2eeEl.id = `e2ee-${identity}`;
+    e2eeEl.className = 'e2ee-on';
+    rightInfo.appendChild(e2eeEl);
+
+    infoBar.appendChild(rightInfo);
+
+    const userTsOverlay = document.createElement('div');
+    userTsOverlay.id = `user-ts-${identity}`;
+    userTsOverlay.className = 'user-ts-overlay';
+
+    div.appendChild(videoEl);
+    div.appendChild(audioEl);
+    div.appendChild(infoBar);
+    div.appendChild(userTsOverlay);
+
+    if (!isLocalParticipant(participant)) {
+      const volumeControl = document.createElement('div');
+      volumeControl.className = 'volume-control';
+
+      const volumeInput = document.createElement('input');
+      volumeInput.id = `volume-${identity}`;
+      volumeInput.type = 'range';
+      volumeInput.min = '0';
+      volumeInput.max = '1';
+      volumeInput.step = '0.1';
+      volumeInput.value = '1';
+      (volumeInput as any).orient = 'vertical';
+
+      volumeControl.appendChild(volumeInput);
+      div.appendChild(volumeControl);
+    } else {
+      const localVolume = document.createElement('progress');
+      localVolume.id = 'local-volume';
+      localVolume.max = 1;
+      localVolume.value = 0;
+      div.appendChild(localVolume);
+    }
+
     container.appendChild(div);
 
     const sizeElm = container.querySelector(`#size-${identity}`);
EOF
Copilot is powered by AI and may make mistakes. Always verify output.
</div>
${
!isLocalParticipant(participant)
? `<div class="volume-control">
Expand Down
14 changes: 14 additions & 0 deletions examples/demo/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,17 @@
position: absolute;
z-index: 4;
}

.participant .user-ts-overlay {
position: absolute;
bottom: 28px;
left: 0;
z-index: 5;
font-family: monospace;
font-size: 0.65em;
color: #eee;
background: rgba(0, 0, 0, 0.5);
padding: 3px 6px;
line-height: 1.4;
border-radius: 0 3px 0 0;
}
20 changes: 20 additions & 0 deletions src/e2ee/E2eeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { DeviceUnsupportedError } from '../room/errors';
import { EngineEvent, ParticipantEvent, RoomEvent } from '../room/events';
import type RemoteTrack from '../room/track/RemoteTrack';
import RemoteVideoTrack from '../room/track/RemoteVideoTrack';
import type { Track } from '../room/track/Track';
import type { VideoCodec } from '../room/track/options';
import { mimeTypeToVideoCodecString } from '../room/track/utils';
Expand Down Expand Up @@ -221,6 +222,9 @@
encryptFuture.resolve(data as EncryptDataResponseMessage['data']);
}
break;
case 'userTimestamp':
this.handleUserTimestamp(data.trackId, data.participantIdentity, data.timestampUs, data.rtpTimestamp);

Check failure on line 226 in src/e2ee/E2eeManager.ts

View workflow job for this annotation

GitHub Actions / test

Replace `data.trackId,·data.participantIdentity,·data.timestampUs,·data.rtpTimestamp` with `⏎··········data.trackId,⏎··········data.participantIdentity,⏎··········data.timestampUs,⏎··········data.rtpTimestamp,⏎········`
break;
default:
break;
}
Expand All @@ -231,6 +235,22 @@
this.emit(EncryptionEvent.EncryptionError, ev.error, undefined);
};

private handleUserTimestamp(trackId: string, participantIdentity: string, timestampUs: number, rtpTimestamp?: number) {

Check failure on line 238 in src/e2ee/E2eeManager.ts

View workflow job for this annotation

GitHub Actions / test

Replace `trackId:·string,·participantIdentity:·string,·timestampUs:·number,·rtpTimestamp?:·number` with `⏎····trackId:·string,⏎····participantIdentity:·string,⏎····timestampUs:·number,⏎····rtpTimestamp?:·number,⏎··`
if (!this.room) {
return;
}
const participant = this.room.getParticipantByIdentity(participantIdentity);
if (!participant) {
return;
}
for (const pub of participant.trackPublications.values()) {
if (pub.track && pub.track.mediaStreamID === trackId && pub.track instanceof RemoteVideoTrack) {

Check failure on line 247 in src/e2ee/E2eeManager.ts

View workflow job for this annotation

GitHub Actions / test

Replace `pub.track·&&·pub.track.mediaStreamID·===·trackId·&&·pub.track·instanceof·RemoteVideoTrack` with `⏎········pub.track·&&⏎········pub.track.mediaStreamID·===·trackId·&&⏎········pub.track·instanceof·RemoteVideoTrack⏎······`
pub.track.setUserTimestamp(timestampUs, rtpTimestamp);
return;
}
}
}

public setupEngine(engine: RTCEngine) {
engine.on(EngineEvent.RTPVideoMapUpdate, (rtpMap) => {
this.postRTPMap(rtpMap);
Expand Down
13 changes: 12 additions & 1 deletion src/e2ee/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,16 @@ export interface EncryptDataResponseMessage extends BaseMessage {
};
}

export interface UserTimestampMessage extends BaseMessage {
kind: 'userTimestamp';
data: {
trackId: string;
participantIdentity: string;
timestampUs: number;
rtpTimestamp?: number;
};
}

export type E2EEWorkerMessage =
| InitMessage
| SetKeyMessage
Expand All @@ -165,7 +175,8 @@ export type E2EEWorkerMessage =
| DecryptDataRequestMessage
| DecryptDataResponseMessage
| EncryptDataRequestMessage
| EncryptDataResponseMessage;
| EncryptDataResponseMessage
| UserTimestampMessage;

export type KeySet = { material: CryptoKey; encryptionKey: CryptoKey };

Expand Down
27 changes: 27 additions & 0 deletions src/e2ee/worker/FrameCryptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { CryptorError, CryptorErrorReason } from '../errors';
import { type CryptorCallbacks, CryptorEvent } from '../events';
import type { DecodeRatchetOptions, KeyProviderOptions, KeySet, RatchetResult } from '../types';
import { deriveKeys, isVideoFrame, needsRbspUnescaping, parseRbsp, writeRbsp } from '../utils';
import { stripUserTimestampFromEncodedFrame } from '../../user_timestamp/UserTimestampTransformer';
import type { UserTimestampMessage } from '../types';
import type { ParticipantKeyHandler } from './ParticipantKeyHandler';
import { processNALUsForEncryption } from './naluUtils';
import { identifySifPayload } from './sifPayload';
Expand Down Expand Up @@ -412,6 +414,31 @@ export class FrameCryptor extends BaseFrameCryptor {
encodedFrame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
controller: TransformStreamDefaultController,
) {
// Always attempt to strip LKTS user timestamp trailer before any e2ee
// processing. On the send side, the trailer is appended *after* encryption,
// so it must be removed *before* decryption.
if (isVideoFrame(encodedFrame) && encodedFrame.data.byteLength > 0) {
try {
const userTsResult = stripUserTimestampFromEncodedFrame(
encodedFrame as RTCEncodedVideoFrame,
);
if (userTsResult !== undefined && this.trackId && this.participantIdentity) {
const msg: UserTimestampMessage = {
kind: 'userTimestamp',
data: {
trackId: this.trackId,
participantIdentity: this.participantIdentity,
timestampUs: userTsResult.timestampUs,
rtpTimestamp: userTsResult.rtpTimestamp,
},
};
postMessage(msg);
}
} catch {
// Best-effort: never break media pipeline if timestamp parsing fails.
}
}

if (
!this.isEnabled() ||
// skip for decryption for empty dtx frames
Expand Down
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,12 @@ export type {
};

export { LocalTrackRecorder } from './room/track/record';

export {
type UserTimestampInfo,
type UserTimestampWithRtp,
USER_TS_MAGIC,
USER_TS_TRAILER_SIZE,
extractUserTimestampTrailer,
stripUserTimestampFromEncodedFrame,
} from './user_timestamp';
6 changes: 3 additions & 3 deletions src/room/RTCEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import type { TrackPublishOptions, VideoCodec } from './track/options';
import { getTrackPublicationInfo } from './track/utils';
import type { LoggerOptions } from './types';
import {
isChromiumBased,
isVideoCodec,
isVideoTrack,
isWeb,
Expand Down Expand Up @@ -655,9 +656,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
private makeRTCConfiguration(serverResponse: JoinResponse | ReconnectResponse): RTCConfiguration {
const rtcConfig = { ...this.rtcConfig };

if (this.signalOpts?.e2eeEnabled) {
this.log.debug('E2EE - setting up transports with insertable streams', this.logContext);
// this makes sure that no data is sent before the transforms are ready
if (this.signalOpts?.e2eeEnabled || isChromiumBased()) {
this.log.debug('setting up transports with insertable streams', this.logContext);
// @ts-ignore
rtcConfig.encodedInsertableStreams = true;
}
Expand Down
76 changes: 76 additions & 0 deletions src/room/Room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,13 @@
import { EncryptionEvent } from '../e2ee';
import { type BaseE2EEManager, E2EEManager } from '../e2ee/E2eeManager';
import log, { LoggerNames, getLogger } from '../logger';
import type {

Check failure on line 39 in src/room/Room.ts

View workflow job for this annotation

GitHub Actions / test

Insert `{·isScriptTransformSupported·}·from·'../e2ee/utils';⏎import·`
InternalRoomConnectOptions,
InternalRoomOptions,
RoomConnectOptions,
RoomOptions,
} from '../options';
import { getBrowser } from '../utils/browserParser';

Check failure on line 45 in src/room/Room.ts

View workflow job for this annotation

GitHub Actions / test

Insert `';⏎import·{·stripUserTimestampFromEncodedFrame·}·from·'../user_timestamp/UserTimestampTransformer';⏎//@ts-ignore⏎import·UserTimestampWorker·from·'../user_timestamp/userTimestamp.worker?worker`
import { BackOffStrategy } from './BackOffStrategy';
import DeviceManager from './DeviceManager';
import RTCEngine from './RTCEngine';
Expand Down Expand Up @@ -74,7 +74,12 @@
import LocalVideoTrack from './track/LocalVideoTrack';
import type RemoteTrack from './track/RemoteTrack';
import RemoteTrackPublication from './track/RemoteTrackPublication';
import RemoteVideoTrack from './track/RemoteVideoTrack';
import { Track } from './track/Track';
import { stripUserTimestampFromEncodedFrame } from '../user_timestamp/UserTimestampTransformer';
//@ts-ignore
import UserTimestampWorker from '../user_timestamp/userTimestamp.worker?worker';
import { isScriptTransformSupported } from '../e2ee/utils';
import type { TrackPublication } from './track/TrackPublication';
import type { TrackProcessor } from './track/processor/types';
import type { AdaptiveStreamSettings } from './track/types';
Expand All @@ -93,6 +98,7 @@
getDisconnectReasonFromConnectionError,
getEmptyAudioStreamTrack,
isBrowserSupported,
isChromiumBased,
isCloud,
isLocalAudioTrack,
isLocalParticipant,
Expand Down Expand Up @@ -2177,6 +2183,13 @@
track.on(TrackEvent.VideoPlaybackFailed, this.handleVideoPlaybackFailed);
track.on(TrackEvent.VideoPlaybackStarted, this.handleVideoPlaybackStarted);
}
// When e2ee is NOT enabled, set up a standalone insertable-streams
// transform to extract LKTS user timestamp trailers from inbound
// video frames. When e2ee IS enabled, the FrameCryptor worker
// handles this before decryption.
if (!this.hasE2EESetup && track instanceof RemoteVideoTrack && track.receiver) {
this.setupUserTimestampTransform(track);
}
this.emit(RoomEvent.TrackSubscribed, track, publication, participant);
},
)
Expand Down Expand Up @@ -2591,6 +2604,69 @@
}
}

/**
* Sets up an insertable-streams transform on a remote video track's receiver
* to strip LKTS user timestamp trailers. Used only when e2ee is NOT enabled
* (when e2ee is enabled, the FrameCryptor worker handles this).
*
* Uses RTCRtpScriptTransform on browsers that support it (non-Chromium),
* and falls back to createEncodedStreams (legacy insertable streams) on
* Chromium where encodedInsertableStreams is enabled on the PeerConnection.
*/
private setupUserTimestampTransform(track: RemoteVideoTrack) {
const receiver = track.receiver;
if (!receiver) {
return;
}

try {
if (isScriptTransformSupported() && !isChromiumBased()) {
const worker = new UserTimestampWorker();
worker.onmessage = (ev: MessageEvent) => {
if (ev.data?.kind === 'userTimestamp' && typeof ev.data.timestampUs === 'number') {
track.setUserTimestamp(ev.data.timestampUs, ev.data.rtpTimestamp);
}
};
// @ts-ignore
receiver.transform = new RTCRtpScriptTransform(worker, { kind: 'decode' });
return;
}

// @ts-ignore
if (typeof receiver.createEncodedStreams === 'function') {
// @ts-ignore
const { readable, writable } = receiver.createEncodedStreams();

const transformStream = new TransformStream<RTCEncodedVideoFrame, RTCEncodedVideoFrame>({
transform: (encodedFrame, controller) => {
try {
const result = stripUserTimestampFromEncodedFrame(encodedFrame);
if (result !== undefined) {
track.setUserTimestamp(result.timestampUs, result.rtpTimestamp);
}
} catch {
// Best-effort: never break the media pipeline if parsing fails.
}
controller.enqueue(encodedFrame);
},
});

readable
.pipeThrough(transformStream)
.pipeTo(writable)
.catch(() => {});
return;
}

this.log.debug(
'user timestamp transform: no supported API available',
this.logContext,
);
} catch {
// If anything goes wrong (e.g., unsupported environment), just skip.
}
}

// /** @internal */
emit<E extends keyof RoomEventCallbacks>(
event: E,
Expand Down
Loading
Loading