Skip to content

Commit 815bbd7

Browse files
mkschulzeclaude
andcommitted
fix(security): patch chat-parser over-read and validate companion WS origin
Two independent issues found during a codebase security review. 1. NINJAM chat message parser (src/core/mpb.cpp) mpb_chat_message::parse assigned parms[x] = p before walking for the next NUL, and only checked for end-of-buffer AFTER an unconditional p++. A malicious server could send a chat message whose final parameter had no NUL terminator; parms[x] then pointed at unterminated memory. Downstream, njclient.cpp consumes the pointer via snprintf(" %s", ...) and ChatMessage_Callback, both of which C-string-walk past the buffer until they hit a stray NUL — a reliable heap info-disclosure primitive leaking bytes into chat UI and logs. The fix only records parms[x] AFTER confirming a NUL was seen strictly before endp; unterminated walks now break out of the loop and leave that slot null. 2. Video companion WebSocket server (juce/video/VideoCompanion.cpp,.h) The ixwebsocket server bound to 127.0.0.1:7170 accepted any handshake with no Origin or Host validation. Browsers do NOT apply SOP/CORS to raw WebSocket upgrades, so any webpage the user visited in another tab could open ws://127.0.0.1:7170 and, on Open, receive the config push (VDO.Ninja room ID, local username) plus subsequent roster broadcasts — then join the VDO.Ninja room directly to lurk on public-server sessions. - On Open, require Origin == "https://jamwide.audio" AND Host == "127.0.0.1:<port>" or "localhost:<port>". Failures are closed with code 1008 ("origin not allowed") BEFORE any state is sent. - A new validatedClients_ set (guarded by wsMutex_) tracks which clients passed validation. broadcastRoster, broadcastBufferDelay, requestPopout, and the deactivate farewell now iterate wsServer_->getClients() but only send when the raw pointer is in validatedClients_, so an unvalidated client that ixwebsocket briefly keeps in its client list during close cannot receive state. - Close/Error messages remove the pointer from the set; stop and destructor clear the set entirely so stale pointers cannot carry across a server restart. The legitimate companion page at https://jamwide.audio opens ws://127.0.0.1:7170 and both headers match, so the check is transparent to users. Host validation also mitigates DNS rebinding. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8df2436 commit 815bbd7

3 files changed

Lines changed: 96 additions & 10 deletions

File tree

juce/video/VideoCompanion.cpp

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ VideoCompanion::~VideoCompanion()
5656
wsServer_->stop();
5757
wsServer_.reset();
5858
}
59+
validatedClients_.clear();
5960
}
6061
}
6162

@@ -273,11 +274,61 @@ bool VideoCompanion::startWebSocketServer(const SessionSnapshot& snap)
273274
{
274275
if (!aliveFlag->load(std::memory_order_acquire))
275276
return;
277+
276278
if (msg->type == ix::WebSocketMessageType::Open)
277279
{
280+
// SECURITY: validate Origin and Host headers before emitting
281+
// any state. Browsers do NOT apply SOP/CORS to raw WebSocket
282+
// upgrades, so without this check any webpage the user visits
283+
// in another tab could connect to ws://127.0.0.1:7170 and
284+
// receive the room ID + roster on Open, then join VDO.Ninja
285+
// directly to lurk. Also guards against DNS rebinding — only
286+
// loopback Host values are accepted.
287+
//
288+
// The legit companion is loaded from https://jamwide.audio/
289+
// and its JS opens ws://127.0.0.1:<port>, so Origin will be
290+
// exactly "https://jamwide.audio" and Host will be
291+
// "127.0.0.1:<port>" (or "localhost:<port>" with some browsers).
292+
const auto& headers = msg->openInfo.headers;
293+
auto originIt = headers.find("Origin");
294+
auto hostIt = headers.find("Host");
295+
const std::string expectedHost1 = "127.0.0.1:" + std::to_string(snap.wsPort);
296+
const std::string expectedHost2 = "localhost:" + std::to_string(snap.wsPort);
297+
const bool originOk = (originIt != headers.end()
298+
&& originIt->second == "https://jamwide.audio");
299+
const bool hostOk = (hostIt != headers.end()
300+
&& (hostIt->second == expectedHost1
301+
|| hostIt->second == expectedHost2));
302+
if (!originOk || !hostOk)
303+
{
304+
DBG("VideoCompanion: rejecting WS client — Origin='"
305+
<< (originIt != headers.end() ? originIt->second : std::string("<missing>"))
306+
<< "' Host='"
307+
<< (hostIt != headers.end() ? hostIt->second : std::string("<missing>"))
308+
<< "'");
309+
// 1008 = policy violation. Not in WebSocketCloseConstants
310+
// but RFC 6455 defines it; ix::WebSocket::close accepts any
311+
// uint16_t.
312+
webSocket.close(1008, "origin not allowed");
313+
return; // do NOT add to validatedClients_ and do NOT send config
314+
}
315+
316+
// Validation passed — register and send initial state.
317+
{
318+
std::lock_guard<std::mutex> lock(wsMutex_);
319+
validatedClients_.insert(&webSocket);
320+
}
278321
sendConfigToClient(webSocket, snap);
279322
}
280-
// Close: no-op (client disconnected)
323+
else if (msg->type == ix::WebSocketMessageType::Close
324+
|| msg->type == ix::WebSocketMessageType::Error)
325+
{
326+
// Remove before the underlying ix::WebSocket is destroyed so
327+
// broadcasts never dereference a stale pointer. Error also
328+
// triggers cleanup — ixwebsocket tears the connection down.
329+
std::lock_guard<std::mutex> lock(wsMutex_);
330+
validatedClients_.erase(&webSocket);
331+
}
281332
}
282333
);
283334

@@ -319,6 +370,11 @@ void VideoCompanion::stopWebSocketServer()
319370
wsServer_->stop();
320371
serverToStop = std::move(wsServer_);
321372
// wsServer_ is now null — broadcastRoster/sendConfig will bail early
373+
// Clear the validated-clients set: the ix::WebSocket objects behind
374+
// those pointers are about to be destroyed with the server. Any stale
375+
// pointer lookup in broadcastRoster/broadcastBufferDelay is guarded
376+
// by the wsServer_ null check that fires first under the same lock.
377+
validatedClients_.clear();
322378
}
323379

324380
// Destroy the server object off the message thread to avoid DAW state-save timeout.
@@ -451,11 +507,17 @@ void VideoCompanion::broadcastRoster(const std::vector<NJClient::RemoteUserInfo>
451507

452508
json += "]}";
453509

454-
// Broadcast to all connected clients
510+
// Broadcast to validated clients only. Iterate the set copy of raw
511+
// pointers captured under the lock — then cross-reference each against
512+
// getClients() (which owns shared_ptrs) so we never dereference a stale
513+
// pointer. This prevents unvalidated clients from receiving roster state
514+
// even if ixwebsocket briefly keeps them around during close.
455515
auto clients = wsServer_->getClients();
516+
const auto payload = json.toStdString();
456517
for (auto& client : clients)
457518
{
458-
client->send(json.toStdString());
519+
if (validatedClients_.count(client.get()) > 0)
520+
client->send(payload);
459521
}
460522
}
461523

@@ -485,8 +547,10 @@ void VideoCompanion::broadcastBufferDelay(float bpm, int bpi)
485547
if (!wsServer_) return;
486548
cachedDelayMs_ = computed;
487549
auto clients = wsServer_->getClients();
550+
const auto payload = json.toStdString();
488551
for (auto& client : clients)
489-
client->send(json.toStdString());
552+
if (validatedClients_.count(client.get()) > 0)
553+
client->send(payload);
490554
}
491555

492556
// ── Popout / Roster Lookup (Phase 13) ─────────────────────────────────────
@@ -501,8 +565,10 @@ void VideoCompanion::requestPopout(const juce::String& streamId)
501565
std::lock_guard<std::mutex> lock(wsMutex_);
502566
if (!wsServer_) return;
503567
auto clients = wsServer_->getClients();
568+
const auto payload = json.toStdString();
504569
for (auto& client : clients)
505-
client->send(json.toStdString());
570+
if (validatedClients_.count(client.get()) > 0)
571+
client->send(payload);
506572
}
507573

508574
juce::String VideoCompanion::getStreamIdForUserIndex(int index) const
@@ -539,8 +605,10 @@ void VideoCompanion::deactivate()
539605
{
540606
juce::String json = "{\"type\":\"deactivate\"}";
541607
auto clients = wsServer_->getClients();
608+
const auto payload = json.toStdString();
542609
for (auto& client : clients)
543-
client->send(json.toStdString());
610+
if (validatedClients_.count(client.get()) > 0)
611+
client->send(payload);
544612
}
545613
}
546614

juce/video/VideoCompanion.h

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,17 @@ class VideoCompanion
156156
int wsPort_ = kDefaultWsPort;
157157

158158
std::unique_ptr<ix::WebSocketServer> wsServer_;
159-
std::mutex wsMutex_; // Guards wsServer_ start/stop and broadcast
159+
std::mutex wsMutex_; // Guards wsServer_ start/stop, broadcast, and validatedClients_
160160
std::future<void> stopFuture_; // WR-01 fix: joinable handle for async server teardown
161161

162+
// Security: only clients whose Origin and Host headers matched the companion
163+
// URL are added here on Open. Broadcasts iterate this set instead of
164+
// wsServer_->getClients() so a rogue webpage cannot receive config/roster
165+
// even briefly. Pointers are the address of the ix::WebSocket& passed to the
166+
// onClientMessage callback — valid from Open until Close, and Close removes
167+
// the entry. Guarded by wsMutex_.
168+
std::set<ix::WebSocket*> validatedClients_;
169+
162170
// Current session config (set on launch, sent to each connecting WS client)
163171
juce::String currentRoom_;
164172
juce::String currentPush_;

src/core/mpb.cpp

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -868,14 +868,24 @@ int mpb_chat_message::parse(Net_Message *msg) // return 0 on success
868868

869869
const char *endp=(char*)msg->get_data()+msg->get_size();
870870

871+
// Security fix: only record parms[x] AFTER confirming a NUL terminator
872+
// was seen strictly before endp. The original loop assigned parms[x]=p
873+
// before walking, then tested the end-of-buffer condition only after an
874+
// unconditional p++, which left the last parms[] pointing into an
875+
// unterminated region whenever the wire payload ran off the end without
876+
// a trailing NUL. Downstream consumers (snprintf %s, ChatMessage_Callback)
877+
// would then C-string-walk past the message buffer, leaking adjacent heap
878+
// contents to the chat UI and log. A malicious server could use this as a
879+
// reliable read primitive.
871880
int x;
872881
memset(parms,0,sizeof(parms));
873882
for (x = 0; x < (int) (sizeof(parms)/sizeof(parms[0])); x ++)
874883
{
875-
parms[x]=p;
884+
const char *start = p;
876885
while (p < endp && *p) p++;
877-
p++;
878-
if (p >= endp) break;
886+
if (p >= endp) break; // unterminated: do NOT record this pointer
887+
parms[x] = start;
888+
p++; // skip the NUL we just confirmed
879889
}
880890
return x?0:3;
881891
}

0 commit comments

Comments
 (0)