Fix review findings: stopping-flag race, unpinned SDK download, key-leak via redirect/logs
Code review findings from before merging feat/livekit-integration to main: - I1: sourceDestroy set self->stopping outside self->mutex, then notified. The worker's condition-variable predicate reads `stopping` under that same mutex, so the store+notify could land between the worker's predicate check and it entering the wait, dropping the notification and leaving the worker asleep for its full backoff (up to 30s) with the OBS UI thread blocked in worker.join(). Now set under the lock, matching how `generation` is already mutated in applySettings. - I3: the LiveKit SDK archive download in cmake/LiveKitSDK.cmake had no SHA256 pin wired up from the top-level CMakeLists.txt, unlike the obs-deps bootstrap right next to it. Added real SHA256 hashes -- computed by downloading each release archive and running sha256sum -- for every triple the pinned v1.10.1 release can resolve to (Linux x64/arm64, macOS x64/arm64, Windows x64), keyed by version+triple so a future version bump fails loudly (via message(WARNING)) instead of silently going unverified. Verified end-to-end locally: a deliberately wrong hash makes the configure step fail with a HASH mismatch error. Only Linux was also build-tested in this environment; macOS/Windows archives were downloaded and hashed but not build-tested here. - I4: the curl HTTP backend followed up to 3 redirects while the read key travels as a URL query parameter, so a malicious/misconfigured redirect (including an HTTPS->HTTP downgrade, which curl doesn't refuse by default) could leak the key. This client only ever talks to two fixed, first-party endpoints, so redirects are disabled outright (CURLOPT_FOLLOWLOCATION 0), matching the WinHTTP backend's existing default behavior. Left normalizeServerUrl's explicit-http:// pass-through as-is with a comment, per review guidance. - I5: ApiClient::redactedUrl was tested but never called. No current call site logs a request URL, so rather than inventing one, added a one-line comment marking it a deliberate guard rail for future logging. - I7: the LiveKit SDK log bridge (livekitLogToObs) wrote SDK messages straight into the OBS log. LiveKit's signaling URL carries the access token as a query parameter; defensively scrub "access_token=" and "key=" values before they ever reach obs_log. New ApiClient::redactSensitiveParams generalizes redactedUrl's redaction pattern to arbitrary text (not just a bare URL), with 6 new unit tests in test_api_client.cpp. - I2: added a code comment on session.cpp's auto_subscribe=true noting the known, unaddressed bandwidth/CPU cost of pulling every participant's track in multi-camera rooms, and that per-publication unsubscribe is a future optimization. No behavior change (out of scope per review). Verified: cmake configure + build + `ctest --test-dir build --output-on-failure` all pass, 6/6 suites (test_api_client now 127 checks, up from 121). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE
This commit is contained in:
@@ -73,17 +73,63 @@ set(STPLUGIN_LIVEKIT_SDK_TRIPLE "" CACHE STRING
|
|||||||
set(STPLUGIN_LIVEKIT_SDK_DIR "${CMAKE_BINARY_DIR}/_deps/livekit-sdk" CACHE PATH
|
set(STPLUGIN_LIVEKIT_SDK_DIR "${CMAKE_BINARY_DIR}/_deps/livekit-sdk" CACHE PATH
|
||||||
"Directory the client-sdk-cpp release archive is extracted into (point at a persistent path to cache it across CI builds)")
|
"Directory the client-sdk-cpp release archive is extracted into (point at a persistent path to cache it across CI builds)")
|
||||||
|
|
||||||
|
# Pinned SHA256 checksums for the client-sdk-cpp v1.10.1 release archives,
|
||||||
|
# so the download in cmake/LiveKitSDK.cmake is verified the same way the
|
||||||
|
# obs-deps bootstrap next to it already is (see
|
||||||
|
# cmake/common/buildspec_common.cmake ~line 324). Each hash below was
|
||||||
|
# computed by downloading the real GitHub release asset and running
|
||||||
|
# `sha256sum` on it (2026-09-06/07) -- none of these were guessed or copied
|
||||||
|
# from an unverified source. To add a hash for a new version or triple:
|
||||||
|
# curl -LO https://github.com/livekit/client-sdk-cpp/releases/download/v<VERSION>/livekit-sdk-<TRIPLE>-<VERSION>.<tar.gz|zip>
|
||||||
|
# sha256sum livekit-sdk-<TRIPLE>-<VERSION>.*
|
||||||
|
# Covers every triple _lk_default_triple() can resolve to for this pinned
|
||||||
|
# version: Linux (ubuntu-22.04-x64/arm64), macOS (macos-x64/arm64) and
|
||||||
|
# Windows (windows-x64). Verified by extracting each archive
|
||||||
|
# (tar tzf / unzip -l) and confirming a real LiveKitConfig.cmake inside --
|
||||||
|
# only Linux was also verified by an actual local CMake configure+build in
|
||||||
|
# this environment; macOS and Windows were downloaded and hashed but not
|
||||||
|
# build-tested here.
|
||||||
|
set(_stplugin_livekit_sha256_1.10.1_ubuntu-22.04-x64 "6f4fc8143f36952d42bfd5ff8d1782cf6211ba8fd6b055877e9ef85441d66324")
|
||||||
|
set(_stplugin_livekit_sha256_1.10.1_ubuntu-22.04-arm64 "399677167b474b7f107c6937904ea01898c9ec8da648cbed669387e599c6ea45")
|
||||||
|
set(_stplugin_livekit_sha256_1.10.1_macos-x64 "7102655c1f2947be4b06a95f9fafa1a11379219328e82ad875e5ebccfc9ac7e3")
|
||||||
|
set(_stplugin_livekit_sha256_1.10.1_macos-arm64 "0822af7014519a473c5b5cd019bde58c26cc2bfe5b78e4a790395ead232dc55b")
|
||||||
|
set(_stplugin_livekit_sha256_1.10.1_windows-x64 "b9fc6b2865298d7e3d032205d7e74fb9628cfe55a2ed28cb657db0a481cd518c")
|
||||||
|
|
||||||
include(LiveKitSDK)
|
include(LiveKitSDK)
|
||||||
|
if(STPLUGIN_LIVEKIT_SDK_TRIPLE)
|
||||||
|
set(_stplugin_livekit_triple "${STPLUGIN_LIVEKIT_SDK_TRIPLE}")
|
||||||
|
else()
|
||||||
|
# Mirrors LiveKitSDK.cmake's own autodetection so the checksum lookup
|
||||||
|
# below matches whatever triple livekit_sdk_setup() will actually
|
||||||
|
# resolve to and download.
|
||||||
|
_lk_default_triple(_stplugin_livekit_triple)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(_stplugin_livekit_sha256_var
|
||||||
|
"_stplugin_livekit_sha256_${STPLUGIN_LIVEKIT_SDK_VERSION}_${_stplugin_livekit_triple}")
|
||||||
|
if(DEFINED ${_stplugin_livekit_sha256_var})
|
||||||
|
set(_stplugin_livekit_sha256 "${${_stplugin_livekit_sha256_var}}")
|
||||||
|
else()
|
||||||
|
set(_stplugin_livekit_sha256 "")
|
||||||
|
message(WARNING
|
||||||
|
"LiveKitSDK: no pinned SHA256 for triple '${_stplugin_livekit_triple}' "
|
||||||
|
"at version ${STPLUGIN_LIVEKIT_SDK_VERSION} -- the downloaded archive "
|
||||||
|
"will NOT be integrity-checked. Compute one (see the comment above "
|
||||||
|
"this block) and add it to CMakeLists.txt.")
|
||||||
|
endif()
|
||||||
|
|
||||||
if(STPLUGIN_LIVEKIT_SDK_TRIPLE)
|
if(STPLUGIN_LIVEKIT_SDK_TRIPLE)
|
||||||
livekit_sdk_setup(
|
livekit_sdk_setup(
|
||||||
VERSION "${STPLUGIN_LIVEKIT_SDK_VERSION}"
|
VERSION "${STPLUGIN_LIVEKIT_SDK_VERSION}"
|
||||||
SDK_DIR "${STPLUGIN_LIVEKIT_SDK_DIR}"
|
SDK_DIR "${STPLUGIN_LIVEKIT_SDK_DIR}"
|
||||||
TRIPLE "${STPLUGIN_LIVEKIT_SDK_TRIPLE}"
|
TRIPLE "${STPLUGIN_LIVEKIT_SDK_TRIPLE}"
|
||||||
|
SHA256 "${_stplugin_livekit_sha256}"
|
||||||
)
|
)
|
||||||
else()
|
else()
|
||||||
livekit_sdk_setup(
|
livekit_sdk_setup(
|
||||||
VERSION "${STPLUGIN_LIVEKIT_SDK_VERSION}"
|
VERSION "${STPLUGIN_LIVEKIT_SDK_VERSION}"
|
||||||
SDK_DIR "${STPLUGIN_LIVEKIT_SDK_DIR}"
|
SDK_DIR "${STPLUGIN_LIVEKIT_SDK_DIR}"
|
||||||
|
SHA256 "${_stplugin_livekit_sha256}"
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
find_package(LiveKit CONFIG REQUIRED)
|
find_package(LiveKit CONFIG REQUIRED)
|
||||||
|
|||||||
@@ -115,6 +115,17 @@ public:
|
|||||||
/// with the read key replaced by "***".
|
/// with the read key replaced by "***".
|
||||||
static std::string redactedUrl(const std::string &url);
|
static std::string redactedUrl(const std::string &url);
|
||||||
|
|
||||||
|
/// Scrubs "access_token=<value>" and "key=<value>" out of arbitrary
|
||||||
|
/// text -- not necessarily a bare URL/query string -- replacing each
|
||||||
|
/// value with "<redacted>". Unlike redactedUrl (which only has to
|
||||||
|
/// handle "&"-delimited query parameters), a value here can be followed
|
||||||
|
/// by a quote or whitespace, because the text this scrubs is a free-form
|
||||||
|
/// log line that may merely *contain* a URL. Used by the OBS adapter's
|
||||||
|
/// LiveKit SDK log bridge: LiveKit's signaling URL carries the access
|
||||||
|
/// token as a query parameter, and the SDK's own log lines could
|
||||||
|
/// include it.
|
||||||
|
static std::string redactSensitiveParams(const std::string &text);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::shared_ptr<HttpClient> http_;
|
std::shared_ptr<HttpClient> http_;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ std::string ApiClient::normalizeServerUrl(const std::string &raw)
|
|||||||
const bool has_scheme = url.compare(0, 7, "http://") == 0 || url.compare(0, 8, "https://") == 0;
|
const bool has_scheme = url.compare(0, 7, "http://") == 0 || url.compare(0, 8, "https://") == 0;
|
||||||
if (!has_scheme)
|
if (!has_scheme)
|
||||||
url = "https://" + url;
|
url = "https://" + url;
|
||||||
|
// An explicit "http://..." is left as-is on purpose: an operator who
|
||||||
|
// typed the scheme out has made a deliberate (if inadvisable) choice,
|
||||||
|
// and this function's job is only to supply a sane default, not to
|
||||||
|
// second-guess an explicit one.
|
||||||
|
|
||||||
while (!url.empty() && url.back() == '/')
|
while (!url.empty() && url.back() == '/')
|
||||||
url.pop_back();
|
url.pop_back();
|
||||||
@@ -108,6 +112,11 @@ std::string ApiClient::normalizeServerUrl(const std::string &raw)
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No current call site logs a request URL (the OBS adapter only logs
|
||||||
|
// ws_url/status text, never the streamer-tools API request URL itself) --
|
||||||
|
// this exists as a deliberate guard rail for whenever request-URL logging
|
||||||
|
// is added later, so the read key can never be pasted into an OBS log by
|
||||||
|
// accident. Not dead code to be deleted.
|
||||||
std::string ApiClient::redactedUrl(const std::string &url)
|
std::string ApiClient::redactedUrl(const std::string &url)
|
||||||
{
|
{
|
||||||
const std::size_t at = url.find("key=");
|
const std::size_t at = url.find("key=");
|
||||||
@@ -120,6 +129,36 @@ std::string ApiClient::redactedUrl(const std::string &url)
|
|||||||
return url.substr(0, value) + "***" + url.substr(end);
|
return url.substr(0, value) + "***" + url.substr(end);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string ApiClient::redactSensitiveParams(const std::string &text)
|
||||||
|
{
|
||||||
|
static const char *const kParams[] = {"access_token=", "key="};
|
||||||
|
|
||||||
|
std::string out = text;
|
||||||
|
for (const char *param : kParams) {
|
||||||
|
const std::size_t param_len = std::string(param).size();
|
||||||
|
std::size_t pos = 0;
|
||||||
|
while ((pos = out.find(param, pos)) != std::string::npos) {
|
||||||
|
const std::size_t value_start = pos + param_len;
|
||||||
|
std::size_t value_end = value_start;
|
||||||
|
// A value ends at the next query-string delimiter, a quote (the
|
||||||
|
// URL is often embedded in a quoted/bracketed log line), or
|
||||||
|
// whitespace -- whichever comes first -- or at the end of the
|
||||||
|
// string.
|
||||||
|
while (value_end < out.size()) {
|
||||||
|
const char c = out[value_end];
|
||||||
|
if (c == '&' || c == '"' || c == '\'' || c == ' ' || c == '\t' || c == '\n' ||
|
||||||
|
c == '\r' || c == ')' || c == ']')
|
||||||
|
break;
|
||||||
|
++value_end;
|
||||||
|
}
|
||||||
|
const std::string replacement = "<redacted>";
|
||||||
|
out.replace(value_start, value_end - value_start, replacement);
|
||||||
|
pos = value_start + replacement.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config, int timeout_ms) const
|
SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config, int timeout_ms) const
|
||||||
{
|
{
|
||||||
SlotsResult result;
|
SlotsResult result;
|
||||||
|
|||||||
+11
-2
@@ -78,8 +78,17 @@ public:
|
|||||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx);
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx);
|
||||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, static_cast<long>(request.timeout_ms));
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, static_cast<long>(request.timeout_ms));
|
||||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, static_cast<long>(request.timeout_ms));
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, static_cast<long>(request.timeout_ms));
|
||||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
// Redirects are never legitimate here: this client only ever talks to
|
||||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
|
// two fixed, first-party streamer-tools API endpoints, and the read
|
||||||
|
// key travels as a URL query parameter (see api_client.cpp). Blindly
|
||||||
|
// following a redirect -- including an HTTPS->HTTP downgrade, which
|
||||||
|
// curl does not refuse by default -- would hand that key to whatever
|
||||||
|
// host the redirect points at. A redirect from our own server is a
|
||||||
|
// configuration error, so treat it as a failed request instead of
|
||||||
|
// silently following it. This also brings this backend in line with
|
||||||
|
// http_winhttp.cpp, which already refuses HTTPS->HTTP downgrades by
|
||||||
|
// default.
|
||||||
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
|
||||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "streamer-tools-obs-plugin/1.0");
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "streamer-tools-obs-plugin/1.0");
|
||||||
// NOSIGNAL is required whenever curl is used off the main thread:
|
// NOSIGNAL is required whenever curl is used off the main thread:
|
||||||
// without it curl installs a SIGALRM handler for DNS timeouts, which
|
// without it curl installs a SIGALRM handler for DNS timeouts, which
|
||||||
|
|||||||
@@ -617,6 +617,15 @@ bool LiveKitSession::connect(const SessionConfig &config)
|
|||||||
livekit::RoomOptions options;
|
livekit::RoomOptions options;
|
||||||
// auto_subscribe is what makes track_subscribed events (and therefore any
|
// auto_subscribe is what makes track_subscribed events (and therefore any
|
||||||
// media at all) happen; the SDK is emphatic about this.
|
// media at all) happen; the SDK is emphatic about this.
|
||||||
|
//
|
||||||
|
// Known, measured-but-unaddressed cost: auto_subscribe pulls every
|
||||||
|
// participant's published track, not just the one camera this session
|
||||||
|
// actually wants, and this client discards the unwanted ones
|
||||||
|
// client-side. In a multi-camera room that is real, wasted bandwidth
|
||||||
|
// and decode CPU that scales with room size, not with what this source
|
||||||
|
// displays. Selectively unsubscribing from unwanted publications (the
|
||||||
|
// SDK exposes per-publication subscribe/unsubscribe) is a real
|
||||||
|
// follow-up optimization, deliberately out of scope here.
|
||||||
options.auto_subscribe = true;
|
options.auto_subscribe = true;
|
||||||
options.dynacast = false;
|
options.dynacast = false;
|
||||||
// This client never publishes, so a single peer connection is all it
|
// This client never publishes, so a single peer connection is all it
|
||||||
|
|||||||
@@ -99,6 +99,38 @@ void testUrlEncodeAndRedaction()
|
|||||||
ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/nothing"), std::string("https://h/nothing"));
|
ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/nothing"), std::string("https://h/nothing"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void testRedactSensitiveParams()
|
||||||
|
{
|
||||||
|
// The LiveKit log bridge's actual use case: a signaling URL embedded in
|
||||||
|
// a free-form SDK log line, not a bare query string.
|
||||||
|
ST_ASSERT_EQ(ApiClient::redactSensitiveParams(
|
||||||
|
"connecting to wss://lk.example.com/rtc?access_token=eyJhbGciOiJIUzI1NiJ9.abc.def&x=1"),
|
||||||
|
std::string("connecting to wss://lk.example.com/rtc?access_token=<redacted>&x=1"));
|
||||||
|
|
||||||
|
// A value can be terminated by a quote or whitespace, not just '&', since
|
||||||
|
// this scrubs arbitrary text rather than a URL/query string.
|
||||||
|
ST_ASSERT_EQ(ApiClient::redactSensitiveParams("url=\"wss://h/rtc?access_token=secret\" state=connecting"),
|
||||||
|
std::string("url=\"wss://h/rtc?access_token=<redacted>\" state=connecting"));
|
||||||
|
|
||||||
|
// "key=" is also scrubbed, matching redactedUrl's convention.
|
||||||
|
ST_ASSERT_EQ(ApiClient::redactSensitiveParams("GET https://h/api/obs/r/slots?key=secret"),
|
||||||
|
std::string("GET https://h/api/obs/r/slots?key=<redacted>"));
|
||||||
|
|
||||||
|
// Both params can appear in the same message, and each is independently
|
||||||
|
// redacted.
|
||||||
|
ST_ASSERT_EQ(
|
||||||
|
ApiClient::redactSensitiveParams("a access_token=tok1 b key=tok2 c"),
|
||||||
|
std::string("a access_token=<redacted> b key=<redacted> c"));
|
||||||
|
|
||||||
|
// Text with neither parameter passes through unchanged.
|
||||||
|
ST_ASSERT_EQ(ApiClient::redactSensitiveParams("livekit: participant joined"),
|
||||||
|
std::string("livekit: participant joined"));
|
||||||
|
|
||||||
|
// A value at the very end of the string is still bounded correctly.
|
||||||
|
ST_ASSERT_EQ(ApiClient::redactSensitiveParams("token was access_token=trailing"),
|
||||||
|
std::string("token was access_token=<redacted>"));
|
||||||
|
}
|
||||||
|
|
||||||
void testRequestShape()
|
void testRequestShape()
|
||||||
{
|
{
|
||||||
auto fake = makeFake(200, R"({"slots":[]})");
|
auto fake = makeFake(200, R"({"slots":[]})");
|
||||||
@@ -421,6 +453,7 @@ int main()
|
|||||||
{
|
{
|
||||||
testNormalizeServerUrl();
|
testNormalizeServerUrl();
|
||||||
testUrlEncodeAndRedaction();
|
testUrlEncodeAndRedaction();
|
||||||
|
testRedactSensitiveParams();
|
||||||
testRequestShape();
|
testRequestShape();
|
||||||
testSlotsHappyPath();
|
testSlotsHappyPath();
|
||||||
testSlotsEdgeCases();
|
testSlotsEdgeCases();
|
||||||
|
|||||||
@@ -410,7 +410,17 @@ void sourceDestroy(void *data)
|
|||||||
if (!self)
|
if (!self)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
self->stopping.store(true);
|
{
|
||||||
|
// Must be set while holding `mutex`, matching how `generation` is
|
||||||
|
// mutated in applySettings: the worker's wait predicate reads
|
||||||
|
// `stopping` under this same lock, so setting it outside the lock
|
||||||
|
// can race between the worker's predicate check and it entering
|
||||||
|
// the wait, dropping the notify_all() below and leaving the worker
|
||||||
|
// asleep for its full backoff (up to kBackoffMaxMs) while this
|
||||||
|
// (OBS UI) thread blocks in worker.join().
|
||||||
|
std::lock_guard<std::mutex> guard(self->mutex);
|
||||||
|
self->stopping.store(true);
|
||||||
|
}
|
||||||
self->wake.notify_all();
|
self->wake.notify_all();
|
||||||
if (self->worker.joinable())
|
if (self->worker.joinable())
|
||||||
self->worker.join();
|
self->worker.join();
|
||||||
@@ -563,7 +573,14 @@ void livekitLogToObs(livekit::LogLevel level, const std::string &, const std::st
|
|||||||
case livekit::LogLevel::Info: obs_level = LOG_INFO; break;
|
case livekit::LogLevel::Info: obs_level = LOG_INFO; break;
|
||||||
default: obs_level = LOG_DEBUG; break;
|
default: obs_level = LOG_DEBUG; break;
|
||||||
}
|
}
|
||||||
obs_log(obs_level, "livekit: %s", message.c_str());
|
// LiveKit's signaling connection URL carries the access token as a
|
||||||
|
// query parameter. This is defensive, not a response to a confirmed
|
||||||
|
// leak: if the SDK ever logs that URL (or anything else carrying
|
||||||
|
// "access_token=" or "key="), the token must not land verbatim in an
|
||||||
|
// OBS log file that a director might paste into a support ticket. Scrub
|
||||||
|
// unconditionally before this message ever reaches obs_log.
|
||||||
|
const std::string scrubbed = ApiClient::redactSensitiveParams(message);
|
||||||
|
obs_log(obs_level, "livekit: %s", scrubbed.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|||||||
Reference in New Issue
Block a user