diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8e35bdd8e..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.1sl12 + LibOBSVersion: 32.1.1sl12cef4 PACKAGE_NAME: osn jobs: diff --git a/CMakeLists.txt b/CMakeLists.txt index a356ae72a..6c0cc3590 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,6 +27,40 @@ if(MSVC) add_compile_options($<$:/Zc:__cplusplus>) endif() +# 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. +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) @@ -114,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++) @@ -140,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() @@ -174,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) @@ -188,4 +279,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/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 35c83a684..caf6940d2 100644 Binary files a/obs-studio-client/dependencies/obs_studio_client.node.txt and b/obs-studio-client/dependencies/obs_studio_client.node.txt differ diff --git a/obs-studio-server/CMakeLists.txt b/obs-studio-server/CMakeLists.txt index 2a34836e1..b072ac239 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 @@ -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 () @@ -449,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( @@ -464,7 +491,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() @@ -475,12 +502,32 @@ 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) - 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 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() if(MSVC) add_definitions(-D_CRT_SECURE_NO_WARNINGS -D_SILENCE_ALL_CXX17_DEPRECATION_WARNINGS) @@ -536,6 +583,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 +596,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) @@ -657,6 +708,11 @@ 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") + endif() if(APPLE) add_custom_command( @@ -677,7 +733,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 0d4054af5..2b2b517f3 100644 Binary files a/obs-studio-server/dependencies/obs64.exe.txt and b/obs-studio-server/dependencies/obs64.exe.txt differ diff --git a/obs-studio-server/source/cef-sandbox-host.cpp b/obs-studio-server/source/cef-sandbox-host.cpp new file mode 100644 index 000000000..436fae246 --- /dev/null +++ b/obs-studio-server/source/cef-sandbox-host.cpp @@ -0,0 +1,254 @@ +#include "cef-sandbox-host.hpp" + +#ifdef _WIN32 + +#include "cef-subprocess.hpp" + +#define OBS_BROWSER_SANDBOX_HOST_IMPLEMENTATION +#include +#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(); + 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[]) +{ + 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; + + if (invocation.kind == InvocationKind::Invalid) { + 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; + } + + 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..da962c66d --- /dev/null +++ b/obs-studio-server/source/cef-subprocess.cpp @@ -0,0 +1,302 @@ +#include "cef-subprocess.hpp" + +#include +#include + +namespace osn::cef { +namespace { + +// 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 = { + "crashpad-handler", + "gpu-process", + "renderer", + "utility", +}; + +template bool IsAllowedProcessType(std::basic_string_view process_type) +{ + for (const auto allowed_type : allowed_process_types) { + 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) + 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; +} + +template Invocation ClassifyInvocationImpl(int argc, const Character *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; + bool separate_type_argument = false; + std::basic_string_view process_type; + + for (int index = 1; index < argc; ++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 (IsSwitch(switch_body, std::string_view{"no-sandbox"})) { + no_sandbox = true; + continue; + } + + 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; + + 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) { + 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 (process_type.empty()) { + result.kind = InvocationKind::Invalid; + result.error = "CEF child process type is empty"; + return result; + } + + if (!IsAllowedProcessType(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; +} + +} // 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) + 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) + return false; + + for (int index = 1; index < argc; ++index) { + const std::wstring_view argument = argv[index] ? argv[index] : L""; + 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; + } + } + + 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..d0064597b --- /dev/null +++ b/obs-studio-server/source/cef-subprocess.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +namespace osn::cef { + +enum class InvocationKind { + Normal, + Child, + Invalid, +}; + +struct Invocation { + InvocationKind kind = InvocationKind::Normal; + 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); +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..bbb0445bd --- /dev/null +++ b/obs-studio-server/source/crashpad-bridge.h @@ -0,0 +1,32 @@ +#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. 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 + +#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..47e4b3df0 --- /dev/null +++ b/obs-studio-server/tests/test-cef-subprocess.cpp @@ -0,0 +1,194 @@ +#include "cef-subprocess.hpp" + +#include + +#include +#include +#include +#include + +namespace { + +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"}) { + 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, 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") + { + 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); + 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") + { + 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") + { + 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); + } + } +} + +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"}; + + CHECK_FALSE(osn::cef::ContainsCefProcessSwitch(3, normal)); + 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]") +{ + 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)")); +} 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)); } });