From 91ce4ed068c8e94fbd88b08ca306bff1441c2a86 Mon Sep 17 00:00:00 2001 From: Vladimir Sumarov Date: Sat, 19 Sep 2026 00:50:20 +0200 Subject: [PATCH 1/9] Host sandboxed CEF subprocesses in obs64 --- CMakeLists.txt | 14 +- .../dependencies/obs_studio_client.node.txt | Bin 1494 -> 588 bytes obs-studio-server/CMakeLists.txt | 63 ++++- obs-studio-server/dependencies/obs64.exe.txt | Bin 1602 -> 6202 bytes obs-studio-server/source/cef-sandbox-host.cpp | 245 ++++++++++++++++++ obs-studio-server/source/cef-sandbox-host.hpp | 13 + obs-studio-server/source/cef-subprocess.cpp | 140 ++++++++++ obs-studio-server/source/cef-subprocess.hpp | 26 ++ obs-studio-server/source/crashpad-bridge.cpp | 185 +++++++++++++ obs-studio-server/source/crashpad-bridge.h | 30 +++ obs-studio-server/source/main.cpp | 50 +--- .../source/util-crashmanager.cpp | 75 +++--- .../tests/test-cef-subprocess.cpp | 96 +++++++ 13 files changed, 844 insertions(+), 93 deletions(-) create mode 100644 obs-studio-server/source/cef-sandbox-host.cpp create mode 100644 obs-studio-server/source/cef-sandbox-host.hpp create mode 100644 obs-studio-server/source/cef-subprocess.cpp create mode 100644 obs-studio-server/source/cef-subprocess.hpp create mode 100644 obs-studio-server/source/crashpad-bridge.cpp create mode 100644 obs-studio-server/source/crashpad-bridge.h create mode 100644 obs-studio-server/tests/test-cef-subprocess.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a356ae72a..e25d0cd8a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,18 @@ set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_SOURCE_DIR}/cmake") # V8 (v8config.h) hard-errors on __cplusplus <= 201703L, so the node headers # won't compile without it. if(MSVC) + # The Windows CEF 6533 v4 wrapper and sandbox are Release-only /MT + # libraries. Keep every source-built target on the same CRT, including + # static dependencies that are linked into obs64.exe and its unit tests. + set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded") + # The matching CEF libraries are Release /MT even in a Debug consumer. + # Remove MSVC's Debug macro after its default definitions, then use the + # corresponding STL and assertion settings for every source-built target. + add_compile_definitions( + $<$:NDEBUG> + $<$:_HAS_ITERATOR_DEBUGGING=0> + ) + add_compile_options($<$:/U_DEBUG>) add_compile_options($<$:/Zc:__cplusplus>) endif() @@ -188,4 +200,4 @@ install(CODE " file(COPY \${file} DESTINATION \"${CMAKE_INSTALL_PREFIX}/bin\") endforeach() ") -endif() \ No newline at end of file +endif() diff --git a/obs-studio-client/dependencies/obs_studio_client.node.txt b/obs-studio-client/dependencies/obs_studio_client.node.txt index 35c83a684ec5620d20cca376675c54c939c3c309..fe8dcd6dcb82a04045b35c62cbb948ee1d3eb96a 100644 GIT binary patch delta 10 Rcmcb{eTHR28RI0@YXBHc1V#V= literal 1494 zcmb`H-Acni5QWdR;5+06QmlIAg-}#bY>PBnFD0a@7Metw_|unHzuCCrRU&p-HaojJ zduGneWIjLkwWo;T*HgEPbfrS69+k6yLaJFCB-<}lY0aqgrj<&xA}zRiW|mGWm2s2v z>f{Icw-uB9xYmVcn(`g%NQWALVCdc>uV?F>5~tm#H`2N08tDceeY%M*o$Y?>#ybBK z-dOkF*1OnDU^CYlUJiAvgHGnAH-rCFw{V)^@&DgE@V3jZluWJ^Qvo&KHpej;-B1BB zXAM%D_n3$bIkVaAe>dhvFH}wgYC(iUp<1ob diff --git a/obs-studio-server/CMakeLists.txt b/obs-studio-server/CMakeLists.txt index 2a34836e1..c1e0650e6 100644 --- a/obs-studio-server/CMakeLists.txt +++ b/obs-studio-server/CMakeLists.txt @@ -247,8 +247,6 @@ if (WIN32) "${libobs_SOURCE_DIR}/include" "${stackwalker_SOURCE_DIR}/Main/StackWalker" "${nlohmannjson_SOURCE_DIR}/single_include" - "${source_dir}/include/third_party/mini_chromium/mini_chromium" - "${source_dir}/include" ) else () set(PROJECT_LIBRARIES @@ -266,6 +264,31 @@ else () ) endif () +# Crashpad's prebuilt static libraries use /MD and contain Chromium base +# symbols that conflict with CEF's /MT sandbox. Keep them in a small /MD DLL +# rather than letting either the server library or obs64 link them directly. +if(WIN32) + add_library( + crashpad-bridge + SHARED + "${PROJECT_SOURCE_DIR}/source/crashpad-bridge.cpp" + "${PROJECT_SOURCE_DIR}/source/crashpad-bridge.h" + ) + add_library(OSN::crashpad-bridge ALIAS crashpad-bridge) + + target_link_libraries(crashpad-bridge PRIVATE crashpad shell32 ole32) + set_target_properties( + crashpad-bridge + PROPERTIES + OUTPUT_NAME "osn-crashpad-bridge" + MSVC_RUNTIME_LIBRARY "MultiThreadedDLL" + ) + target_compile_definitions(crashpad-bridge PRIVATE NDEBUG _HAS_ITERATOR_DEBUGGING=0) + if(MSVC) + target_compile_options(crashpad-bridge PRIVATE /U_DEBUG) + endif() +endif() + set(PROJECT_DATA "${PROJECT_SOURCE_DIR}/resources") SET(osn-server_SOURCES @@ -438,6 +461,8 @@ elseif(WIN32) LIST( APPEND osn-server_SOURCES + "${PROJECT_SOURCE_DIR}/source/cef-subprocess.cpp" + "${PROJECT_SOURCE_DIR}/source/cef-subprocess.hpp" "${PROJECT_SOURCE_DIR}/source/osn-multitrack-video-system-info.cpp" ) endif () @@ -464,7 +489,7 @@ ENDIF() target_include_directories(obs-studio-server-lib PUBLIC ${PROJECT_INCLUDE_PATHS}) if(WIN32) - target_link_libraries(obs-studio-server-lib PUBLIC ${PROJECT_LIBRARIES} optimized crashpad strmiids StackWalker) + target_link_libraries(obs-studio-server-lib PUBLIC ${PROJECT_LIBRARIES} crashpad-bridge strmiids StackWalker) else() target_link_libraries(obs-studio-server-lib PUBLIC ${PROJECT_LIBRARIES} crashpad ${COREFOUNDATION} ${COCOA} ${IOSURF} ${GLKIT} ${AVFOUNDATION} ${IOKit} ${SECURITY_LIBRARY} ${BSM_LIBRARY}) endif() @@ -477,10 +502,28 @@ add_executable( ) if(WIN32) - target_sources(${PROJECT_NAME} PUBLIC "${PROJECT_BINARY_DIR}/version.rc") + if(NOT TARGET OBS::cef-sandbox) + message(FATAL_ERROR "The libobs development package does not provide required target OBS::cef-sandbox") + endif() + + target_sources( + ${PROJECT_NAME} + PRIVATE + "${PROJECT_BINARY_DIR}/version.rc" + "${PROJECT_SOURCE_DIR}/source/cef-sandbox-host.cpp" + "${PROJECT_SOURCE_DIR}/source/cef-sandbox-host.hpp" + ) + endif() -target_link_libraries(${PROJECT_NAME} obs-studio-server-lib) +target_link_libraries(${PROJECT_NAME} PRIVATE obs-studio-server-lib) + +if(WIN32) + # The packaged CEF 6533 v4 sandbox archive is Release-only and built with + # /MT. Link it into obs64.exe itself, where the CEF sandbox contract + # requires cef_sandbox_info_create/destroy to reside. + target_link_libraries(${PROJECT_NAME} PRIVATE crashpad-bridge OBS::cef-sandbox) +endif() if(MSVC) add_definitions(-D_CRT_SECURE_NO_WARNINGS -D_SILENCE_ALL_CXX17_DEPRECATION_WARNINGS) @@ -536,6 +579,7 @@ set(PROGRAM_PERMISSIONS_DEFAULT if (WIN32) install(TARGETS obs-studio-server RUNTIME DESTINATION "./" PERMISSIONS ${PROGRAM_PERMISSIONS_DEFAULT} COMPONENT Runtime) + install(TARGETS crashpad-bridge RUNTIME DESTINATION "./" PERMISSIONS ${PROGRAM_PERMISSIONS_DEFAULT} COMPONENT Runtime) ELSE() install(TARGETS obs-studio-server RUNTIME DESTINATION "./bin" PERMISSIONS ${PROGRAM_PERMISSIONS_DEFAULT} COMPONENT Runtime) set(_COMMAND @@ -548,6 +592,9 @@ ENDIF() IF(WIN32 AND NOT CLANG_ANALYZE_CONFIG) install(FILES $ DESTINATION "./" OPTIONAL) + if(MSVC) + install(FILES $ DESTINATION "./" OPTIONAL) + endif() ENDIF() install(DIRECTORY ${PROJECT_DATA} DESTINATION "./" OPTIONAL USE_SOURCE_PERMISSIONS) install(DIRECTORY ${crashpad_SOURCE_DIR}/bin/ DESTINATION "./" USE_SOURCE_PERMISSIONS) @@ -658,6 +705,10 @@ if(BUILD_TESTING) obs-studio-server-lib ) + if(WIN32) + target_sources(obs_studio_server_unit_tests PRIVATE "tests/test-cef-subprocess.cpp") + endif() + if(APPLE) add_custom_command( TARGET obs_studio_server_unit_tests @@ -677,7 +728,7 @@ if(BUILD_TESTING) if(APPLE) list(APPEND _osn_server_test_dl_paths "${libobs_SOURCE_DIR}/OBS.app/Contents/Frameworks") elseif(WIN32) - list(APPEND _osn_server_test_dl_paths "${libobs_SOURCE_DIR}/bin/64bit") + list(APPEND _osn_server_test_dl_paths "${libobs_SOURCE_DIR}/bin/64bit" "$") endif() catch_discover_tests( diff --git a/obs-studio-server/dependencies/obs64.exe.txt b/obs-studio-server/dependencies/obs64.exe.txt index 0d4054af5f7a8edd5f07b45279b1193deb14230d..2b2b517f362931ca946ce88c7253c55360b6ac45 100644 GIT binary patch literal 6202 zcmc&(+iuf95S?cv{vkgQL|eoI4@i|rRYcWB4Q<5}im@999NUT$Li_Q+IlC-&QfD1` z2cgLE+TNU<>zVQ9*U#tjOa}P4^ILN@koU5Xsmvt7b&64i^9mzg-%BAGK80*#E;(im zB*v2xM{Y*Be8H0hzk2f*xw#6*?o(h^E<^lcY1T=BGX=^}BHWo{L@8e2%r%{y?$L#qtBI`R8PK9zohQq|3Z$6FHX)xsy{F!=Ao%?_?s!@>x#s?VEQh z@8tFmUf0|hQqGLWGU)9?&EZ*VUEi#+TtSy>=yHwGnHSN{8AGd2IDDuR8OcYv0cy9E zXwMNWdeLbo>**nUULlTblnyaY;kN>QOK`WscnS~N2p(c|guC|eNqhs1MzFz1F5wH` z%v&>W0^eU@u5V_$ytS+)WG)~tGpfROF5e+H@8!^u*DJ_gYw$8?wFJn-UYg9$O>8{1 zQrH?ci{}u(eJf>(tEs zGZ2>*l<2bvA z*Z5{%qtl02{wBvZb3HslHqd<|ujQ4Z_Pd@dPsP9oe@I64;oODk zDVd*)7ri)fh>x%dE1e#)^?`o(YI~r}aMi9Y`dGHkL?3b(lh~|ZLS{Yt?)E%7{JM?$ z@A9N+k?l3L>SZ(XbZpZ^B&UXRUlY;E7*{_8*q!Oc+%B)~dm}mN@wQOs;R;+t zZe|VRzLcK)hj;XTFMKe&HkbJB-e+=qL0x-swi$$(!}PYx)9GZ`!?5aeZa!e9d=U`mGH$tmeh0>rYy%X}jOJyYBu0 S;LiF4@A&La>@FRjy5l$CMR1t_ delta 289 zcmZvS%?bfw6oy|0S(r>Q#Y$ON42t3cBuN_iFB{UJQ4BGZy|w6Efg6yGjirVAnLBXC zP!<;7*Lj}zxn~}M*b=$nwPKAjhdMD6(yv z=%9u+Do|1S7SX>8G8j-Jm=`syU?R=`1_oJDRbZyGzY%o&n%5DgSWwnij5=<@Ji{1k z?D&~{&FximR-Ji><&T*&o6v0&avBW_7}#>j{8P@@YZy{NDtq50-GtufvZN$Q5# +#undef OBS_BROWSER_SANDBOX_HOST_IMPLEMENTATION +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +DWORD InitialNvOptimusPreference() +{ + int argument_count = 0; + LPWSTR *arguments = CommandLineToArgvW(GetCommandLineW(), &argument_count); + if (!arguments) + return 1; + + const bool cef_process = osn::cef::ContainsCefProcessSwitch(argument_count, arguments); + LocalFree(arguments); + if (cef_process) + return 1; + + PWSTR roaming_path = nullptr; + if (FAILED(SHGetKnownFolderPath(FOLDERID_RoamingAppData, 0, nullptr, &roaming_path))) + return 1; + + std::filesystem::path file_path(roaming_path); + CoTaskMemFree(roaming_path); + file_path /= L"slobs-client\\basic.ini"; + + std::ifstream file(file_path); + std::string line; + while (std::getline(file, line)) { + const size_t setting_position = line.find("ForceGPUAsRenderDevice"); + if (setting_position == std::string::npos) + continue; + + const size_t separator_position = line.find('=', setting_position); + if (separator_position != std::string::npos && line.substr(separator_position + 1) == "false") + return 0; + break; + } + + return 1; +} + +} // namespace + +// NVIDIA reads this exported value during process startup. Preserve the +// existing pre-main behavior for the normal OSN host, but do not touch the +// user profile for CEF child invocations. +extern "C" __declspec(dllexport) DWORD NvOptimusEnablement = InitialNvOptimusPreference(); + +extern "C" __declspec(dllexport) uint32_t __cdecl obs_browser_sandbox_abi_version(void) +{ + return OBS_BROWSER_SANDBOX_ABI_VERSION; +} + +extern "C" __declspec(dllexport) void *__cdecl obs_browser_sandbox_info_create(void) +{ + return cef_sandbox_info_create(); +} + +extern "C" __declspec(dllexport) void __cdecl obs_browser_sandbox_info_destroy(void *sandbox_info) +{ + cef_sandbox_info_destroy(sandbox_info); +} + +namespace osn::cef { +namespace { + +constexpr int dispatch_failure = EXIT_FAILURE; + +void ReportWindowsError(const char *operation) +{ + const DWORD error = GetLastError(); + std::cerr << operation << " failed with Windows error " << error << std::endl; +} + +bool PathsEqual(const std::filesystem::path &left, const std::filesystem::path &right) +{ + const std::wstring left_path = left.native(); + const std::wstring right_path = right.native(); + return _wcsicmp(left_path.c_str(), right_path.c_str()) == 0; +} + +bool GetExecutablePath(std::filesystem::path &result) +{ + std::vector buffer(512); + for (;;) { + SetLastError(ERROR_SUCCESS); + const DWORD length = GetModuleFileNameW(nullptr, buffer.data(), static_cast(buffer.size())); + if (length == 0) { + ReportWindowsError("GetModuleFileNameW"); + return false; + } + + if (length < buffer.size() - 1) { + result = std::wstring(buffer.data(), length); + return true; + } + + if (buffer.size() >= 32768) { + SetLastError(ERROR_INSUFFICIENT_BUFFER); + ReportWindowsError("GetModuleFileNameW"); + return false; + } + + buffer.resize(buffer.size() * 2); + } +} + +int ExecuteSubprocess() +{ + void *sandbox_info = obs_browser_sandbox_info_create(); + if (!sandbox_info) { + std::cerr << "cef_sandbox_info_create returned null" << std::endl; + return dispatch_failure; + } + + auto destroy_sandbox_info = [&]() { + if (sandbox_info) { + obs_browser_sandbox_info_destroy(sandbox_info); + sandbox_info = nullptr; + } + }; + + std::filesystem::path executable_path; + if (!GetExecutablePath(executable_path)) { + destroy_sandbox_info(); + return dispatch_failure; + } + + std::error_code error; + executable_path = std::filesystem::canonical(executable_path, error); + if (error) { + std::cerr << "Could not canonicalize obs64.exe path: " << error.message() << std::endl; + destroy_sandbox_info(); + return dispatch_failure; + } + + const std::filesystem::path install_root = executable_path.parent_path(); + std::filesystem::path plugin_directory = install_root / "obs-plugins" / "64bit"; + plugin_directory = std::filesystem::canonical(plugin_directory, error); + if (error) { + std::cerr << "Could not resolve the packaged obs-browser directory: " << error.message() << std::endl; + destroy_sandbox_info(); + return dispatch_failure; + } + if (!IsInstallOwnedPath(install_root, plugin_directory)) { + std::cerr << "Packaged obs-browser directory resolved outside the installation directory" << std::endl; + destroy_sandbox_info(); + return dispatch_failure; + } + + const std::filesystem::path requested_plugin_path = BrowserPluginPath(executable_path); + const std::filesystem::path plugin_path = std::filesystem::canonical(requested_plugin_path, error); + if (error) { + std::cerr << "Could not resolve packaged obs-browser.dll: " << error.message() << std::endl; + destroy_sandbox_info(); + return dispatch_failure; + } + + if (!IsInstallOwnedPath(install_root, plugin_path) || !PathsEqual(plugin_path.parent_path(), plugin_directory)) { + std::cerr << "Packaged obs-browser.dll resolved outside the installation directory" << std::endl; + destroy_sandbox_info(); + return dispatch_failure; + } + + if (!SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_DEFAULT_DIRS)) { + ReportWindowsError("SetDefaultDllDirectories"); + destroy_sandbox_info(); + return dispatch_failure; + } + + DLL_DIRECTORY_COOKIE plugin_cookie = AddDllDirectory(plugin_directory.c_str()); + if (!plugin_cookie) { + ReportWindowsError("AddDllDirectory"); + destroy_sandbox_info(); + return dispatch_failure; + } + + HMODULE browser_module = LoadLibraryExW(plugin_path.c_str(), nullptr, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS); + if (!browser_module) { + ReportWindowsError("LoadLibraryExW(obs-browser.dll)"); + RemoveDllDirectory(plugin_cookie); + destroy_sandbox_info(); + return dispatch_failure; + } + + const auto execute_subprocess = reinterpret_cast(GetProcAddress(browser_module, "obs_browser_execute_subprocess")); + if (!execute_subprocess) { + ReportWindowsError("GetProcAddress(obs_browser_execute_subprocess)"); + FreeLibrary(browser_module); + RemoveDllDirectory(plugin_cookie); + destroy_sandbox_info(); + return dispatch_failure; + } + + const int exit_code = execute_subprocess(sandbox_info); + destroy_sandbox_info(); + FreeLibrary(browser_module); + RemoveDllDirectory(plugin_cookie); + + if (exit_code < 0) { + std::cerr << "CEF child dispatcher returned an invalid negative exit code: " << exit_code << std::endl; + return dispatch_failure; + } + + return exit_code; +} + +} // namespace + +std::optional DispatchSubprocessIfNeeded(int argc, char *argv[]) +{ + const Invocation invocation = ClassifyInvocation(argc, argv); + if (invocation.kind == InvocationKind::Normal) + return std::nullopt; + + if (invocation.kind == InvocationKind::Invalid) { + std::cerr << "Rejected CEF child process invocation: " << invocation.error << std::endl; + return dispatch_failure; + } + + return ExecuteSubprocess(); +} + +} // namespace osn::cef + +#endif diff --git a/obs-studio-server/source/cef-sandbox-host.hpp b/obs-studio-server/source/cef-sandbox-host.hpp new file mode 100644 index 000000000..e860e3710 --- /dev/null +++ b/obs-studio-server/source/cef-sandbox-host.hpp @@ -0,0 +1,13 @@ +#pragma once + +#ifdef _WIN32 + +#include + +namespace osn::cef { + +std::optional DispatchSubprocessIfNeeded(int argc, char *argv[]); + +} // namespace osn::cef + +#endif diff --git a/obs-studio-server/source/cef-subprocess.cpp b/obs-studio-server/source/cef-subprocess.cpp new file mode 100644 index 000000000..dedfaf2ac --- /dev/null +++ b/obs-studio-server/source/cef-subprocess.cpp @@ -0,0 +1,140 @@ +#include "cef-subprocess.hpp" + +#include +#include + +namespace osn::cef { +namespace { + +// Keep this list pinned to CEF 6533/Chromium 127. CEF may relaunch the main +// executable for its embedded Crashpad handler in addition to Content child +// processes. +constexpr std::array allowed_process_types = { + "crashpad-handler", + "gpu-process", + "renderer", + "utility", +}; + +bool IsAllowedProcessType(std::string_view process_type) +{ + for (const auto allowed_type : allowed_process_types) { + if (process_type == allowed_type) + return true; + } + + return false; +} + +} // namespace + +Invocation ClassifyInvocation(int argc, const char *const argv[]) +{ + Invocation result; + if (argc < 1 || !argv) { + result.kind = InvocationKind::Invalid; + result.error = "CEF child process command line is missing"; + return result; + } + + size_t type_argument_count = 0; + bool no_sandbox = false; + + for (int index = 1; index < argc; ++index) { + const std::string_view argument = argv[index] ? argv[index] : ""; + + if (argument == "--no-sandbox" || argument.starts_with("--no-sandbox=")) { + no_sandbox = true; + continue; + } + + if (argument == "--type") { + result.kind = InvocationKind::Invalid; + result.error = "CEF child process type must use --type="; + return result; + } + + constexpr std::string_view type_prefix = "--type="; + if (argument.starts_with(type_prefix)) { + ++type_argument_count; + result.process_type = argument.substr(type_prefix.size()); + } + } + + if (type_argument_count == 0) { + if (no_sandbox) { + result.kind = InvocationKind::Invalid; + result.error = "--no-sandbox is not permitted"; + } + return result; + } + + if (type_argument_count != 1) { + result.kind = InvocationKind::Invalid; + result.error = "CEF child process command line contains duplicate --type arguments"; + return result; + } + + if (result.process_type.empty()) { + result.kind = InvocationKind::Invalid; + result.error = "CEF child process type is empty"; + return result; + } + + if (!IsAllowedProcessType(result.process_type)) { + result.kind = InvocationKind::Invalid; + result.error = "CEF child process type is not supported"; + return result; + } + + if (no_sandbox) { + result.kind = InvocationKind::Invalid; + result.error = "--no-sandbox is not permitted for CEF child processes"; + return result; + } + + result.kind = InvocationKind::Child; + return result; +} + +bool ContainsCefProcessSwitch(int argc, const wchar_t *const argv[]) +{ + if (argc < 1 || !argv) + return false; + + for (int index = 1; index < argc; ++index) { + const std::wstring_view argument = argv[index] ? argv[index] : L""; + if (argument == L"--type" || argument.starts_with(L"--type=") || argument == L"--no-sandbox" || argument.starts_with(L"--no-sandbox=")) { + return true; + } + } + + return false; +} + +std::filesystem::path BrowserPluginPath(const std::filesystem::path &executable_path) +{ + return (executable_path.parent_path() / "obs-plugins" / "64bit" / "obs-browser.dll").lexically_normal(); +} + +bool IsInstallOwnedPath(const std::filesystem::path &install_root, const std::filesystem::path &candidate) +{ + const std::filesystem::path normalized_root = install_root.lexically_normal(); + const std::filesystem::path normalized_candidate = candidate.lexically_normal(); + + if (normalized_root.empty() || normalized_candidate.empty()) + return false; + + auto root_component = normalized_root.begin(); + auto candidate_component = normalized_candidate.begin(); + for (; root_component != normalized_root.end(); ++root_component, ++candidate_component) { + if (candidate_component == normalized_candidate.end() || _wcsicmp(root_component->c_str(), candidate_component->c_str()) != 0) { + return false; + } + } + + // The installation root itself is not an install-owned child path. + return candidate_component != normalized_candidate.end(); +} + +} // namespace osn::cef diff --git a/obs-studio-server/source/cef-subprocess.hpp b/obs-studio-server/source/cef-subprocess.hpp new file mode 100644 index 000000000..d66429a71 --- /dev/null +++ b/obs-studio-server/source/cef-subprocess.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include + +namespace osn::cef { + +enum class InvocationKind { + Normal, + Child, + Invalid, +}; + +struct Invocation { + InvocationKind kind = InvocationKind::Normal; + std::string_view process_type; + std::string error; +}; + +Invocation ClassifyInvocation(int argc, const char *const argv[]); +bool ContainsCefProcessSwitch(int argc, const wchar_t *const argv[]); +std::filesystem::path BrowserPluginPath(const std::filesystem::path &executable_path); +bool IsInstallOwnedPath(const std::filesystem::path &install_root, const std::filesystem::path &candidate); + +} // namespace osn::cef diff --git a/obs-studio-server/source/crashpad-bridge.cpp b/obs-studio-server/source/crashpad-bridge.cpp new file mode 100644 index 000000000..18d4328c7 --- /dev/null +++ b/obs-studio-server/source/crashpad-bridge.cpp @@ -0,0 +1,185 @@ +#define OSN_CRASHPAD_BRIDGE_IMPLEMENTATION +#include "crashpad-bridge.h" + +#if defined(_WIN32) + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include + +#include "client/crash_report_database.h" +#include "client/crashpad_client.h" +#include "client/settings.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +std::mutex crashpad_mutex; +std::unique_ptr crashpad_client; +std::unique_ptr crashpad_database; +std::atomic host_exception_callback{nullptr}; +std::atomic internal_exception_filter{nullptr}; +std::atomic_bool bridge_active{false}; + +LONG WINAPI BridgeExceptionFilter(EXCEPTION_POINTERS *exception_pointers) noexcept; + +long CallInternalExceptionFilter(LPTOP_LEVEL_EXCEPTION_FILTER filter, void *exception_pointers) noexcept +{ + try { + if (filter) + return filter(static_cast(exception_pointers)); + } catch (...) { + } + + return EXCEPTION_CONTINUE_SEARCH; +} + +bool BuildCrashpadArguments(const char *const *annotation_keys, const char *const *annotation_values, uint32_t annotation_count, const char *const *arguments, + uint32_t argument_count, std::map &annotations, std::vector &handler_arguments) +{ + if ((annotation_count && (!annotation_keys || !annotation_values)) || (argument_count && !arguments)) { + return false; + } + + for (uint32_t index = 0; index < annotation_count; ++index) { + if (!annotation_keys[index] || !annotation_values[index]) + return false; + annotations.emplace(annotation_keys[index], annotation_values[index]); + } + + for (uint32_t index = 0; index < argument_count; ++index) { + if (!arguments[index]) + return false; + handler_arguments.emplace_back(arguments[index]); + } + + return true; +} + +LONG WINAPI BridgeExceptionFilter(EXCEPTION_POINTERS *exception_pointers) noexcept +{ + if (!bridge_active.load(std::memory_order_acquire)) + return EXCEPTION_CONTINUE_SEARCH; + + const auto callback = host_exception_callback.load(std::memory_order_acquire); + if (callback) { + try { + callback(exception_pointers); + } catch (...) { + // Never allow a server exception to escape a Windows exception filter. + } + } + + // HandleCrash refreshes Crashpad so its annotations describe this failure. + // Use the filter installed by that refresh, unless reporting was disabled + // while the host callback was running. + if (!bridge_active.load(std::memory_order_acquire)) + return EXCEPTION_CONTINUE_SEARCH; + + return CallInternalExceptionFilter(internal_exception_filter.load(std::memory_order_acquire), exception_pointers); +} + +} // namespace + +extern "C" bool __cdecl osn_crashpad_bridge_start(const char *report_server_url, const char *const *annotation_keys, const char *const *annotation_values, + uint32_t annotation_count, const char *const *arguments, uint32_t argument_count) +{ + try { + if (!report_server_url || !*report_server_url) + return false; + + std::map annotations; + std::vector handler_arguments; + if (!BuildCrashpadArguments(annotation_keys, annotation_values, annotation_count, arguments, argument_count, annotations, handler_arguments)) + return false; + + PWSTR roaming_app_data = nullptr; + const HRESULT result = SHGetKnownFolderPath(FOLDERID_RoamingAppData, 0, nullptr, &roaming_app_data); + if (FAILED(result) || !roaming_app_data) + return false; + + std::wstring database_path(roaming_app_data); + CoTaskMemFree(roaming_app_data); + database_path.append(L"\\obs-studio-node-server"); + + const base::FilePath database_file_path(database_path); + const base::FilePath handler_path(L"crashpad_handler.exe"); + + // An exception may arrive while Crashpad is starting. Its filter must + // never wait on a mutex owned by the faulting thread; the already-installed + // Crashpad filter can still handle that exception without refreshed annotations. + std::unique_lock lock(crashpad_mutex, std::try_to_lock); + if (!lock.owns_lock()) + return false; + + crashpad_database.reset(); + + auto database = crashpad::CrashReportDatabase::Initialize(database_file_path); + if (!database || !database->GetSettings()) + return false; + if (!database->GetSettings()->SetUploadsEnabled(true)) + return false; + + if (!crashpad_client) + crashpad_client = std::make_unique(); + if (!crashpad_client->StartHandler(handler_path, database_file_path, database_file_path, report_server_url, annotations, handler_arguments, + /* restartable */ true, + /* asynchronous_start */ true)) { + return false; + } + if (!crashpad_client->WaitForHandlerStart(INFINITE)) + return false; + + crashpad_database = std::move(database); + return true; + } catch (...) { + return false; + } +} + +extern "C" bool __cdecl osn_crashpad_bridge_set_exception_callback(osn_crashpad_exception_callback callback) +{ + try { + if (!callback) + return false; + + host_exception_callback.store(callback, std::memory_order_release); + const LPTOP_LEVEL_EXCEPTION_FILTER previous_filter = SetUnhandledExceptionFilter(BridgeExceptionFilter); + if (previous_filter != BridgeExceptionFilter) + internal_exception_filter.store(previous_filter, std::memory_order_release); + bridge_active.store(true, std::memory_order_release); + return true; + } catch (...) { + return false; + } +} + +extern "C" void __cdecl osn_crashpad_bridge_shutdown(void) +{ + try { + // Leave the bridge filter installed but inert. Windows has no atomic + // compare-and-restore operation for the process exception filter, so + // attempting to restore it here could overwrite a newer owner's filter. + bridge_active.store(false, std::memory_order_release); + host_exception_callback.store(nullptr, std::memory_order_release); + internal_exception_filter.store(nullptr, std::memory_order_release); + + std::lock_guard lock(crashpad_mutex); + crashpad_client.reset(); + crashpad_database.reset(); + } catch (...) { + } +} + +#endif diff --git a/obs-studio-server/source/crashpad-bridge.h b/obs-studio-server/source/crashpad-bridge.h new file mode 100644 index 000000000..057457914 --- /dev/null +++ b/obs-studio-server/source/crashpad-bridge.h @@ -0,0 +1,30 @@ +#pragma once + +// This interface deliberately exposes only C-compatible values. The server is +// built with /MT, while the Crashpad bridge uses /MD, so C++ objects and heap +// ownership must not cross this boundary. +#if defined(_WIN32) +#include + +#if defined(OSN_CRASHPAD_BRIDGE_IMPLEMENTATION) +#define OSN_CRASHPAD_BRIDGE_API __declspec(dllexport) +#else +#define OSN_CRASHPAD_BRIDGE_API __declspec(dllimport) +#endif + +typedef void(__cdecl *osn_crashpad_exception_callback)(void *exception_pointers); + +extern "C" { + +OSN_CRASHPAD_BRIDGE_API bool __cdecl osn_crashpad_bridge_start(const char *report_server_url, const char *const *annotation_keys, + const char *const *annotation_values, uint32_t annotation_count, const char *const *arguments, + uint32_t argument_count); + +// Registers the server's crash bookkeeping callback. The bridge installs the +// Windows exception filter and forwards to Crashpad's previous filter itself. +OSN_CRASHPAD_BRIDGE_API bool __cdecl osn_crashpad_bridge_set_exception_callback(osn_crashpad_exception_callback callback); + +OSN_CRASHPAD_BRIDGE_API void __cdecl osn_crashpad_bridge_shutdown(void); +} + +#endif diff --git a/obs-studio-server/source/main.cpp b/obs-studio-server/source/main.cpp index 74e4f565a..4da5a8dbe 100644 --- a/obs-studio-server/source/main.cpp +++ b/obs-studio-server/source/main.cpp @@ -64,6 +64,10 @@ #include "osn-enhanced-broadcasting-simple-streaming.hpp" #include "osn-enhanced-broadcasting-advanced-streaming.hpp" +#ifdef _WIN32 +#include "cef-sandbox-host.hpp" +#endif + #include "util-crashmanager.h" #include "shared.hpp" @@ -87,48 +91,6 @@ #include #endif -#if defined(_WIN32) -#include "Shlobj.h" - -// Checks ForceGPUAsRenderDevice setting -extern "C" __declspec(dllexport) DWORD NvOptimusEnablement = [] { - LPWSTR roamingPath; - std::wstring filePath; - std::string line; - std::fstream file; - bool settingValue = true; // Default value (NvOptimusEnablement = 1) - - if (FAILED(SHGetKnownFolderPath(FOLDERID_RoamingAppData, 0, nullptr, &roamingPath))) { - // Couldn't find roaming app data folder path, assume default value - return settingValue; - } else { - filePath.assign(roamingPath); - filePath.append(L"\\slobs-client\\basic.ini"); - CoTaskMemFree(roamingPath); - } - - file.open(filePath); - - if (file.is_open()) { - while (std::getline(file, line)) { - if (line.find("ForceGPUAsRenderDevice", 0) != std::string::npos) { - if (line.substr(line.find('=') + 1) == "false") { - settingValue = false; - file.close(); - break; - } - } - } - } else { - //Couldn't open config file, assume default value - return settingValue; - } - - // Return setting value - return settingValue; -}(); -#endif - #define BUFFSIZE 512 struct ServerData { @@ -170,6 +132,10 @@ static void Shutdown(void *data, const int64_t id, const std::vector int main(int argc, char *argv[]) { +#ifdef _WIN32 + if (const auto cef_exit_code = osn::cef::DispatchSubprocessIfNeeded(argc, argv)) + return *cef_exit_code; +#endif #ifdef __APPLE__ std::string_view slobsStdOutPath("/tmp/slobs-stdout"); std::string_view slobsStdErrPath("/tmp/slobs-stderr"); diff --git a/obs-studio-server/source/util-crashmanager.cpp b/obs-studio-server/source/util-crashmanager.cpp index 7ff0f9bf7..13335458e 100644 --- a/obs-studio-server/source/util-crashmanager.cpp +++ b/obs-studio-server/source/util-crashmanager.cpp @@ -64,11 +64,12 @@ #include "osn-error.hpp" #include "shared.hpp" -#ifdef ENABLE_CRASHREPORT +#if defined(_WIN32) +#include "crashpad-bridge.h" +#elif defined(ENABLE_CRASHREPORT) #include "client/crash_report_database.h" #include "client/crashpad_client.h" #include "client/settings.h" -#include "nodeobs_api.h" #endif #include "nlohmann/json.hpp" @@ -86,7 +87,6 @@ std::vector handledOBSCrashes; PDH_HQUERY cpuQuery; PDH_HCOUNTER cpuTotal; util::MetricsProvider metricsClient; -LPTOP_LEVEL_EXCEPTION_FILTER crashpadInternalExceptionFilterMethod = nullptr; HANDLE memoryDumpEvent = INVALID_HANDLE_VALUE; std::filesystem::path memoryDumpFolder; #elif defined(__APPLE__) @@ -97,17 +97,20 @@ struct sigaction oldBus = {}; std::string appState = "starting"; // "starting","idle","encoding","shutdown" std::string reportServerUrl = ""; +// Declared in util-crashmanager.h. It remains available on Windows even +// though the Windows Crashpad bridge does not need the handler's directory. +std::string workingDirectory; // Crashpad variables #ifdef ENABLE_CRASHREPORT std::wstring globalAppData_path; +#if !defined(_WIN32) std::wstring appdata_path; crashpad::CrashpadClient client; std::unique_ptr database; -std::string url; base::FilePath db; base::FilePath handler; std::vector arguments; -std::string workingDirectory; +#endif static nlohmann::json briefCrashInfo; static std::mutex briefCrashInfoMutex; static std::wstring_view briefCrashInfoBasename(L"brief-crash-info.json"); @@ -536,19 +539,6 @@ bool util::CrashManager::Initialize(char *path, const std::string &appdata) // There's a static local wstring inside this function, now it's cached for thread safe read access util::CrashManager::GetMemoryDumpName(); - // Setup the windows exeption filter - auto ExceptionHandlerMethod = [](struct _EXCEPTION_POINTERS *ExceptionInfo) { - HandleCrash("UnhandledExceptionFilter", false); - - // Call the crashpad internal exception filter method since we overrided it here and - // it must be called to proper generate a report - return crashpadInternalExceptionFilterMethod(ExceptionInfo); - }; - - // This method will substitute the crashpad unhandled exception filter method by our one, returning - // the old method used by it, we will store this method pointer to be able to call it directly - crashpadInternalExceptionFilterMethod = SetUnhandledExceptionFilter(ExceptionHandlerMethod); - // Setup the metrics query for the CPU usage // Ref: https://stackoverflow.com/questions/63166/how-to-determine-cpu-and-memory-consumption-from-inside-a-process PdhOpenQuery(NULL, NULL, &cpuQuery); @@ -591,29 +581,31 @@ bool util::CrashManager::SetupCrashpad() #ifdef ENABLE_CRASHREPORT #if defined(_WIN32) - HRESULT hResult; - PWSTR ppszPath; - - hResult = SHGetKnownFolderPath(FOLDERID_RoamingAppData, 0, NULL, &ppszPath); - - appdata_path.assign(ppszPath); - appdata_path.append(L"\\obs-studio-node-server"); - - CoTaskMemFree(ppszPath); -#endif + std::vector annotation_keys; + std::vector annotation_values; + annotation_keys.reserve(annotations.size()); + annotation_values.reserve(annotations.size()); + for (const auto &[key, value] : annotations) { + annotation_keys.push_back(key.c_str()); + annotation_values.push_back(value.c_str()); + } - arguments.push_back("--no-rate-limit"); + const char *const arguments[] = {"--no-rate-limit"}; + const bool started = osn_crashpad_bridge_start(reportServerUrl.c_str(), annotation_keys.data(), annotation_values.data(), + static_cast(annotation_keys.size()), arguments, 1); + if (!started) { + blog(LOG_WARNING, "Unable to start crash handler"); + return false; + } -#ifdef WIN32 - std::wstring handler_path(L"crashpad_handler.exe"); + const osn_crashpad_exception_callback exception_callback = [](void *) { HandleCrash("UnhandledExceptionFilter", false); }; + return osn_crashpad_bridge_set_exception_callback(exception_callback); #else + arguments.push_back("--no-rate-limit"); std::string handler_path = workingDirectory + '/'; handler_path.append("crashpad_handler"); -#endif -#ifdef __APPLE__ std::string appdata_path = wstring_to_utf8(globalAppData_path) + "/Crashpad"; -#endif db = base::FilePath(appdata_path); handler = base::FilePath(handler_path); @@ -622,10 +614,7 @@ bool util::CrashManager::SetupCrashpad() return false; database->GetSettings()->SetUploadsEnabled(true); - bool asynchronous_start = true; -#if defined(__APPLE__) - asynchronous_start = false; -#endif + bool asynchronous_start = false; bool rc = client.StartHandler(handler, db, db, reportServerUrl, annotations, arguments, /* restartable */ true, asynchronous_start); if (!rc) { @@ -633,13 +622,7 @@ bool util::CrashManager::SetupCrashpad() return false; } -#ifdef WIN32 - // Windows will wait since asynchronous_start is set to true. - rc = client.WaitForHandlerStart(INFINITE); - if (!rc) - return false; #endif - #endif return true; @@ -1323,9 +1306,13 @@ void util::CrashManager::DisableReports() #ifdef ENABLE_CRASHREPORT +#if defined(_WIN32) + osn_crashpad_bridge_shutdown(); +#else client.~CrashpadClient(); database->~CrashReportDatabase(); database = nullptr; +#endif #endif } diff --git a/obs-studio-server/tests/test-cef-subprocess.cpp b/obs-studio-server/tests/test-cef-subprocess.cpp new file mode 100644 index 000000000..6f12774a8 --- /dev/null +++ b/obs-studio-server/tests/test-cef-subprocess.cpp @@ -0,0 +1,96 @@ +#include "cef-subprocess.hpp" + +#include + +#include +#include + +namespace { + +osn::cef::Invocation Classify(std::initializer_list arguments) +{ + return osn::cef::ClassifyInvocation(static_cast(arguments.size()), arguments.begin()); +} + +} // namespace + +TEST_CASE("Normal OSN startup is not classified as a CEF child", "[cef-sandbox]") +{ + const auto invocation = Classify({"obs64.exe", R"(\\.\pipe\slobs)", "1.2.3"}); + CHECK(invocation.kind == osn::cef::InvocationKind::Normal); +} + +TEST_CASE("CEF child process types are accepted", "[cef-sandbox]") +{ + for (const char *type : {"--type=renderer", "--type=gpu-process", "--type=utility", "--type=crashpad-handler"}) { + CAPTURE(type); + const auto invocation = Classify({"obs64.exe", type, "--some-cef-switch"}); + CHECK(invocation.kind == osn::cef::InvocationKind::Child); + } +} + +TEST_CASE("Malformed CEF child invocations fail closed", "[cef-sandbox]") +{ + SECTION("missing command line") + { + CHECK(osn::cef::ClassifyInvocation(0, nullptr).kind == osn::cef::InvocationKind::Invalid); + } + + SECTION("missing process type value") + { + CHECK(Classify({"obs64.exe", "--type="}).kind == osn::cef::InvocationKind::Invalid); + } + + SECTION("separate process type value") + { + CHECK(Classify({"obs64.exe", "--type", "renderer"}).kind == osn::cef::InvocationKind::Invalid); + } + + SECTION("duplicate process type") + { + CHECK(Classify({"obs64.exe", "--type=renderer", "--type=utility"}).kind == osn::cef::InvocationKind::Invalid); + } + + SECTION("unknown process type") + { + CHECK(Classify({"obs64.exe", "--type=zygote"}).kind == osn::cef::InvocationKind::Invalid); + CHECK(Classify({"obs64.exe", "--type=Renderer"}).kind == osn::cef::InvocationKind::Invalid); + } + + SECTION("sandbox opt out") + { + CHECK(Classify({"obs64.exe", "--type=renderer", "--no-sandbox"}).kind == osn::cef::InvocationKind::Invalid); + CHECK(Classify({"obs64.exe", "--no-sandbox"}).kind == osn::cef::InvocationKind::Invalid); + CHECK(Classify({"obs64.exe", "--type=renderer", "--no-sandbox=1"}).kind == osn::cef::InvocationKind::Invalid); + } +} + +TEST_CASE("Pre-main initialization recognizes CEF process switches", "[cef-sandbox]") +{ + const wchar_t *normal[] = {L"obs64.exe", L"socket-name", L"1.2.3"}; + const wchar_t *renderer[] = {L"obs64.exe", L"--type=renderer"}; + const wchar_t *malformed_type[] = {L"obs64.exe", L"--type", L"renderer"}; + const wchar_t *sandbox_opt_out[] = {L"obs64.exe", L"--no-sandbox=1"}; + + CHECK_FALSE(osn::cef::ContainsCefProcessSwitch(3, normal)); + CHECK(osn::cef::ContainsCefProcessSwitch(2, renderer)); + CHECK(osn::cef::ContainsCefProcessSwitch(3, malformed_type)); + CHECK(osn::cef::ContainsCefProcessSwitch(2, sandbox_opt_out)); +} + +TEST_CASE("Browser plugin path is fixed relative to obs64.exe", "[cef-sandbox]") +{ + const auto path = osn::cef::BrowserPluginPath(R"(C:\Program Files\Streamlabs\obs64.exe)"); + CHECK(path == R"(C:\Program Files\Streamlabs\obs-plugins\64bit\obs-browser.dll)"); +} + +TEST_CASE("Install-owned path validation is component-aware", "[cef-sandbox]") +{ + const std::filesystem::path root = R"(C:\Program Files\Streamlabs)"; + + CHECK(osn::cef::IsInstallOwnedPath(root, root / "obs-plugins" / "64bit")); + CHECK(osn::cef::IsInstallOwnedPath(root, root / "obs-plugins" / "64bit" / "obs-browser.dll")); + CHECK_FALSE(osn::cef::IsInstallOwnedPath(root, root)); + CHECK_FALSE(osn::cef::IsInstallOwnedPath(root, R"(C:\Program Files\Streamlabs-evil\obs-browser.dll)")); + CHECK_FALSE(osn::cef::IsInstallOwnedPath(root, R"(C:\Program Files\Streamlabs\obs-plugins\..\..\outside\obs-browser.dll)")); +} From 7b1d1659905fecb1631226984cc5816aab48cd00 Mon Sep 17 00:00:00 2001 From: Vladimir Sumarov Date: Sat, 19 Sep 2026 01:51:51 +0200 Subject: [PATCH 2/9] Update libobs to 32.1.1sl10cef2 --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8e35bdd8e..32f84f2cc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,7 +25,7 @@ env: SLGenerator: Visual Studio 17 2022 SLDistributeDirectory: distribute SLFullDistributePath: "streamlabs-build.app/distribute" # The .app extension is required to run macOS tests correctly. - LibOBSVersion: 32.1.1sl12 + LibOBSVersion: 32.1.1sl10cef2 PACKAGE_NAME: osn jobs: From da444d6138dde08ce88e38964597b8471e0fdc2d Mon Sep 17 00:00:00 2001 From: Vladimir Sumarov Date: Sat, 19 Sep 2026 02:15:24 +0200 Subject: [PATCH 3/9] Ignore CEF runtime libraries in module test --- tests/osn-tests/src/test_osn_module.ts | 50 ++++++++++++++------------ 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/tests/osn-tests/src/test_osn_module.ts b/tests/osn-tests/src/test_osn_module.ts index 75a868609..0452f3e70 100644 --- a/tests/osn-tests/src/test_osn_module.ts +++ b/tests/osn-tests/src/test_osn_module.ts @@ -45,6 +45,21 @@ describe(testName, () => { it('Open all module types and initialize them', () => { let moduleTypes: string[] = []; let modulePath: string; + const nonModuleLibraries = new Set([ + 'chrome_elf.dll', + 'd3dcompiler_47.dll', + 'dxcompiler.dll', + 'dxil.dll', + 'libcef.dll', + 'libEGL.dll', + 'libGLESv2.dll', + 'Spout.dll', + 'SpoutDX.dll', + 'SpoutLibrary.dll', + 'vk_swiftshader.dll', + 'vulkan-1.dll', + 'mediasoup-connector.dll', // Doesn't build in debug mode + ]); if (obs.os == 'win32') { modulePath = path.join(path.normalize(osn.DefaultPluginPath), '64bit'); @@ -53,30 +68,21 @@ describe(testName, () => { } fs.readdirSync(modulePath).forEach(file => { - if (file.endsWith('.dll')) { - if (file != 'chrome_elf.dll' && - file != 'libcef.dll' && - file != 'libEGL.dll' && - file != 'libGLESv2.dll' && - file != 'Spout.dll' && - file != 'SpoutDX.dll' && - file != 'SpoutLibrary.dll' && - file != 'mediasoup-connector.dll') { // Doesn't build in debug mode - // Opening module - console.log('Opening module: ' + file); - const moduleType = osn.ModuleFactory.open(path.join(modulePath, '/' + file), path.normalize(osn.DefaultDataPath)); - console.log('Opened module: ' + file + ' successfully ' + moduleType); - // Checking if module was opened properly - expect(moduleType).to.not.equal(undefined, GetErrorMessage(ETestErrorMsg.OpenModule, file)); + if (file.endsWith('.dll') && !nonModuleLibraries.has(file)) { + // Opening module + console.log('Opening module: ' + file); + const moduleType = osn.ModuleFactory.open(path.join(modulePath, '/' + file), path.normalize(osn.DefaultDataPath)); + console.log('Opened module: ' + file + ' successfully ' + moduleType); + // Checking if module was opened properly + expect(moduleType).to.not.equal(undefined, GetErrorMessage(ETestErrorMsg.OpenModule, file)); - // Initializing module - expect(function () { - moduleType.initialize(); - }).to.not.throw(); + // Initializing module + expect(function () { + moduleType.initialize(); + }).to.not.throw(); - // Adding to moduleArrays to use in check later - moduleTypes.push(path.join(modulePath, '/' + file)); - } + // Adding to moduleArrays to use in check later + moduleTypes.push(path.join(modulePath, '/' + file)); } }); From 2acc6c9788e37fc74dee06b4f52b1639abf4b9b3 Mon Sep 17 00:00:00 2001 From: Vladimir Sumarov Date: Sun, 20 Sep 2026 11:37:09 +0200 Subject: [PATCH 4/9] Address CEF sandbox integration review --- CMakeLists.txt | 103 ++++++++++++++++-- obs-studio-client/CMakeLists.txt | 9 ++ .../dependencies/obs_studio_client.node.txt | Bin 588 -> 1494 bytes obs-studio-server/CMakeLists.txt | 7 +- obs-studio-server/source/cef-sandbox-host.cpp | 9 +- obs-studio-server/source/cef-subprocess.cpp | 73 ++++++++++++- obs-studio-server/source/cef-subprocess.hpp | 2 + obs-studio-server/source/crashpad-bridge.h | 4 +- .../tests/test-cef-subprocess.cpp | 26 ++++- 9 files changed, 205 insertions(+), 28 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e25d0cd8a..abf16fa0e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,21 +24,43 @@ set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_SOURCE_DIR}/cmake") # V8 (v8config.h) hard-errors on __cplusplus <= 201703L, so the node headers # won't compile without it. if(MSVC) - # The Windows CEF 6533 v4 wrapper and sandbox are Release-only /MT - # libraries. Keep every source-built target on the same CRT, including - # static dependencies that are linked into obs64.exe and its unit tests. - set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded") - # The matching CEF libraries are Release /MT even in a Debug consumer. - # Remove MSVC's Debug macro after its default definitions, then use the - # corresponding STL and assertion settings for every source-built target. - add_compile_definitions( - $<$:NDEBUG> - $<$:_HAS_ITERATOR_DEBUGGING=0> - ) - add_compile_options($<$:/U_DEBUG>) add_compile_options($<$:/Zc:__cplusplus>) endif() +# The Windows CEF 6533 v4 sandbox archive is Release-only and built with /MT. +# Apply its ABI requirements only to the obs64 link closure. In particular, +# never change the addon's runtime: it is loaded into Electron and must retain +# the dynamic CRT selected by its host. +function(osn_target_uses_static_msvc_runtime target) + if(MSVC) + set_property(TARGET ${target} PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded") + target_compile_definitions( + ${target} + PRIVATE + $<$:NDEBUG> + $<$:_HAS_ITERATOR_DEBUGGING=0> + ) + target_compile_options(${target} PRIVATE $<$:/U_DEBUG>) + endif() +endfunction() + +function(osn_target_uses_dynamic_msvc_runtime target) + if(MSVC) + set_property(TARGET ${target} PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") + endif() +endfunction() + +# A configure-time guard against accidentally widening the CEF CRT setting to +# Electron's native addon again. +function(osn_verify_msvc_runtime target expected_runtime) + if(MSVC) + get_property(actual_runtime TARGET ${target} PROPERTY MSVC_RUNTIME_LIBRARY) + if(NOT actual_runtime STREQUAL expected_runtime) + message(FATAL_ERROR "${target} must use ${expected_runtime}; found ${actual_runtime}") + endif() + endif() +endfunction() + # CppCheck IF( NOT CLANG_ANALYZE_CONFIG) include(cppcheck) @@ -126,6 +148,10 @@ if (WIN32) FetchContent_Populate(stackwalker) add_subdirectory(${stackwalker_SOURCE_DIR} ${stackwalker_BINARY_DIR} EXCLUDE_FROM_ALL) endif() + if(TARGET StackWalker) + osn_target_uses_static_msvc_runtime(StackWalker) + osn_verify_msvc_runtime(StackWalker "MultiThreaded") + endif() endif() # Nlohmann JSON (modern JSON for C++) @@ -152,6 +178,14 @@ if(BUILD_TESTING) set(CATCH_DEVELOPMENT_BUILD OFF CACHE BOOL "" FORCE) FetchContent_MakeAvailable(Catch2) + if(TARGET Catch2) + osn_target_uses_static_msvc_runtime(Catch2) + osn_verify_msvc_runtime(Catch2 "MultiThreaded") + endif() + if(TARGET Catch2WithMain) + osn_target_uses_static_msvc_runtime(Catch2WithMain) + osn_verify_msvc_runtime(Catch2WithMain "MultiThreaded") + endif() list(APPEND CMAKE_MODULE_PATH "${catch2_SOURCE_DIR}/extras") endif() @@ -186,7 +220,52 @@ endif() add_subdirectory(${libcurl_SOURCE_DIR} ${libcurl_BINARY_DIR} EXCLUDE_FROM_ALL) endif() +if(TARGET libcurl) + osn_target_uses_static_msvc_runtime(libcurl) + osn_verify_msvc_runtime(libcurl "MultiThreaded") +endif() + add_subdirectory(lib-streamlabs-ipc) + +if(WIN32) + # lib-streamlabs-ipc is a shared static archive for both binaries. Clone its + # target-local build settings into a second archive so the Electron addon + # remains /MD while obs64 gets the /MT CEF-compatible objects. + get_target_property(OSN_IPC_SOURCES lib-streamlabs-ipc SOURCES) + get_target_property(OSN_IPC_INCLUDE_DIRECTORIES lib-streamlabs-ipc INCLUDE_DIRECTORIES) + get_target_property(OSN_IPC_COMPILE_DEFINITIONS lib-streamlabs-ipc COMPILE_DEFINITIONS) + get_target_property(OSN_IPC_COMPILE_OPTIONS lib-streamlabs-ipc COMPILE_OPTIONS) + get_target_property(OSN_IPC_LINK_LIBRARIES lib-streamlabs-ipc LINK_LIBRARIES) + get_target_property(OSN_IPC_SOURCE_DIRECTORY lib-streamlabs-ipc SOURCE_DIR) + get_property( + OSN_IPC_DIRECTORY_COMPILE_DEFINITIONS + DIRECTORY "${OSN_IPC_SOURCE_DIRECTORY}" + PROPERTY COMPILE_DEFINITIONS + ) + + add_library(lib-streamlabs-ipc-obs64 STATIC ${OSN_IPC_SOURCES}) + if(OSN_IPC_INCLUDE_DIRECTORIES) + target_include_directories(lib-streamlabs-ipc-obs64 PRIVATE ${OSN_IPC_INCLUDE_DIRECTORIES}) + endif() + if(OSN_IPC_COMPILE_DEFINITIONS) + target_compile_definitions(lib-streamlabs-ipc-obs64 PRIVATE ${OSN_IPC_COMPILE_DEFINITIONS}) + endif() + if(OSN_IPC_DIRECTORY_COMPILE_DEFINITIONS) + target_compile_definitions(lib-streamlabs-ipc-obs64 PRIVATE ${OSN_IPC_DIRECTORY_COMPILE_DEFINITIONS}) + endif() + if(OSN_IPC_COMPILE_OPTIONS) + target_compile_options(lib-streamlabs-ipc-obs64 PRIVATE ${OSN_IPC_COMPILE_OPTIONS}) + endif() + if(OSN_IPC_LINK_LIBRARIES) + target_link_libraries(lib-streamlabs-ipc-obs64 PRIVATE ${OSN_IPC_LINK_LIBRARIES}) + endif() + + osn_target_uses_dynamic_msvc_runtime(lib-streamlabs-ipc) + osn_target_uses_static_msvc_runtime(lib-streamlabs-ipc-obs64) + osn_verify_msvc_runtime(lib-streamlabs-ipc "MultiThreaded$<$:Debug>DLL") + osn_verify_msvc_runtime(lib-streamlabs-ipc-obs64 "MultiThreaded") +endif() + add_subdirectory(obs-studio-client) add_subdirectory(obs-studio-server) diff --git a/obs-studio-client/CMakeLists.txt b/obs-studio-client/CMakeLists.txt index 07f9c037b..62e7705d8 100644 --- a/obs-studio-client/CMakeLists.txt +++ b/obs-studio-client/CMakeLists.txt @@ -174,6 +174,11 @@ add_nodejs_module( ${osn-client_SOURCES} ) +# Node modules are loaded into Electron, whose dynamic CRT must remain shared +# with the addon and its normal IPC archive. +osn_target_uses_dynamic_msvc_runtime(obs_studio_client) +osn_verify_msvc_runtime(obs_studio_client "MultiThreaded$<$:Debug>DLL") + if(BUILD_TESTING) include(Catch) @@ -203,6 +208,10 @@ if(BUILD_TESTING) Catch2::Catch2WithMain ) + # Catch2 is a static test dependency of the obs64 tests, so it is /MT. + # Keep this standalone test executable compatible with that archive too. + osn_target_uses_static_msvc_runtime(obs_studio_client_unit_tests) + if(APPLE) add_custom_command( TARGET obs_studio_client_unit_tests diff --git a/obs-studio-client/dependencies/obs_studio_client.node.txt b/obs-studio-client/dependencies/obs_studio_client.node.txt index fe8dcd6dcb82a04045b35c62cbb948ee1d3eb96a..caf6940d2fac80aecbf5681dac77a38a178f113c 100644 GIT binary patch literal 1494 zcmb`H-Acni5QWdR;5+06QmlIAg-}#bY>PBnFD0a@7Metw_>-4cze!y2Dk<%qGJs}Fm%Vr+1dP{sIl&|8|hMWjdX_}eYTOVo4fmOH?PyJ z@Wy)ly}iqu3Es?g0n4FIb=2v(+0F2Os(XBzz<~<}LL;jg`^Y6yAx-tu2cpJS@i4`m}kbR-ri?BD~3oqdX<$8x<8(BZGwEQga zyzs`;UV4J>R{g$wU&7GvO4Ut8rDVz%_V#&qJ!YS-wob delta 10 Rcmcb{eTHR28RI0@YXBHc1V#V= diff --git a/obs-studio-server/CMakeLists.txt b/obs-studio-server/CMakeLists.txt index c1e0650e6..e8c75f810 100644 --- a/obs-studio-server/CMakeLists.txt +++ b/obs-studio-server/CMakeLists.txt @@ -235,7 +235,7 @@ endif() if (WIN32) set(PROJECT_LIBRARIES - lib-streamlabs-ipc + lib-streamlabs-ipc-obs64 OBS::libobs dwmapi.lib dxgi.lib @@ -474,6 +474,8 @@ add_library( obs-studio-server-lib STATIC ${OSN_SERVER_CORE_SOURCES} ) +osn_target_uses_static_msvc_runtime(obs-studio-server-lib) +osn_verify_msvc_runtime(obs-studio-server-lib "MultiThreaded") IF(WIN32) target_compile_definitions( @@ -500,6 +502,8 @@ add_executable( ${PROJECT_NAME} "${PROJECT_SOURCE_DIR}/source/main.cpp" ) +osn_target_uses_static_msvc_runtime(${PROJECT_NAME}) +osn_verify_msvc_runtime(${PROJECT_NAME} "MultiThreaded") if(WIN32) if(NOT TARGET OBS::cef-sandbox) @@ -704,6 +708,7 @@ if(BUILD_TESTING) Catch2::Catch2WithMain obs-studio-server-lib ) + osn_target_uses_static_msvc_runtime(obs_studio_server_unit_tests) if(WIN32) target_sources(obs_studio_server_unit_tests PRIVATE "tests/test-cef-subprocess.cpp") diff --git a/obs-studio-server/source/cef-sandbox-host.cpp b/obs-studio-server/source/cef-sandbox-host.cpp index 208cc6744..2927ef0d8 100644 --- a/obs-studio-server/source/cef-sandbox-host.cpp +++ b/obs-studio-server/source/cef-sandbox-host.cpp @@ -213,14 +213,10 @@ int ExecuteSubprocess() const int exit_code = execute_subprocess(sandbox_info); destroy_sandbox_info(); - FreeLibrary(browser_module); - RemoveDllDirectory(plugin_cookie); - if (exit_code < 0) { std::cerr << "CEF child dispatcher returned an invalid negative exit code: " << exit_code << std::endl; return dispatch_failure; } - return exit_code; } @@ -233,7 +229,10 @@ std::optional DispatchSubprocessIfNeeded(int argc, char *argv[]) return std::nullopt; if (invocation.kind == InvocationKind::Invalid) { - std::cerr << "Rejected CEF child process invocation: " << invocation.error << std::endl; + std::cerr << "Rejected CEF child process invocation: " << invocation.error; + if (invocation.sandbox_opt_out) + std::cerr << "; argv=" << RenderInvocationArguments(argc, argv); + std::cerr << std::endl; return dispatch_failure; } diff --git a/obs-studio-server/source/cef-subprocess.cpp b/obs-studio-server/source/cef-subprocess.cpp index dedfaf2ac..74739096c 100644 --- a/obs-studio-server/source/cef-subprocess.cpp +++ b/obs-studio-server/source/cef-subprocess.cpp @@ -6,9 +6,9 @@ namespace osn::cef { namespace { -// Keep this list pinned to CEF 6533/Chromium 127. CEF may relaunch the main -// executable for its embedded Crashpad handler in addition to Content child -// processes. +// Keep this list pinned to the supported CEF 6533/6613 packages. CEF may +// relaunch the main executable for its embedded Crashpad handler in addition +// to Content child processes. constexpr std::array allowed_process_types = { "crashpad-handler", "gpu-process", @@ -26,6 +26,44 @@ bool IsAllowedProcessType(std::string_view process_type) return false; } +std::string RenderArgument(const char *argument) +{ + if (!argument) + return ""; + + constexpr char hex[] = "0123456789ABCDEF"; + std::string result{"\""}; + for (const unsigned char character : std::string_view(argument)) { + switch (character) { + case '\\': + result.append("\\\\"); + break; + case '\"': + result.append("\\\""); + break; + case '\n': + result.append("\\n"); + break; + case '\r': + result.append("\\r"); + break; + case '\t': + result.append("\\t"); + break; + default: + if (character >= 0x20 && character <= 0x7e) { + result.push_back(static_cast(character)); + } else { + result.append("\\x"); + result.push_back(hex[character >> 4]); + result.push_back(hex[character & 0x0f]); + } + } + } + result.push_back('\"'); + return result; +} + } // namespace Invocation ClassifyInvocation(int argc, const char *const argv[]) @@ -39,6 +77,7 @@ Invocation ClassifyInvocation(int argc, const char *const argv[]) size_t type_argument_count = 0; bool no_sandbox = false; + bool separate_type_argument = false; for (int index = 1; index < argc; ++index) { const std::string_view argument = argv[index] ? argv[index] : ""; @@ -49,9 +88,8 @@ Invocation ClassifyInvocation(int argc, const char *const argv[]) } if (argument == "--type") { - result.kind = InvocationKind::Invalid; - result.error = "CEF child process type must use --type="; - return result; + separate_type_argument = true; + continue; } constexpr std::string_view type_prefix = "--type="; @@ -60,6 +98,13 @@ Invocation ClassifyInvocation(int argc, const char *const argv[]) result.process_type = argument.substr(type_prefix.size()); } } + result.sandbox_opt_out = no_sandbox; + + if (separate_type_argument) { + result.kind = InvocationKind::Invalid; + result.error = "CEF child process type must use --type="; + return result; + } if (type_argument_count == 0) { if (no_sandbox) { @@ -97,6 +142,22 @@ Invocation ClassifyInvocation(int argc, const char *const argv[]) return result; } +std::string RenderInvocationArguments(int argc, const char *const argv[]) +{ + if (!argv) + return ""; + if (argc <= 0) + return ""; + + std::string result; + for (int index = 0; index < argc; ++index) { + if (index) + result.push_back(' '); + result.append(RenderArgument(argv[index])); + } + return result; +} + bool ContainsCefProcessSwitch(int argc, const wchar_t *const argv[]) { if (argc < 1 || !argv) diff --git a/obs-studio-server/source/cef-subprocess.hpp b/obs-studio-server/source/cef-subprocess.hpp index d66429a71..7e42479e5 100644 --- a/obs-studio-server/source/cef-subprocess.hpp +++ b/obs-studio-server/source/cef-subprocess.hpp @@ -16,9 +16,11 @@ struct Invocation { InvocationKind kind = InvocationKind::Normal; std::string_view process_type; std::string error; + bool sandbox_opt_out = false; }; Invocation ClassifyInvocation(int argc, const char *const argv[]); +std::string RenderInvocationArguments(int argc, const char *const argv[]); bool ContainsCefProcessSwitch(int argc, const wchar_t *const argv[]); std::filesystem::path BrowserPluginPath(const std::filesystem::path &executable_path); bool IsInstallOwnedPath(const std::filesystem::path &install_root, const std::filesystem::path &candidate); diff --git a/obs-studio-server/source/crashpad-bridge.h b/obs-studio-server/source/crashpad-bridge.h index 057457914..bbb0445bd 100644 --- a/obs-studio-server/source/crashpad-bridge.h +++ b/obs-studio-server/source/crashpad-bridge.h @@ -2,7 +2,9 @@ // This interface deliberately exposes only C-compatible values. The server is // built with /MT, while the Crashpad bridge uses /MD, so C++ objects and heap -// ownership must not cross this boundary. +// ownership must not cross this boundary. Inputs are caller-owned and copied +// synchronously; the API returns no allocated memory, FILE*, fd/HANDLE, or +// errno state. The exception callback receives a non-owning OS payload only. #if defined(_WIN32) #include diff --git a/obs-studio-server/tests/test-cef-subprocess.cpp b/obs-studio-server/tests/test-cef-subprocess.cpp index 6f12774a8..b9f546e32 100644 --- a/obs-studio-server/tests/test-cef-subprocess.cpp +++ b/obs-studio-server/tests/test-cef-subprocess.cpp @@ -59,12 +59,32 @@ TEST_CASE("Malformed CEF child invocations fail closed", "[cef-sandbox]") SECTION("sandbox opt out") { - CHECK(Classify({"obs64.exe", "--type=renderer", "--no-sandbox"}).kind == osn::cef::InvocationKind::Invalid); - CHECK(Classify({"obs64.exe", "--no-sandbox"}).kind == osn::cef::InvocationKind::Invalid); - CHECK(Classify({"obs64.exe", "--type=renderer", "--no-sandbox=1"}).kind == osn::cef::InvocationKind::Invalid); + for (const auto invocation : { + Classify({"obs64.exe", "--type=renderer", "--no-sandbox"}), + Classify({"obs64.exe", "--no-sandbox"}), + Classify({"obs64.exe", "--type=renderer", "--no-sandbox=1"}), + }) { + CHECK(invocation.kind == osn::cef::InvocationKind::Invalid); + CHECK(invocation.sandbox_opt_out); + } + + const auto malformed = Classify({"obs64.exe", "--type", "renderer", "--no-sandbox"}); + CHECK(malformed.kind == osn::cef::InvocationKind::Invalid); + CHECK(malformed.sandbox_opt_out); } } +TEST_CASE("Rejected CEF invocations render every argument safely", "[cef-sandbox]") +{ + const char *arguments[] = {"obs64.exe", "--type=renderer", "--no-sandbox=1", "line\nbreak", "quote\"slash\\", "\x01"}; + CHECK(osn::cef::RenderInvocationArguments(6, arguments) == + "\"obs64.exe\" \"--type=renderer\" \"--no-sandbox=1\" \"line\\nbreak\" \"quote\\\"slash\\\\\" \"\\x01\""); + + const char *with_null[] = {"obs64.exe", nullptr}; + CHECK(osn::cef::RenderInvocationArguments(2, with_null) == R"("obs64.exe" )"); + CHECK(osn::cef::RenderInvocationArguments(0, nullptr) == ""); +} + TEST_CASE("Pre-main initialization recognizes CEF process switches", "[cef-sandbox]") { const wchar_t *normal[] = {L"obs64.exe", L"socket-name", L"1.2.3"}; From cc5b6192b1eb5cca1caf2c9daccb43efeb966dc1 Mon Sep 17 00:00:00 2001 From: Vladimir Sumarov Date: Thu, 1 Oct 2026 16:20:24 -0700 Subject: [PATCH 5/9] Use current CEF 6613 libobs test package --- .github/workflows/main.yml | 2 +- CMakeLists.txt | 2 +- obs-studio-server/CMakeLists.txt | 4 ++-- obs-studio-server/source/cef-subprocess.cpp | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 32f84f2cc..c197380aa 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,7 +25,7 @@ env: SLGenerator: Visual Studio 17 2022 SLDistributeDirectory: distribute SLFullDistributePath: "streamlabs-build.app/distribute" # The .app extension is required to run macOS tests correctly. - LibOBSVersion: 32.1.1sl10cef2 + LibOBSVersion: 32.1.1sl12cef1 PACKAGE_NAME: osn jobs: diff --git a/CMakeLists.txt b/CMakeLists.txt index abf16fa0e..6c0cc3590 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,7 +27,7 @@ if(MSVC) add_compile_options($<$:/Zc:__cplusplus>) endif() -# The Windows CEF 6533 v4 sandbox archive is Release-only and built with /MT. +# The supported Windows x64 CEF sandbox archive is Release-only and built with /MT. # Apply its ABI requirements only to the obs64 link closure. In particular, # never change the addon's runtime: it is loaded into Electron and must retain # the dynamic CRT selected by its host. diff --git a/obs-studio-server/CMakeLists.txt b/obs-studio-server/CMakeLists.txt index e8c75f810..b072ac239 100644 --- a/obs-studio-server/CMakeLists.txt +++ b/obs-studio-server/CMakeLists.txt @@ -523,8 +523,8 @@ endif() target_link_libraries(${PROJECT_NAME} PRIVATE obs-studio-server-lib) if(WIN32) - # The packaged CEF 6533 v4 sandbox archive is Release-only and built with - # /MT. Link it into obs64.exe itself, where the CEF sandbox contract + # The supported Windows x64 CEF sandbox archive is Release-only and built + # with /MT. Link it into obs64.exe itself, where the CEF sandbox contract # requires cef_sandbox_info_create/destroy to reside. target_link_libraries(${PROJECT_NAME} PRIVATE crashpad-bridge OBS::cef-sandbox) endif() diff --git a/obs-studio-server/source/cef-subprocess.cpp b/obs-studio-server/source/cef-subprocess.cpp index 74739096c..05a9c05ee 100644 --- a/obs-studio-server/source/cef-subprocess.cpp +++ b/obs-studio-server/source/cef-subprocess.cpp @@ -6,7 +6,7 @@ namespace osn::cef { namespace { -// Keep this list pinned to the supported CEF 6533/6613 packages. CEF may +// Keep this list pinned to the supported CEF package. CEF may // relaunch the main executable for its embedded Crashpad handler in addition // to Content child processes. constexpr std::array allowed_process_types = { From 501575db88122392aca6a57e657885072b69737e Mon Sep 17 00:00:00 2001 From: Vladimir Sumarov Date: Fri, 2 Oct 2026 10:46:52 -0700 Subject: [PATCH 6/9] Match Chromium Windows switches in CEF child dispatch --- obs-studio-server/source/cef-sandbox-host.cpp | 12 +- obs-studio-server/source/cef-subprocess.cpp | 139 +++++++++++++++--- obs-studio-server/source/cef-subprocess.hpp | 3 +- .../tests/test-cef-subprocess.cpp | 108 ++++++++++++-- 4 files changed, 226 insertions(+), 36 deletions(-) diff --git a/obs-studio-server/source/cef-sandbox-host.cpp b/obs-studio-server/source/cef-sandbox-host.cpp index 2927ef0d8..436fae246 100644 --- a/obs-studio-server/source/cef-sandbox-host.cpp +++ b/obs-studio-server/source/cef-sandbox-host.cpp @@ -224,7 +224,17 @@ int ExecuteSubprocess() std::optional DispatchSubprocessIfNeeded(int argc, char *argv[]) { - const Invocation invocation = ClassifyInvocation(argc, argv); + int wide_argc = 0; + LPWSTR *wide_argv = CommandLineToArgvW(GetCommandLineW(), &wide_argc); + if (!wide_argv) { + ReportWindowsError("CommandLineToArgvW"); + return dispatch_failure; + } + + // CEF parses the native Windows command line. Classify those same arguments + // so narrow CRT argv conversion cannot hide a sandbox opt-out switch. + const Invocation invocation = ClassifyInvocation(wide_argc, wide_argv); + LocalFree(wide_argv); if (invocation.kind == InvocationKind::Normal) return std::nullopt; diff --git a/obs-studio-server/source/cef-subprocess.cpp b/obs-studio-server/source/cef-subprocess.cpp index 05a9c05ee..da962c66d 100644 --- a/obs-studio-server/source/cef-subprocess.cpp +++ b/obs-studio-server/source/cef-subprocess.cpp @@ -16,16 +16,99 @@ constexpr std::array allowed_process_types = { "utility", }; -bool IsAllowedProcessType(std::string_view process_type) +template bool IsAllowedProcessType(std::basic_string_view process_type) { for (const auto allowed_type : allowed_process_types) { - if (process_type == allowed_type) + if (process_type.size() != allowed_type.size()) + continue; + bool matches = true; + for (size_t index = 0; index < process_type.size(); ++index) { + if (process_type[index] != Character(allowed_type[index])) { + matches = false; + break; + } + } + if (matches) return true; } return false; } +bool IsCommandLineWhitespace(char character) +{ + return std::string_view{" \t\n\r\f\v"}.find(character) != std::string_view::npos; +} + +bool IsCommandLineWhitespace(wchar_t character) +{ + constexpr std::wstring_view whitespace = + L" \t\n\r\f\v\u0085\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000"; + return whitespace.find(character) != std::wstring_view::npos; +} + +// Chromium trims each Windows argument before parsing switches. The narrow CRT +// argv preserves ASCII whitespace; the wide command line also has Unicode whitespace. +template std::basic_string_view TrimCommandLineWhitespace(std::basic_string_view argument) +{ + while (!argument.empty() && IsCommandLineWhitespace(argument.front())) + argument.remove_prefix(1); + while (!argument.empty() && IsCommandLineWhitespace(argument.back())) + argument.remove_suffix(1); + return argument; +} + +template bool IsSwitchTerminator(std::basic_string_view argument) +{ + argument = TrimCommandLineWhitespace(argument); + return argument.size() == 2 && argument[0] == Character('-') && argument[1] == Character('-'); +} + +// Chromium's Windows command line parser accepts all three switch prefixes +// and folds ASCII switch names to lowercase before looking them up. +template std::basic_string_view SwitchBody(std::basic_string_view argument) +{ + argument = TrimCommandLineWhitespace(argument); + if (argument.empty() || (argument.front() != Character('-') && argument.front() != Character('/'))) + return {}; + + const size_t prefix_size = argument.size() > 1 && argument[0] == Character('-') && argument[1] == Character('-') ? 2 : 1; + return argument.substr(prefix_size); +} + +template bool IsSwitch(std::basic_string_view body, std::string_view name) +{ + if (body.size() < name.size()) + return false; + + for (size_t index = 0; index < name.size(); ++index) { + Character character = body[index]; + if (character >= Character('A') && character <= Character('Z')) + character = static_cast(character + Character('a' - 'A')); + if (character != Character(name[index])) + return false; + } + + return body.size() == name.size() || body[name.size()] == Character('='); +} + +void AssignProcessType(Invocation &result, std::string_view process_type) +{ + result.process_type.assign(process_type.data(), process_type.size()); +} + +void AssignProcessType(Invocation &result, std::wstring_view process_type) +{ + result.process_type.clear(); + for (const wchar_t character : process_type) { + if (character > 0x7f) { + result.process_type.clear(); + return; + } + result.process_type.push_back(static_cast(character)); + } +} + std::string RenderArgument(const char *argument) { if (!argument) @@ -64,9 +147,7 @@ std::string RenderArgument(const char *argument) return result; } -} // namespace - -Invocation ClassifyInvocation(int argc, const char *const argv[]) +template Invocation ClassifyInvocationImpl(int argc, const Character *const argv[]) { Invocation result; if (argc < 1 || !argv) { @@ -78,24 +159,29 @@ Invocation ClassifyInvocation(int argc, const char *const argv[]) size_t type_argument_count = 0; bool no_sandbox = false; bool separate_type_argument = false; + std::basic_string_view process_type; for (int index = 1; index < argc; ++index) { - const std::string_view argument = argv[index] ? argv[index] : ""; + const std::basic_string_view argument = argv[index] ? std::basic_string_view{argv[index]} + : std::basic_string_view{}; + if (IsSwitchTerminator(argument)) + break; + const std::basic_string_view switch_body = SwitchBody(argument); - if (argument == "--no-sandbox" || argument.starts_with("--no-sandbox=")) { + if (IsSwitch(switch_body, std::string_view{"no-sandbox"})) { no_sandbox = true; continue; } - if (argument == "--type") { - separate_type_argument = true; - continue; - } - - constexpr std::string_view type_prefix = "--type="; - if (argument.starts_with(type_prefix)) { - ++type_argument_count; - result.process_type = argument.substr(type_prefix.size()); + constexpr std::string_view type_name = "type"; + if (IsSwitch(switch_body, type_name)) { + if (switch_body.size() == type_name.size()) { + separate_type_argument = true; + } else { + ++type_argument_count; + process_type = switch_body.substr(type_name.size() + 1); + AssignProcessType(result, process_type); + } } } result.sandbox_opt_out = no_sandbox; @@ -120,13 +206,13 @@ Invocation ClassifyInvocation(int argc, const char *const argv[]) return result; } - if (result.process_type.empty()) { + if (process_type.empty()) { result.kind = InvocationKind::Invalid; result.error = "CEF child process type is empty"; return result; } - if (!IsAllowedProcessType(result.process_type)) { + if (!IsAllowedProcessType(process_type)) { result.kind = InvocationKind::Invalid; result.error = "CEF child process type is not supported"; return result; @@ -142,6 +228,18 @@ Invocation ClassifyInvocation(int argc, const char *const argv[]) return result; } +} // namespace + +Invocation ClassifyInvocation(int argc, const char *const argv[]) +{ + return ClassifyInvocationImpl(argc, argv); +} + +Invocation ClassifyInvocation(int argc, const wchar_t *const argv[]) +{ + return ClassifyInvocationImpl(argc, argv); +} + std::string RenderInvocationArguments(int argc, const char *const argv[]) { if (!argv) @@ -165,7 +263,10 @@ bool ContainsCefProcessSwitch(int argc, const wchar_t *const argv[]) for (int index = 1; index < argc; ++index) { const std::wstring_view argument = argv[index] ? argv[index] : L""; - if (argument == L"--type" || argument.starts_with(L"--type=") || argument == L"--no-sandbox" || argument.starts_with(L"--no-sandbox=")) { + if (IsSwitchTerminator(argument)) + break; + const std::wstring_view switch_body = SwitchBody(argument); + if (IsSwitch(switch_body, std::string_view{"type"}) || IsSwitch(switch_body, std::string_view{"no-sandbox"})) { return true; } } diff --git a/obs-studio-server/source/cef-subprocess.hpp b/obs-studio-server/source/cef-subprocess.hpp index 7e42479e5..d0064597b 100644 --- a/obs-studio-server/source/cef-subprocess.hpp +++ b/obs-studio-server/source/cef-subprocess.hpp @@ -14,12 +14,13 @@ enum class InvocationKind { struct Invocation { InvocationKind kind = InvocationKind::Normal; - std::string_view process_type; + std::string process_type; std::string error; bool sandbox_opt_out = false; }; Invocation ClassifyInvocation(int argc, const char *const argv[]); +Invocation ClassifyInvocation(int argc, const wchar_t *const argv[]); std::string RenderInvocationArguments(int argc, const char *const argv[]); bool ContainsCefProcessSwitch(int argc, const wchar_t *const argv[]); std::filesystem::path BrowserPluginPath(const std::filesystem::path &executable_path); diff --git a/obs-studio-server/tests/test-cef-subprocess.cpp b/obs-studio-server/tests/test-cef-subprocess.cpp index b9f546e32..47e4b3df0 100644 --- a/obs-studio-server/tests/test-cef-subprocess.cpp +++ b/obs-studio-server/tests/test-cef-subprocess.cpp @@ -4,6 +4,8 @@ #include #include +#include +#include namespace { @@ -12,28 +14,46 @@ osn::cef::Invocation Classify(std::initializer_list arguments) return osn::cef::ClassifyInvocation(static_cast(arguments.size()), arguments.begin()); } +osn::cef::Invocation ClassifyWide(std::initializer_list arguments) +{ + return osn::cef::ClassifyInvocation(static_cast(arguments.size()), arguments.begin()); +} + +constexpr std::wstring_view chromium_wide_whitespace = + L" \t\n\r\f\v\u0085\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000"; + } // namespace TEST_CASE("Normal OSN startup is not classified as a CEF child", "[cef-sandbox]") { const auto invocation = Classify({"obs64.exe", R"(\\.\pipe\slobs)", "1.2.3"}); CHECK(invocation.kind == osn::cef::InvocationKind::Normal); + const auto after_terminator = Classify({"obs64.exe", "pipe", "version", " -- ", "--type=renderer", "--no-sandbox"}); + CHECK(after_terminator.kind == osn::cef::InvocationKind::Normal); + CHECK_FALSE(after_terminator.sandbox_opt_out); + CHECK(ClassifyWide({L"obs64.exe", L"pipe", L"version", L"\u3000--\u00a0", L"/TYPE=renderer", L"/no-sandbox"}).kind == osn::cef::InvocationKind::Normal); + CHECK(ClassifyWide({L"obs64.exe", L"--type=renderer", L"--", L"/no-sandbox"}).kind == osn::cef::InvocationKind::Child); } TEST_CASE("CEF child process types are accepted", "[cef-sandbox]") { - for (const char *type : {"--type=renderer", "--type=gpu-process", "--type=utility", "--type=crashpad-handler"}) { + for (const char *type : {"--type=renderer", "-type=gpu-process", "/type=utility", "/TYPE=crashpad-handler"}) { CAPTURE(type); const auto invocation = Classify({"obs64.exe", type, "--some-cef-switch"}); CHECK(invocation.kind == osn::cef::InvocationKind::Child); } + + const auto padded = Classify({"obs64.exe", " \t/type=renderer\r ", "--some-cef-switch"}); + CHECK(padded.kind == osn::cef::InvocationKind::Child); + CHECK(padded.process_type == "renderer"); } TEST_CASE("Malformed CEF child invocations fail closed", "[cef-sandbox]") { SECTION("missing command line") { - CHECK(osn::cef::ClassifyInvocation(0, nullptr).kind == osn::cef::InvocationKind::Invalid); + CHECK(osn::cef::ClassifyInvocation(0, static_cast(nullptr)).kind == osn::cef::InvocationKind::Invalid); + CHECK(osn::cef::ClassifyInvocation(0, static_cast(nullptr)).kind == osn::cef::InvocationKind::Invalid); } SECTION("missing process type value") @@ -44,11 +64,15 @@ TEST_CASE("Malformed CEF child invocations fail closed", "[cef-sandbox]") SECTION("separate process type value") { CHECK(Classify({"obs64.exe", "--type", "renderer"}).kind == osn::cef::InvocationKind::Invalid); + CHECK(Classify({"obs64.exe", "-type", "renderer"}).kind == osn::cef::InvocationKind::Invalid); + CHECK(Classify({"obs64.exe", "/TYPE", "renderer"}).kind == osn::cef::InvocationKind::Invalid); } SECTION("duplicate process type") { CHECK(Classify({"obs64.exe", "--type=renderer", "--type=utility"}).kind == osn::cef::InvocationKind::Invalid); + CHECK(Classify({"obs64.exe", "-type=renderer", "/TYPE=utility"}).kind == osn::cef::InvocationKind::Invalid); + CHECK(Classify({"obs64.exe", "--type=renderer", " \t/type=utility\r"}).kind == osn::cef::InvocationKind::Invalid); } SECTION("unknown process type") @@ -59,18 +83,58 @@ TEST_CASE("Malformed CEF child invocations fail closed", "[cef-sandbox]") SECTION("sandbox opt out") { - for (const auto invocation : { - Classify({"obs64.exe", "--type=renderer", "--no-sandbox"}), - Classify({"obs64.exe", "--no-sandbox"}), - Classify({"obs64.exe", "--type=renderer", "--no-sandbox=1"}), - }) { - CHECK(invocation.kind == osn::cef::InvocationKind::Invalid); - CHECK(invocation.sandbox_opt_out); + for (const char *opt_out : + {"--no-sandbox", "--no-sandbox=1", "-no-sandbox", "-no-sandbox=1", "/no-sandbox", "/no-sandbox=1", "/NO-SANDBOX=0"}) { + CAPTURE(opt_out); + for (const auto invocation : {Classify({"obs64.exe", "-type=renderer", opt_out}), Classify({"obs64.exe", opt_out})}) { + CHECK(invocation.kind == osn::cef::InvocationKind::Invalid); + CHECK(invocation.sandbox_opt_out); + } } const auto malformed = Classify({"obs64.exe", "--type", "renderer", "--no-sandbox"}); CHECK(malformed.kind == osn::cef::InvocationKind::Invalid); CHECK(malformed.sandbox_opt_out); + + for (const char whitespace : std::string_view{" \t\n\r\f\v"}) { + CAPTURE(static_cast(whitespace)); + std::string opt_out(1, whitespace); + opt_out += "-no-sandbox=1"; + opt_out.push_back(whitespace); + for (const auto invocation : {Classify({"obs64.exe", "--type=renderer", opt_out.c_str()}), Classify({"obs64.exe", opt_out.c_str()})}) { + CHECK(invocation.kind == osn::cef::InvocationKind::Invalid); + CHECK(invocation.sandbox_opt_out); + } + } + } + + SECTION("switch names require an exact match") + { + CHECK(Classify({"obs64.exe", "-typewriter=renderer"}).kind == osn::cef::InvocationKind::Normal); + CHECK(Classify({"obs64.exe", "/no-sandboxed"}).kind == osn::cef::InvocationKind::Normal); + CHECK(Classify({"obs64.exe", "type=renderer"}).kind == osn::cef::InvocationKind::Normal); + } +} + +TEST_CASE("Wide CEF child invocations honor Chromium whitespace", "[cef-sandbox]") +{ + const auto child = ClassifyWide({L"obs64.exe", L"\u3000/TYPE=renderer\u00a0"}); + CHECK(child.kind == osn::cef::InvocationKind::Child); + CHECK(child.process_type == "renderer"); + + CHECK(ClassifyWide({L"obs64.exe", L"--type=renderer", L"\u2007-type=utility\u0085"}).kind == osn::cef::InvocationKind::Invalid); + CHECK(ClassifyWide({L"obs64.exe", L"\u202f/type\u205f", L"renderer"}).kind == osn::cef::InvocationKind::Invalid); + + for (const wchar_t whitespace : chromium_wide_whitespace) { + CAPTURE(static_cast(whitespace)); + std::wstring opt_out(1, whitespace); + opt_out += L"/NO-SANDBOX=0"; + opt_out.push_back(whitespace); + for (const auto invocation : + {ClassifyWide({L"obs64.exe", L"--type=renderer", opt_out.c_str()}), ClassifyWide({L"obs64.exe", opt_out.c_str()})}) { + CHECK(invocation.kind == osn::cef::InvocationKind::Invalid); + CHECK(invocation.sandbox_opt_out); + } } } @@ -88,14 +152,28 @@ TEST_CASE("Rejected CEF invocations render every argument safely", "[cef-sandbox TEST_CASE("Pre-main initialization recognizes CEF process switches", "[cef-sandbox]") { const wchar_t *normal[] = {L"obs64.exe", L"socket-name", L"1.2.3"}; - const wchar_t *renderer[] = {L"obs64.exe", L"--type=renderer"}; - const wchar_t *malformed_type[] = {L"obs64.exe", L"--type", L"renderer"}; - const wchar_t *sandbox_opt_out[] = {L"obs64.exe", L"--no-sandbox=1"}; CHECK_FALSE(osn::cef::ContainsCefProcessSwitch(3, normal)); - CHECK(osn::cef::ContainsCefProcessSwitch(2, renderer)); - CHECK(osn::cef::ContainsCefProcessSwitch(3, malformed_type)); - CHECK(osn::cef::ContainsCefProcessSwitch(2, sandbox_opt_out)); + for (const wchar_t *cef_switch : + {L"--type=renderer", L"-type=renderer", L"/TYPE=renderer", L"--type", L"-type", L"/TYPE", L"--no-sandbox=1", L"-no-sandbox", L"/NO-SANDBOX=0"}) { + CAPTURE(cef_switch); + const wchar_t *arguments[] = {L"obs64.exe", cef_switch}; + CHECK(osn::cef::ContainsCefProcessSwitch(2, arguments)); + } + for (const wchar_t whitespace : chromium_wide_whitespace) { + CAPTURE(static_cast(whitespace)); + std::wstring opt_out(1, whitespace); + opt_out += L"/NO-SANDBOX=0"; + opt_out.push_back(whitespace); + const wchar_t *arguments[] = {L"obs64.exe", opt_out.c_str()}; + CHECK(osn::cef::ContainsCefProcessSwitch(2, arguments)); + } + const wchar_t *padded_type[] = {L"obs64.exe", L"\u3000--type=renderer\u00a0"}; + CHECK(osn::cef::ContainsCefProcessSwitch(2, padded_type)); + const wchar_t *near_match[] = {L"obs64.exe", L"/no-sandboxed"}; + CHECK_FALSE(osn::cef::ContainsCefProcessSwitch(2, near_match)); + const wchar_t *after_terminator[] = {L"obs64.exe", L"\u3000--\u00a0", L"/TYPE=renderer", L"/no-sandbox"}; + CHECK_FALSE(osn::cef::ContainsCefProcessSwitch(4, after_terminator)); } TEST_CASE("Browser plugin path is fixed relative to obs64.exe", "[cef-sandbox]") From 2ba4700344355aabc88ec922728e4f6365aab73b Mon Sep 17 00:00:00 2001 From: Vladimir Sumarov Date: Fri, 2 Oct 2026 13:49:51 -0700 Subject: [PATCH 7/9] ci: use libobs 32.1.1sl12cef2 package --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c197380aa..5ebcbe2e0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,7 +25,7 @@ env: SLGenerator: Visual Studio 17 2022 SLDistributeDirectory: distribute SLFullDistributePath: "streamlabs-build.app/distribute" # The .app extension is required to run macOS tests correctly. - LibOBSVersion: 32.1.1sl12cef1 + LibOBSVersion: 32.1.1sl12cef2 PACKAGE_NAME: osn jobs: From 92e7ada04ca94a35d005dc94eb461850fbf4cbd9 Mon Sep 17 00:00:00 2001 From: Vladimir Sumarov Date: Fri, 2 Oct 2026 16:41:28 -0700 Subject: [PATCH 8/9] ci: use libobs 32.1.1sl12cef3 package --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5ebcbe2e0..b783aa2bc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,7 +25,7 @@ env: SLGenerator: Visual Studio 17 2022 SLDistributeDirectory: distribute SLFullDistributePath: "streamlabs-build.app/distribute" # The .app extension is required to run macOS tests correctly. - LibOBSVersion: 32.1.1sl12cef2 + LibOBSVersion: 32.1.1sl12cef3 PACKAGE_NAME: osn jobs: From 8ac5d69a9edac658f16f9f4f5ec4057a2c880a1b Mon Sep 17 00:00:00 2001 From: Vladimir Sumarov Date: Wed, 7 Oct 2026 16:04:07 -0700 Subject: [PATCH 9/9] ci: update libobs sandbox test package to cef4 --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b783aa2bc..5b17d1a78 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,7 +25,7 @@ env: SLGenerator: Visual Studio 17 2022 SLDistributeDirectory: distribute SLFullDistributePath: "streamlabs-build.app/distribute" # The .app extension is required to run macOS tests correctly. - LibOBSVersion: 32.1.1sl12cef3 + LibOBSVersion: 32.1.1sl12cef4 PACKAGE_NAME: osn jobs: