From a18467f4eee4d0cf627cd331449496cae53d1f3d Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 15 Mar 2026 11:07:34 +0100 Subject: [PATCH 1/7] feat: add WebRTC signaling server plugin Lightweight signaling server for WebRTC peer-to-peer connections. Relays SDP offers/answers and ICE candidates between authenticated users. The actual media/data flows directly between peers. - New plugin: src/webrtc/index.js (~110 lines) - CLI: --webrtc, --webrtc-path - Env: JSS_WEBRTC, JSS_WEBRTC_PATH - WebSocket endpoint at /.webrtc (default) - WebID-based authentication required - Peer presence notifications (join/leave) Fixes #207 --- bin/jss.js | 6 +++ src/config.js | 6 +++ src/server.js | 9 ++++ src/webrtc/index.js | 120 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 src/webrtc/index.js diff --git a/bin/jss.js b/bin/jss.js index 3b225416..c02d7dc0 100755 --- a/bin/jss.js +++ b/bin/jss.js @@ -65,6 +65,9 @@ program .option('--no-nostr', 'Disable Nostr relay') .option('--nostr-path ', 'Nostr relay WebSocket path (default: /relay)') .option('--nostr-max-events ', 'Max events in relay memory (default: 1000)', parseInt) + .option('--webrtc', 'Enable WebRTC signaling server') + .option('--no-webrtc', 'Disable WebRTC signaling server') + .option('--webrtc-path ', 'WebRTC signaling WebSocket path (default: /.webrtc)') .option('--activitypub', 'Enable ActivityPub federation') .option('--no-activitypub', 'Disable ActivityPub federation') .option('--ap-username ', 'ActivityPub username (default: me)') @@ -142,6 +145,8 @@ program nostr: config.nostr, nostrPath: config.nostrPath, nostrMaxEvents: config.nostrMaxEvents, + webrtc: config.webrtc, + webrtcPath: config.webrtcPath, activitypub: config.activitypub, apUsername: config.apUsername, apDisplayName: config.apDisplayName, @@ -186,6 +191,7 @@ program if (config.solidosUi) console.log(' SolidOS UI: enabled (modern interface)'); if (config.git) console.log(' Git: enabled (clone/push support)'); if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`); + if (config.webrtc) console.log(` WebRTC: enabled (${config.webrtcPath || '/.webrtc'})`); if (config.activitypub) console.log(` ActivityPub: enabled (@${config.apUsername || 'me'})`); if (config.singleUser) console.log(` Single-user: ${config.singleUserName || 'me'} (registration disabled)`); else if (config.inviteOnly) console.log(' Registration: invite-only'); diff --git a/src/config.js b/src/config.js index 14cec6b6..7477a0f7 100644 --- a/src/config.js +++ b/src/config.js @@ -54,6 +54,10 @@ export const defaults = { nostrPath: '/relay', nostrMaxEvents: 1000, + // WebRTC signaling + webrtc: false, + webrtcPath: '/.webrtc', + // ActivityPub federation activitypub: false, apUsername: 'me', @@ -134,6 +138,8 @@ const envMap = { JSS_NOSTR: 'nostr', JSS_NOSTR_PATH: 'nostrPath', JSS_NOSTR_MAX_EVENTS: 'nostrMaxEvents', + JSS_WEBRTC: 'webrtc', + JSS_WEBRTC_PATH: 'webrtcPath', JSS_ACTIVITYPUB: 'activitypub', JSS_AP_USERNAME: 'apUsername', JSS_AP_DISPLAY_NAME: 'apDisplayName', diff --git a/src/server.js b/src/server.js index d4901180..4747e203 100644 --- a/src/server.js +++ b/src/server.js @@ -18,6 +18,7 @@ import { createPayHandler, isPayRequest } from './handlers/pay.js'; import { activityPubPlugin, getActorHandler } from './ap/index.js'; import { remoteStoragePlugin } from './remotestorage.js'; import { dbPlugin } from './db/index.js'; +import { webrtcPlugin } from './webrtc/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -74,6 +75,9 @@ export function createServer(options = {}) { const nostrEnabled = options.nostr ?? false; const nostrPath = options.nostrPath ?? '/relay'; const nostrMaxEvents = options.nostrMaxEvents ?? 1000; + // WebRTC signaling is OFF by default + const webrtcEnabled = options.webrtc ?? false; + const webrtcPath = options.webrtcPath ?? '/.webrtc'; // ActivityPub federation is OFF by default const activitypubEnabled = options.activitypub ?? false; const apUsername = options.apUsername ?? 'me'; @@ -240,6 +244,11 @@ export function createServer(options = {}) { }); } + // Register WebRTC signaling if enabled + if (webrtcEnabled) { + fastify.register(webrtcPlugin, { path: webrtcPath }); + } + // Register ActivityPub plugin if enabled if (activitypubEnabled) { fastify.register(activityPubPlugin, { diff --git a/src/webrtc/index.js b/src/webrtc/index.js new file mode 100644 index 00000000..c1a63689 --- /dev/null +++ b/src/webrtc/index.js @@ -0,0 +1,120 @@ +/** + * WebRTC Signaling Server Plugin + * + * Lightweight signaling server for WebRTC peer-to-peer connections. + * Relays SDP offers/answers and ICE candidates between authenticated users. + * The actual media/data flows directly between peers — JSS just introduces them. + * + * Usage: jss start --webrtc + * Endpoint: wss://your.pod/.webrtc + * + * Protocol (JSON over WebSocket): + * → { type: "offer", to: "", sdp: "..." } + * → { type: "answer", to: "", sdp: "..." } + * → { type: "candidate", to: "", candidate: {...} } + * → { type: "hangup", to: "" } + * ← { type: "offer", from: "", sdp: "..." } + * ← { type: "answer", from: "", sdp: "..." } + * ← { type: "candidate", from: "", candidate: {...} } + * ← { type: "hangup", from: "" } + * ← { type: "error", message: "..." } + * ← { type: "peers", peers: ["", ...] } + */ + +import websocket from '@fastify/websocket'; +import { getWebIdFromRequestAsync } from '../auth/token.js'; + +// Connected peers: webId → WebSocket +const peers = new Map(); + +/** + * Register WebRTC signaling routes on Fastify instance + * + * @param {object} fastify - Fastify instance + * @param {object} options - Options + * @param {string} options.path - WebSocket path (default: '/.webrtc') + */ +export async function webrtcPlugin(fastify, options = {}) { + const path = options.path || '/.webrtc'; + + await fastify.register(websocket); + + fastify.get(path, { websocket: true }, async (connection, request) => { + const socket = connection.socket; + + // Authenticate the connection + const { webId } = await getWebIdFromRequestAsync(request); + if (!webId) { + socket.send(JSON.stringify({ type: 'error', message: 'Authentication required' })); + socket.close(); + return; + } + + // Register this peer + const existing = peers.get(webId); + if (existing) { + existing.close(); + } + peers.set(webId, socket); + socket.webId = webId; + + // Notify the peer of their identity and online peers + socket.send(JSON.stringify({ + type: 'peers', + you: webId, + peers: [...peers.keys()].filter(id => id !== webId) + })); + + // Notify other peers that someone came online + broadcast(webId, { type: 'peer-joined', webId }); + + socket.on('message', (data) => { + let msg; + try { + msg = JSON.parse(data.toString()); + } catch { + socket.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' })); + return; + } + + if (!msg.to || !msg.type) { + socket.send(JSON.stringify({ type: 'error', message: 'Missing "to" or "type" field' })); + return; + } + + const target = peers.get(msg.to); + if (!target || target.readyState !== 1) { + socket.send(JSON.stringify({ type: 'error', message: 'Peer not online', peer: msg.to })); + return; + } + + // Relay the message, replacing 'to' with 'from' + const relay = { ...msg, from: webId }; + delete relay.to; + target.send(JSON.stringify(relay)); + }); + + socket.on('close', () => { + peers.delete(webId); + broadcast(webId, { type: 'peer-left', webId }); + }); + + socket.on('error', () => { + peers.delete(webId); + }); + }); +} + +/** + * Send a message to all connected peers except the sender + */ +function broadcast(senderWebId, msg) { + const data = JSON.stringify(msg); + for (const [id, socket] of peers) { + if (id !== senderWebId && socket.readyState === 1) { + socket.send(data); + } + } +} + +export default webrtcPlugin; From 325d69a894bf46b987eb5d23b18a90231255717c Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 15 Mar 2026 11:09:41 +0100 Subject: [PATCH 2/7] docs: add WebRTC signaling to README --- README.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/README.md b/README.md index a7f29572..3a771bb8 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,8 @@ jss --help # Show help | `--mongo` | Enable MongoDB-backed /db/ route | false | | `--mongo-url ` | MongoDB connection URL | mongodb://localhost:27017 | | `--mongo-database ` | MongoDB database name | solid | +| `--webrtc` | Enable WebRTC signaling server | false | +| `--webrtc-path ` | WebRTC signaling WebSocket path | /.webrtc | | `-q, --quiet` | Suppress logs | false | ### Environment Variables @@ -195,6 +197,7 @@ export JSS_PAY_RATE=10 export JSS_MONGO=true export JSS_MONGO_URL=mongodb://localhost:27017 export JSS_MONGO_DATABASE=solid +export JSS_WEBRTC=true jss start ``` @@ -823,6 +826,42 @@ curl -X DELETE http://localhost:3000/db/alice/notes/1 \ Supported formats: `50MB`, `1GB`, `500KB`, `1TB` +## WebRTC Signaling + +Peer-to-peer communication via WebRTC, using JSS as the signaling server. Once peers are connected, all media and data flows directly between them. + +```bash +jss start --webrtc +``` + +### How It Works + +1. Both peers connect to `wss://your.pod/.webrtc` (WebID auth required) +2. Caller sends an SDP offer targeting the callee's WebID +3. JSS relays the offer/answer and ICE candidates between peers +4. Once a direct path is found, the peer-to-peer connection is established +5. JSS steps out — video, audio, files, and data flow directly between peers + +### Protocol + +Messages are JSON over WebSocket: + +```js +// Send an offer to another user +{ "type": "offer", "to": "https://bob.example/profile/card#me", "sdp": "..." } + +// Receive an offer from another user +{ "type": "offer", "from": "https://alice.example/profile/card#me", "sdp": "..." } + +// ICE candidate exchange +{ "type": "candidate", "to": "https://bob.example/profile/card#me", "candidate": {...} } + +// Hang up +{ "type": "hangup", "to": "https://bob.example/profile/card#me" } +``` + +On connect, peers receive a list of online users and get notified when others join or leave. + ## HTTP 402 Paid Access Monetize API endpoints with per-request satoshi payments. Resources under `/pay/*` require NIP-98 authentication and a positive balance. From fa76cc4bf06efd994f910ecfcc87d435f99c2be8 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 15 Mar 2026 11:18:19 +0100 Subject: [PATCH 3/7] test: add WebRTC signaling tests and fix reconnection race - 6 tests covering auth, peer presence, full signaling relay, error handling - Skip WAC and dotfile checks for /.webrtc path - Fix reconnection race: old socket close handler no longer deletes the replacement socket from the peers map --- src/server.js | 3 +- src/webrtc/index.js | 14 +++- test/webrtc.test.js | 200 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 test/webrtc.test.js diff --git a/src/server.js b/src/server.js index 4747e203..7bbdf3ec 100644 --- a/src/server.js +++ b/src/server.js @@ -328,7 +328,7 @@ export function createServer(options = {}) { // Security: Block access to dotfiles except allowed Solid-specific ones // This prevents exposure of .git/, .env, .htpasswd, etc. // Git protocol requests bypass this check when git is enabled - const ALLOWED_DOTFILES = ['.well-known', '.acl', '.meta', '.pods', '.notifications', '.account']; + const ALLOWED_DOTFILES = ['.well-known', '.acl', '.meta', '.pods', '.notifications', '.account', '.webrtc']; fastify.addHook('onRequest', async (request, reply) => { // Allow git protocol requests through when git is enabled if (gitEnabled && isGitRequest(request.url)) { @@ -414,6 +414,7 @@ export function createServer(options = {}) { request.url.startsWith('/storage/') || (payEnabled && isPayRequest(request.url)) || (mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) || + (webrtcEnabled && request.url.startsWith(webrtcPath)) || mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) { return; } diff --git a/src/webrtc/index.js b/src/webrtc/index.js index c1a63689..e80371bb 100644 --- a/src/webrtc/index.js +++ b/src/webrtc/index.js @@ -50,9 +50,10 @@ export async function webrtcPlugin(fastify, options = {}) { return; } - // Register this peer + // Register this peer (close old connection if reconnecting) const existing = peers.get(webId); if (existing) { + peers.delete(webId); existing.close(); } peers.set(webId, socket); @@ -95,12 +96,17 @@ export async function webrtcPlugin(fastify, options = {}) { }); socket.on('close', () => { - peers.delete(webId); - broadcast(webId, { type: 'peer-left', webId }); + // Only remove if this socket is still the registered one (not replaced by reconnect) + if (peers.get(webId) === socket) { + peers.delete(webId); + broadcast(webId, { type: 'peer-left', webId }); + } }); socket.on('error', () => { - peers.delete(webId); + if (peers.get(webId) === socket) { + peers.delete(webId); + } }); }); } diff --git a/test/webrtc.test.js b/test/webrtc.test.js new file mode 100644 index 00000000..35447613 --- /dev/null +++ b/test/webrtc.test.js @@ -0,0 +1,200 @@ +/** + * WebRTC Signaling Server Tests + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert'; +import { WebSocket } from 'ws'; +import { + startTestServer, + stopTestServer, + createTestPod, + getBaseUrl, + getPodToken +} from './helpers.js'; + +describe('WebRTC Signaling', () => { + let wsUrl; + + before(async () => { + await startTestServer({ webrtc: true }); + await createTestPod('alice'); + await createTestPod('bob'); + const base = getBaseUrl(); + wsUrl = base.replace('http', 'ws') + '/.webrtc'; + }); + + after(async () => { + await stopTestServer(); + }); + + /** Connect an authenticated WebSocket for a pod user, waits for open */ + function connectPeer(podName) { + const token = getPodToken(podName); + const ws = new WebSocket(wsUrl, { + headers: { 'Authorization': `Bearer ${token}` } + }); + return ws; + } + + /** Connect and wait for the 'peers' welcome message */ + async function connectAndWait(podName) { + const ws = connectPeer(podName); + const msg = await waitForMessage(ws, 'peers'); + return { ws, ...msg }; + } + + /** Wait for a specific message type from a WebSocket */ + function waitForMessage(ws, type, timeout = 3000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timeout waiting for "${type}"`)), timeout); + ws.on('message', function handler(data) { + const msg = JSON.parse(data.toString()); + if (msg.type === type) { + clearTimeout(timer); + ws.removeListener('message', handler); + resolve(msg); + } + }); + }); + } + + /** Collect messages from a WebSocket for a duration */ + function collectMessages(ws, duration = 500) { + return new Promise((resolve) => { + const msgs = []; + const handler = (data) => msgs.push(JSON.parse(data.toString())); + ws.on('message', handler); + setTimeout(() => { + ws.removeListener('message', handler); + resolve(msgs); + }, duration); + }); + } + + describe('Authentication', () => { + it('should reject unauthenticated connections', async () => { + const ws = new WebSocket(wsUrl); + + const msg = await waitForMessage(ws, 'error'); + assert.strictEqual(msg.type, 'error'); + assert.ok(msg.message.includes('Authentication')); + ws.close(); + }); + + it('should accept authenticated connections', async () => { + const ws = connectPeer('alice'); + + const msg = await waitForMessage(ws, 'peers'); + assert.strictEqual(msg.type, 'peers'); + assert.ok(msg.you, 'Should include own WebID'); + assert.ok(Array.isArray(msg.peers), 'Should include peers list'); + ws.close(); + }); + }); + + describe('Peer Presence and Signaling Relay', () => { + it('should handle full signaling lifecycle', async () => { + // Alice connects first — should see no peers + const { ws: alice, you: aliceId } = await connectAndWait('alice'); + + // Bob joins — set up listener for peer-joined before bob connects + const joinPromise = waitForMessage(alice, 'peer-joined'); + const { ws: bob, you: bobId, peers: bobPeerList } = await connectAndWait('bob'); + + // Bob should see alice in the peer list + assert.strictEqual(bobPeerList.length, 1, 'Bob should see Alice'); + + // Alice should get peer-joined notification + const joinMsg = await joinPromise; + assert.strictEqual(joinMsg.type, 'peer-joined'); + + // 1. Alice sends offer to Bob + const offerPromise = waitForMessage(bob, 'offer'); + alice.send(JSON.stringify({ type: 'offer', to: bobId, sdp: 'v=0\r\n' })); + + const offer = await offerPromise; + assert.strictEqual(offer.type, 'offer'); + assert.strictEqual(offer.from, aliceId); + assert.ok(offer.sdp, 'Should include SDP'); + assert.strictEqual(offer.to, undefined, 'Should strip "to" field'); + + // 2. Bob sends answer to Alice + const answerPromise = waitForMessage(alice, 'answer'); + bob.send(JSON.stringify({ type: 'answer', to: aliceId, sdp: 'v=0\r\n' })); + + const answer = await answerPromise; + assert.strictEqual(answer.type, 'answer'); + assert.strictEqual(answer.from, bobId); + + // 3. Alice sends ICE candidate to Bob + const candidatePromise = waitForMessage(bob, 'candidate'); + alice.send(JSON.stringify({ + type: 'candidate', to: bobId, + candidate: { candidate: 'candidate:1 1 UDP 2122252543 192.168.1.1 12345 typ host', sdpMid: '0' } + })); + + const candidate = await candidatePromise; + assert.strictEqual(candidate.type, 'candidate'); + assert.ok(candidate.candidate.candidate); + + // 4. Alice sends hangup to Bob + const hangupPromise = waitForMessage(bob, 'hangup'); + alice.send(JSON.stringify({ type: 'hangup', to: bobId })); + + const hangup = await hangupPromise; + assert.strictEqual(hangup.type, 'hangup'); + assert.strictEqual(hangup.from, aliceId); + + // 5. Bob leaves — alice should get notified + const leavePromise = waitForMessage(alice, 'peer-left'); + bob.close(); + + const leaveMsg = await leavePromise; + assert.strictEqual(leaveMsg.type, 'peer-left'); + + alice.close(); + await new Promise(r => setTimeout(r, 100)); + }); + }); + + describe('Error Handling', () => { + it('should reject invalid JSON', async () => { + const alice = connectPeer('alice'); + await waitForMessage(alice, 'peers'); + + alice.send('not json'); + const err = await waitForMessage(alice, 'error'); + assert.strictEqual(err.message, 'Invalid JSON'); + + alice.close(); + }); + + it('should reject messages without "to" field', async () => { + const alice = connectPeer('alice'); + await waitForMessage(alice, 'peers'); + + alice.send(JSON.stringify({ type: 'offer', sdp: '...' })); + const err = await waitForMessage(alice, 'error'); + assert.ok(err.message.includes('Missing')); + + alice.close(); + }); + + it('should error when target peer is not online', async () => { + const alice = connectPeer('alice'); + await waitForMessage(alice, 'peers'); + + alice.send(JSON.stringify({ + type: 'offer', + to: 'https://nobody.example/profile/card#me', + sdp: '...' + })); + const err = await waitForMessage(alice, 'error'); + assert.ok(err.message.includes('not online')); + + alice.close(); + await new Promise(r => setTimeout(r, 50)); + }); + }); +}); From e641363126ed36239bb5ed8a9336e798d678fe9a Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 15 Mar 2026 11:27:45 +0100 Subject: [PATCH 4/7] fix: address Copilot review on WebRTC signaling - Move peers Map inside plugin (instance-scoped), add onClose cleanup hook - Guard @fastify/websocket registration to avoid duplicates - Allowlist signaling types (offer/answer/candidate/hangup), enforce 64KB max - Only add dotfile exception when webrtcEnabled, derive from webrtcPath - Fix test waitForMessage to clean up listeners on timeout/close --- src/server.js | 3 ++- src/webrtc/index.js | 53 ++++++++++++++++++++++++++++++++------------- test/webrtc.test.js | 18 ++++++++++++--- 3 files changed, 55 insertions(+), 19 deletions(-) diff --git a/src/server.js b/src/server.js index 7bbdf3ec..a2427f39 100644 --- a/src/server.js +++ b/src/server.js @@ -328,7 +328,8 @@ export function createServer(options = {}) { // Security: Block access to dotfiles except allowed Solid-specific ones // This prevents exposure of .git/, .env, .htpasswd, etc. // Git protocol requests bypass this check when git is enabled - const ALLOWED_DOTFILES = ['.well-known', '.acl', '.meta', '.pods', '.notifications', '.account', '.webrtc']; + const ALLOWED_DOTFILES = ['.well-known', '.acl', '.meta', '.pods', '.notifications', '.account']; + if (webrtcEnabled) ALLOWED_DOTFILES.push(webrtcPath.split('/').pop()); fastify.addHook('onRequest', async (request, reply) => { // Allow git protocol requests through when git is enabled if (gitEnabled && isGitRequest(request.url)) { diff --git a/src/webrtc/index.js b/src/webrtc/index.js index e80371bb..f55a04ea 100644 --- a/src/webrtc/index.js +++ b/src/webrtc/index.js @@ -24,8 +24,8 @@ import websocket from '@fastify/websocket'; import { getWebIdFromRequestAsync } from '../auth/token.js'; -// Connected peers: webId → WebSocket -const peers = new Map(); +const ALLOWED_TYPES = new Set(['offer', 'answer', 'candidate', 'hangup']); +const MAX_MESSAGE_SIZE = 64 * 1024; // 64KB /** * Register WebRTC signaling routes on Fastify instance @@ -37,7 +37,30 @@ const peers = new Map(); export async function webrtcPlugin(fastify, options = {}) { const path = options.path || '/.webrtc'; - await fastify.register(websocket); + // Instance-scoped peer state + const peers = new Map(); + + // Only register @fastify/websocket if not already registered + if (!fastify.websocketServer) { + await fastify.register(websocket); + } + + // Clean up all connections on server close + fastify.addHook('onClose', async () => { + for (const [, socket] of peers) { + socket.close(); + } + peers.clear(); + }); + + function broadcast(senderWebId, msg) { + const data = JSON.stringify(msg); + for (const [id, socket] of peers) { + if (id !== senderWebId && socket.readyState === 1) { + socket.send(data); + } + } + } fastify.get(path, { websocket: true }, async (connection, request) => { const socket = connection.socket; @@ -70,6 +93,12 @@ export async function webrtcPlugin(fastify, options = {}) { broadcast(webId, { type: 'peer-joined', webId }); socket.on('message', (data) => { + // Enforce max message size + if (data.length > MAX_MESSAGE_SIZE) { + socket.send(JSON.stringify({ type: 'error', message: 'Message too large' })); + return; + } + let msg; try { msg = JSON.parse(data.toString()); @@ -83,6 +112,12 @@ export async function webrtcPlugin(fastify, options = {}) { return; } + // Only relay known signaling types + if (!ALLOWED_TYPES.has(msg.type)) { + socket.send(JSON.stringify({ type: 'error', message: `Unknown type "${msg.type}"` })); + return; + } + const target = peers.get(msg.to); if (!target || target.readyState !== 1) { socket.send(JSON.stringify({ type: 'error', message: 'Peer not online', peer: msg.to })); @@ -111,16 +146,4 @@ export async function webrtcPlugin(fastify, options = {}) { }); } -/** - * Send a message to all connected peers except the sender - */ -function broadcast(senderWebId, msg) { - const data = JSON.stringify(msg); - for (const [id, socket] of peers) { - if (id !== senderWebId && socket.readyState === 1) { - socket.send(data); - } - } -} - export default webrtcPlugin; diff --git a/test/webrtc.test.js b/test/webrtc.test.js index 35447613..53d4aad8 100644 --- a/test/webrtc.test.js +++ b/test/webrtc.test.js @@ -47,15 +47,27 @@ describe('WebRTC Signaling', () => { /** Wait for a specific message type from a WebSocket */ function waitForMessage(ws, type, timeout = 3000) { return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`Timeout waiting for "${type}"`)), timeout); - ws.on('message', function handler(data) { + function handler(data) { const msg = JSON.parse(data.toString()); if (msg.type === type) { clearTimeout(timer); ws.removeListener('message', handler); + ws.removeListener('close', onClose); resolve(msg); } - }); + } + function onClose() { + clearTimeout(timer); + ws.removeListener('message', handler); + reject(new Error(`WebSocket closed while waiting for "${type}"`)); + } + const timer = setTimeout(() => { + ws.removeListener('message', handler); + ws.removeListener('close', onClose); + reject(new Error(`Timeout waiting for "${type}"`)); + }, timeout); + ws.on('message', handler); + ws.on('close', onClose); }); } From 72c6a16e7bd1dde3570b19020dc7eb773e63d50a Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 15 Mar 2026 11:37:57 +0100 Subject: [PATCH 5/7] fix: harden WebRTC signaling per Copilot review - Use Buffer.byteLength for reliable message size enforcement - Whitelist relay payload fields to prevent prototype pollution - Exact path match for dotfile and WAC bypass (no prefix leakage) - Dotfile exception only via early-return hook, not ALLOWED_DOTFILES array --- src/server.js | 9 +++++++-- src/webrtc/index.js | 18 ++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/server.js b/src/server.js index a2427f39..92986b34 100644 --- a/src/server.js +++ b/src/server.js @@ -329,7 +329,6 @@ export function createServer(options = {}) { // This prevents exposure of .git/, .env, .htpasswd, etc. // Git protocol requests bypass this check when git is enabled const ALLOWED_DOTFILES = ['.well-known', '.acl', '.meta', '.pods', '.notifications', '.account']; - if (webrtcEnabled) ALLOWED_DOTFILES.push(webrtcPath.split('/').pop()); fastify.addHook('onRequest', async (request, reply) => { // Allow git protocol requests through when git is enabled if (gitEnabled && isGitRequest(request.url)) { @@ -341,6 +340,12 @@ export function createServer(options = {}) { return; } + // Allow WebRTC signaling endpoint through when enabled + const urlNoQuery = request.url.split('?')[0]; + if (webrtcEnabled && urlNoQuery === webrtcPath) { + return; + } + const segments = request.url.split('/').map(s => s.split('?')[0]); // Remove query strings const hasForbiddenDotfile = segments.some(seg => seg.startsWith('.') && @@ -415,7 +420,7 @@ export function createServer(options = {}) { request.url.startsWith('/storage/') || (payEnabled && isPayRequest(request.url)) || (mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) || - (webrtcEnabled && request.url.startsWith(webrtcPath)) || + (webrtcEnabled && (request.url === webrtcPath || request.url.startsWith(webrtcPath + '?'))) || mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) { return; } diff --git a/src/webrtc/index.js b/src/webrtc/index.js index f55a04ea..028616d8 100644 --- a/src/webrtc/index.js +++ b/src/webrtc/index.js @@ -93,15 +93,16 @@ export async function webrtcPlugin(fastify, options = {}) { broadcast(webId, { type: 'peer-joined', webId }); socket.on('message', (data) => { - // Enforce max message size - if (data.length > MAX_MESSAGE_SIZE) { + // Enforce max message size (Buffer.byteLength for reliable byte count) + const raw = Buffer.isBuffer(data) ? data : Buffer.from(data); + if (raw.byteLength > MAX_MESSAGE_SIZE) { socket.send(JSON.stringify({ type: 'error', message: 'Message too large' })); return; } let msg; try { - msg = JSON.parse(data.toString()); + msg = JSON.parse(raw.toString()); } catch { socket.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' })); return; @@ -124,9 +125,14 @@ export async function webrtcPlugin(fastify, options = {}) { return; } - // Relay the message, replacing 'to' with 'from' - const relay = { ...msg, from: webId }; - delete relay.to; + // Build relay payload with whitelisted fields only (prevent prototype pollution) + const relay = Object.create(null); + relay.type = msg.type; + relay.from = webId; + if (typeof msg.sdp === 'string') relay.sdp = msg.sdp; + if (msg.candidate != null && typeof msg.candidate === 'object' && !Array.isArray(msg.candidate)) { + relay.candidate = msg.candidate; + } target.send(JSON.stringify(relay)); }); From d5062d22a003f92d17fda10eb7296796e75bb703 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 15 Mar 2026 11:45:29 +0100 Subject: [PATCH 6/7] fix: final Copilot nits on WebRTC signaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document peer-joined/peer-left/you in protocol comment - Only broadcast peer-joined for new connections, not reconnects - Let error handler defer cleanup to close handler - Grammar: "flows" → "flow" - Fix misleading test docstrings --- src/webrtc/index.js | 20 +++++++++++--------- test/webrtc.test.js | 4 ++-- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/webrtc/index.js b/src/webrtc/index.js index 028616d8..047c1a86 100644 --- a/src/webrtc/index.js +++ b/src/webrtc/index.js @@ -3,7 +3,7 @@ * * Lightweight signaling server for WebRTC peer-to-peer connections. * Relays SDP offers/answers and ICE candidates between authenticated users. - * The actual media/data flows directly between peers — JSS just introduces them. + * The actual media/data flow directly between peers — JSS just introduces them. * * Usage: jss start --webrtc * Endpoint: wss://your.pod/.webrtc @@ -18,7 +18,9 @@ * ← { type: "candidate", from: "", candidate: {...} } * ← { type: "hangup", from: "" } * ← { type: "error", message: "..." } - * ← { type: "peers", peers: ["", ...] } + * ← { type: "peers", you: "", peers: ["", ...] } + * ← { type: "peer-joined", webId: "" } + * ← { type: "peer-left", webId: "" } */ import websocket from '@fastify/websocket'; @@ -75,6 +77,7 @@ export async function webrtcPlugin(fastify, options = {}) { // Register this peer (close old connection if reconnecting) const existing = peers.get(webId); + const isReconnect = !!existing; if (existing) { peers.delete(webId); existing.close(); @@ -89,8 +92,10 @@ export async function webrtcPlugin(fastify, options = {}) { peers: [...peers.keys()].filter(id => id !== webId) })); - // Notify other peers that someone came online - broadcast(webId, { type: 'peer-joined', webId }); + // Only broadcast peer-joined for new connections, not reconnects + if (!isReconnect) { + broadcast(webId, { type: 'peer-joined', webId }); + } socket.on('message', (data) => { // Enforce max message size (Buffer.byteLength for reliable byte count) @@ -144,11 +149,8 @@ export async function webrtcPlugin(fastify, options = {}) { } }); - socket.on('error', () => { - if (peers.get(webId) === socket) { - peers.delete(webId); - } - }); + // Error handler: close event will follow and handle cleanup + socket.on('error', () => {}); }); } diff --git a/test/webrtc.test.js b/test/webrtc.test.js index 53d4aad8..47d5c7b0 100644 --- a/test/webrtc.test.js +++ b/test/webrtc.test.js @@ -28,7 +28,7 @@ describe('WebRTC Signaling', () => { await stopTestServer(); }); - /** Connect an authenticated WebSocket for a pod user, waits for open */ + /** Create an authenticated WebSocket for a pod user */ function connectPeer(podName) { const token = getPodToken(podName); const ws = new WebSocket(wsUrl, { @@ -37,7 +37,7 @@ describe('WebRTC Signaling', () => { return ws; } - /** Connect and wait for the 'peers' welcome message */ + /** Connect a peer and wait for the 'peers' welcome message */ async function connectAndWait(podName) { const ws = connectPeer(podName); const msg = await waitForMessage(ws, 'peers'); From 3e7e7e98fcf997c0088b4a23176beafaffda1258 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 15 Mar 2026 11:55:56 +0100 Subject: [PATCH 7/7] fix: guard send() calls with try/catch to prevent crash on closed sockets --- src/webrtc/index.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/webrtc/index.js b/src/webrtc/index.js index 047c1a86..1cbfd869 100644 --- a/src/webrtc/index.js +++ b/src/webrtc/index.js @@ -59,7 +59,7 @@ export async function webrtcPlugin(fastify, options = {}) { const data = JSON.stringify(msg); for (const [id, socket] of peers) { if (id !== senderWebId && socket.readyState === 1) { - socket.send(data); + try { socket.send(data); } catch { /* socket closed between check and send */ } } } } @@ -138,7 +138,9 @@ export async function webrtcPlugin(fastify, options = {}) { if (msg.candidate != null && typeof msg.candidate === 'object' && !Array.isArray(msg.candidate)) { relay.candidate = msg.candidate; } - target.send(JSON.stringify(relay)); + try { target.send(JSON.stringify(relay)); } catch { + socket.send(JSON.stringify({ type: 'error', message: 'Peer not online', peer: msg.to })); + } }); socket.on('close', () => {