Skip to content
42 changes: 42 additions & 0 deletions examples/demo/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
ParticipantEvent,
RemoteParticipant,
RemoteTrackPublication,
RemoteVideoTrack,
Room,
RoomEvent,
ScreenSharePresets,
Expand All @@ -36,10 +37,12 @@
isLocalTrack,
isRemoteParticipant,
isRemoteTrack,
isVideoTrack,
setLogLevel,
supportsAV1,
supportsVP9,
} from '../../src/index';
import { TrackEvent } from '../../src/room/events';
import type { DataTrackFrame } from '../../src/room/data-track/frame';
import { isSVCCodec, sleep, supportsH265 } from '../../src/room/utils';

Expand Down Expand Up @@ -243,6 +246,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 @@ -867,6 +907,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 2 days ago

In general, to fix unsafe HTML construction you should either (1) avoid innerHTML entirely and build the DOM with document.createElement and textContent/innerText, or (2) rigorously escape/sanitize any untrusted values before inserting them into HTML. Since this is demo UI code and we want to preserve functionality while minimizing changes, the best fix here is to ensure that any untrusted dynamic value interpolated into the HTML string is passed through a simple HTML-escaping helper before being inserted into innerHTML.

Concretely, in examples/demo/demo.ts we should define a small escapeHtml function near the rendering helpers that replaces &, <, >, ", and ' with their corresponding HTML entities. Then, inside renderParticipant, we should compute a safe version of the participant identity, e.g. const safeIdentity = escapeHtml(identity);, and use safeIdentity instead of identity in every interpolated position within the div.innerHTML template. This keeps the structure of the HTML unchanged (IDs, class names, etc.) but ensures that any untrusted characters are escaped and can no longer break out of their intended context to execute scripts. This one change addresses all CodeQL variants that trace through participant/track data into the innerHTML template.

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
@@ -877,42 +877,52 @@
   })();
 }
 
+function escapeHtml(value: string): string {
+  return value
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#39;');
+}
+
 // updates participant UI
 function renderParticipant(participant: Participant, remove: boolean = false) {
   const container = getParticipantsAreaElement();
   if (!container) return;
   const { identity } = participant;
-  let div = container.querySelector(`#participant-${identity}`);
+  const safeIdentity = escapeHtml(identity);
+  let div = container.querySelector(`#participant-${safeIdentity}`);
   if (!div && !remove) {
     div = document.createElement('div');
-    div.id = `participant-${identity}`;
+    div.id = `participant-${safeIdentity}`;
     div.className = 'participant';
     div.innerHTML = `
-      <video id="video-${identity}"></video>
-      <audio id="audio-${identity}"></audio>
+      <video id="video-${safeIdentity}"></video>
+      <audio id="audio-${safeIdentity}"></audio>
       <div class="info-bar">
-        <div id="name-${identity}" class="name">
+        <div id="name-${safeIdentity}" class="name">
         </div>
         <div style="text-align: center;">
-          <span id="codec-${identity}" class="codec">
+          <span id="codec-${safeIdentity}" class="codec">
           </span>
-          <span id="size-${identity}" class="size">
+          <span id="size-${safeIdentity}" class="size">
           </span>
-          <span id="bitrate-${identity}" class="bitrate">
+          <span id="bitrate-${safeIdentity}" 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>
+          <span id="signal-${safeIdentity}"></span>
+          <span id="mic-${safeIdentity}" class="mic-on"></span>
+          <span id="e2ee-${safeIdentity}" class="e2ee-on"></span>
         </div>
       </div>
-      <div id="user-ts-${identity}" class="user-ts-overlay">
+      <div id="user-ts-${safeIdentity}" 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" />
+        <input id="volume-${safeIdentity}" type="range" min="0" max="1" step="0.1" value="1" orient="vertical" />
       </div>`
           : `<progress id="local-volume" max="1" value="0" />`
       }
@@ -920,8 +907,8 @@
     `;
     container.appendChild(div);
 
-    const sizeElm = container.querySelector(`#size-${identity}`);
-    const videoElm = <HTMLVideoElement>container.querySelector(`#video-${identity}`);
+    const sizeElm = container.querySelector(`#size-${safeIdentity}`);
+    const videoElm = <HTMLVideoElement>container.querySelector(`#video-${safeIdentity}`);
     videoElm.onresize = () => {
       updateVideoSize(videoElm!, sizeElm!);
     };
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;
}
55 changes: 35 additions & 20 deletions rollup.config.worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,38 @@ import typescript from 'rollup-plugin-typescript2';
import packageJson from './package.json';
import { commonPlugins, kebabCaseToPascalCase } from './rollup.config';

export default {
input: 'src/e2ee/worker/e2ee.worker.ts',
output: [
{
file: `dist/${packageJson.name}.e2ee.worker.mjs`,
format: 'es',
strict: true,
sourcemap: true,
},
{
file: `dist/${packageJson.name}.e2ee.worker.js`,
format: 'umd',
strict: true,
sourcemap: true,
name: kebabCaseToPascalCase(packageJson.name) + '.e2ee.worker',
plugins: [terser()],
},
],
plugins: [typescript({ tsconfig: './src/e2ee/worker/tsconfig.json' }), ...commonPlugins],
};
function workerConfig(input, suffix, umdName) {
return {
input,
output: [
{
file: `dist/${packageJson.name}.${suffix}.mjs`,
format: 'es',
strict: true,
sourcemap: true,
},
{
file: `dist/${packageJson.name}.${suffix}.js`,
format: 'umd',
strict: true,
sourcemap: true,
name: umdName,
plugins: [terser()],
},
],
plugins: [typescript({ tsconfig: './src/e2ee/worker/tsconfig.json' }), ...commonPlugins],
};
}

export default [
workerConfig(
'src/e2ee/worker/e2ee.worker.ts',
'e2ee.worker',
kebabCaseToPascalCase(packageJson.name) + '.e2ee.worker',
),
workerConfig(
'src/packet_trailer/packetTrailer.worker.ts',
'packet-trailer.worker',
kebabCaseToPascalCase(packageJson.name) + '.packetTrailer.worker',
),
];
36 changes: 36 additions & 0 deletions src/e2ee/E2eeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ConnectionState } from '../room/Room';
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,15 @@ export class E2EEManager
encryptFuture.resolve(data as EncryptDataResponseMessage['data']);
}
break;
case 'packetTrailer':
this.handleUserTimestamp(
data.trackId,
data.participantIdentity,
data.timestampUs,
data.frameId,
data.rtpTimestamp,
);
break;
default:
break;
}
Expand All @@ -231,6 +241,32 @@ export class E2EEManager
this.emit(EncryptionEvent.EncryptionError, ev.error, undefined);
};

private handleUserTimestamp(
trackId: string,
participantIdentity: string,
timestampUs: number,
frameId?: number,
rtpTimestamp?: number,
) {
if (!this.room) {
return;
Comment on lines +244 to +252
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Similar to my last comment - I don't know a ton about the user timestamp feature, but my suspicion is that putting handleUserTimestamp inside the E2eeManager is mixing concerns unnecesarily.

}
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
) {
pub.track.setUserTimestamp(timestampUs, rtpTimestamp, frameId);
return;
}
}
}

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

export interface PacketTrailerMessage extends BaseMessage {
kind: 'packetTrailer';
data: {
trackId: string;
participantIdentity: string;
timestampUs: number;
frameId?: number;
rtpTimestamp?: number;
};
}

export type E2EEWorkerMessage =
| InitMessage
| SetKeyMessage
Expand All @@ -166,7 +177,8 @@ export type E2EEWorkerMessage =
| DecryptDataRequestMessage
| DecryptDataResponseMessage
| EncryptDataRequestMessage
| EncryptDataResponseMessage;
| EncryptDataResponseMessage
| PacketTrailerMessage;

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

Expand Down
35 changes: 34 additions & 1 deletion src/e2ee/worker/FrameCryptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@ import { EventEmitter } from 'events';
import type TypedEventEmitter from 'typed-emitter';
import { workerLogger } from '../../logger';
import type { VideoCodec } from '../../room/track/options';
import { stripPacketTrailerFromEncodedFrame } from '../../packet_trailer/PacketTrailerTransformer';
import { ENCRYPTION_ALGORITHM, IV_LENGTH, UNENCRYPTED_BYTES } from '../constants';
import { CryptorError, CryptorErrorReason } from '../errors';
import { type CryptorCallbacks, CryptorEvent } from '../events';
import type { DecodeRatchetOptions, KeyProviderOptions, KeySet, RatchetResult } from '../types';
import type {
DecodeRatchetOptions,
KeyProviderOptions,
KeySet,
RatchetResult,
PacketTrailerMessage,
} from '../types';
import { deriveKeys, isVideoFrame, needsRbspUnescaping, parseRbsp, writeRbsp } from '../utils';
import type { ParticipantKeyHandler } from './ParticipantKeyHandler';
import { processNALUsForEncryption } from './naluUtils';
Expand Down Expand Up @@ -454,6 +461,32 @@ export class FrameCryptor extends BaseFrameCryptor {
encodedFrame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
controller: TransformStreamDefaultController,
) {
// Always attempt to strip LKTS packet 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 packetTrailerResult = stripPacketTrailerFromEncodedFrame(
encodedFrame as RTCEncodedVideoFrame,
);
if (packetTrailerResult !== undefined && this.trackId && this.participantIdentity) {
const msg: PacketTrailerMessage = {
kind: 'packetTrailer',
data: {
trackId: this.trackId,
participantIdentity: this.participantIdentity,
timestampUs: packetTrailerResult.timestampUs,
frameId: packetTrailerResult.frameId,
rtpTimestamp: packetTrailerResult.rtpTimestamp,
},
};
postMessage(msg);
}
} catch {
// Best-effort: never break media pipeline if timestamp parsing fails.
}
}

Comment on lines +464 to +489
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Is the FrameCryptor (which I believe runs as part of the e2ee worker, which may not be loaded if e2ee is not enabled) the best place for this logic to live?

Is there a reason it has to run in a web worker context (ie, maybe it is super computationally expensive / etc)? Is this feature supposed to work if e2ee is disabled?

if (
!this.isEnabled() ||
// skip for decryption for empty dtx frames
Expand Down
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,13 @@ export {
export { type DataChannelKind } from './room/RTCEngine';

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

export {
type UserFrameMetadata,
type PacketTrailerInfo,
type PacketTrailerWithRtp,
PACKET_TRAILER_MAGIC,
PACKET_TRAILER_SIZE,
extractPacketTrailer,
stripPacketTrailerFromEncodedFrame,
} from './packet_trailer';
Loading
Loading