From 338a478ead27ed68fb2e6b6fda329fc3806a43b0 Mon Sep 17 00:00:00 2001 From: WarmUpTill <19472752+WarmUpTill@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:53:41 +0200 Subject: [PATCH] Cleanup Twitch timestamp verification --- .gitmodules | 3 - deps/date | 1 - plugins/twitch/CMakeLists.txt | 35 +----- plugins/twitch/event-sub.cpp | 47 +------ plugins/twitch/token.cpp | 3 - plugins/twitch/twitch-timestamp.cpp | 86 +++++++++++++ plugins/twitch/twitch-timestamp.hpp | 8 ++ tests/CMakeLists.txt | 10 ++ tests/test-twitch-timestamp.cpp | 183 ++++++++++++++++++++++++++++ 9 files changed, 291 insertions(+), 85 deletions(-) delete mode 160000 deps/date create mode 100644 plugins/twitch/twitch-timestamp.cpp create mode 100644 plugins/twitch/twitch-timestamp.hpp create mode 100644 tests/test-twitch-timestamp.cpp diff --git a/.gitmodules b/.gitmodules index 2c36bb38..18872bb3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -28,9 +28,6 @@ [submodule "deps/libusb"] path = deps/libusb url = https://github.com/libusb/libusb.git -[submodule "deps/date"] - path = deps/date - url = https://github.com/HowardHinnant/date.git [submodule "deps/jsoncons"] path = deps/jsoncons url = https://github.com/danielaparker/jsoncons.git diff --git a/deps/date b/deps/date deleted file mode 160000 index 5bdb7e6f..00000000 --- a/deps/date +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5bdb7e6f31fac909c090a46dbd9fea27b6e609a4 diff --git a/plugins/twitch/CMakeLists.txt b/plugins/twitch/CMakeLists.txt index 93b4cc61..ffa92069 100644 --- a/plugins/twitch/CMakeLists.txt +++ b/plugins/twitch/CMakeLists.txt @@ -32,32 +32,6 @@ if(NOT ZLIB_FOUND) return() endif() -set(DATE_LIB_DIR "${ADVSS_SOURCE_DIR}/deps/date") -if(EXISTS "${DATE_LIB_DIR}/CMakeLists.txt" - AND NOT DISABLE_TWITCH_TIMESTAMP_VERIFICATION) - set(BUILD_TZ_LIB ON) - if(OS_WINDOWS) - if(CURL_FOUND AND TARGET CURL::libcurl) - get_target_property(CURL_INCLUDE_DIR CURL::libcurl - INTERFACE_INCLUDE_DIRECTORIES) - add_subdirectory("${DATE_LIB_DIR}" "${DATE_LIB_DIR}/build" - EXCLUDE_FROM_ALL) - target_include_directories(date-tz PRIVATE "${CURL_INCLUDE_DIR}") - set(VERIFY_TWITCH_TIMESTAMPS ON) - else() - message(WARNING "CURL not found - not verifying Twitch timestamps") - endif() - else() - add_subdirectory("${DATE_LIB_DIR}" "${DATE_LIB_DIR}/build" EXCLUDE_FROM_ALL) - target_compile_options(date-tz PUBLIC -Wno-error=conversion - -Wno-error=shadow) - set(VERIFY_TWITCH_TIMESTAMPS ON) - endif() -else() - message(WARNING "date lib not found in \"${DATE_LIB_DIR}\"!\n" - "Twitch timestamps will not be checked!") -endif() - # --- End of section --- add_library(${PROJECT_NAME} MODULE) @@ -92,6 +66,8 @@ target_sources( content-classification.hpp event-sub.cpp event-sub.hpp + twitch-timestamp.cpp + twitch-timestamp.hpp language-selection.cpp language-selection.hpp macro-action-twitch.cpp @@ -114,13 +90,6 @@ set_target_properties(${PROJECT_NAME} PROPERTIES PREFIX "") target_include_directories(${PROJECT_NAME} PRIVATE "${CPP_HTTPLIB_DIR}/" "${OPENSSL_INCLUDE_DIR}") target_link_libraries(${PROJECT_NAME} PRIVATE ${OPENSSL_LIBRARIES} ZLIB::ZLIB) -if(DEFINED VERIFY_TWITCH_TIMESTAMPS) - target_compile_definitions(${PROJECT_NAME} PRIVATE VERIFY_TIMESTAMPS=1) - target_link_libraries(${PROJECT_NAME} PRIVATE date::date-tz) - if(OS_WINDOWS) - target_link_libraries(${PROJECT_NAME} PRIVATE CURL::libcurl) - endif() -endif() install_advss_plugin(${PROJECT_NAME}) if(OS_WINDOWS) diff --git a/plugins/twitch/event-sub.cpp b/plugins/twitch/event-sub.cpp index 45612d45..447e51db 100644 --- a/plugins/twitch/event-sub.cpp +++ b/plugins/twitch/event-sub.cpp @@ -4,10 +4,7 @@ #include #include - -#ifdef VERIFY_TIMESTAMPS -#include "date/tz.h" -#endif +#include "twitch-timestamp.hpp" using namespace std::chrono_literals; @@ -357,46 +354,6 @@ void EventSub::OnOpen(connection_hdl) _connected = true; } -static bool isValidTimestamp(const std::string ×tamp) -{ -#ifdef VERIFY_TIMESTAMPS - // Example input: 2023-07-19T14:56:51.634234626Z - try { - // Discard the nanosecond part - static constexpr size_t dotPos = 19; - std::string trimmed = timestamp.substr(0, dotPos); - auto tzStart = timestamp.find_first_of("Z+-", dotPos); - trimmed = timestamp.substr(0, dotPos); - if (tzStart != std::string::npos) { - trimmed += timestamp.substr(tzStart); - } - - std::istringstream in(trimmed); - date::sys_time parsedTime; - in >> date::parse("%FT%TZ", parsedTime); - if (in.fail()) { - blog(LOG_WARNING, "failed to parse timestamp %s", - timestamp.c_str()); - return false; - } - - auto now = date::zoned_time{date::current_zone(), - std::chrono::system_clock::now()} - .get_sys_time(); - - auto duration = now - parsedTime; - // Clocks might be off by a bit, so allow negative values also - return duration <= 10min && duration >= -1min; - } catch (const std::exception &e) { - blog(LOG_WARNING, "%s: %s", __func__, e.what()); - return false; - } -#else - // Just assume timestamps are always valid - return true; -#endif -} - bool EventSub::IsValidMessageID(const std::string &id) { auto it = std::find(_messageIDs.begin(), _messageIDs.end(), id); @@ -436,7 +393,7 @@ EventSub::ParseWebSocketMessage(const EventSubWSClient::message_ptr &message) OBSDataAutoRelease metadata = obs_data_get_obj(json, "metadata"); std::string timestamp = obs_data_get_string(metadata, "message_timestamp"); - if (_validateTimestamps && !isValidTimestamp(timestamp)) { + if (_validateTimestamps && !IsValidEventSubTimestamp(timestamp)) { blog(LOG_WARNING, "discarding Twitch EventSub with invalid timestamp %s", timestamp.c_str()); diff --git a/plugins/twitch/token.cpp b/plugins/twitch/token.cpp index 4aba4570..b83cb780 100644 --- a/plugins/twitch/token.cpp +++ b/plugins/twitch/token.cpp @@ -652,9 +652,6 @@ TwitchTokenSettingsDialog::TwitchTokenSettingsDialog( } _validateTimestamps->setChecked(settings._validateEventSubTimestamps); -#ifndef VERIFY_TIMESTAMPS - _validateTimestamps->hide(); -#endif _warnIfInvalid->setChecked(settings._warnIfInvalid); _currentToken = settings; diff --git a/plugins/twitch/twitch-timestamp.cpp b/plugins/twitch/twitch-timestamp.cpp new file mode 100644 index 00000000..69196179 --- /dev/null +++ b/plugins/twitch/twitch-timestamp.cpp @@ -0,0 +1,86 @@ +#include "twitch-timestamp.hpp" + +#include + +#include +#include +#include +#include + +using namespace std::chrono_literals; + +namespace advss { + +bool IsValidEventSubTimestamp(const std::string ×tamp) +{ + // Example input: 2023-07-19T14:56:51.634234626Z + // or: 2023-07-19T14:56:51+05:30 + static const std::regex pattern( + R"(^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:\d{2})$)"); + + std::smatch m; + if (!std::regex_match(timestamp, m, pattern)) { + blog(LOG_WARNING, "failed to parse timestamp %s", + timestamp.c_str()); + return false; + } + + std::tm tm = {}; + tm.tm_year = std::stoi(m[1]) - 1900; + tm.tm_mon = std::stoi(m[2]) - 1; + tm.tm_mday = std::stoi(m[3]); + tm.tm_hour = std::stoi(m[4]); + tm.tm_min = std::stoi(m[5]); + tm.tm_sec = std::stoi(m[6]); + + // Range checks; timegm/_mkgmtime silently normalize out-of-range + // fields instead of failing, so we need to catch that ourselves. + if (tm.tm_mon < 0 || tm.tm_mon > 11 || tm.tm_mday < 1 || + tm.tm_mday > 31 || tm.tm_hour > 23 || tm.tm_min > 59 || + tm.tm_sec > 60 /* allow leap second */) { + blog(LOG_WARNING, "timestamp field out of range %s", + timestamp.c_str()); + return false; + } + +#ifdef _WIN32 + time_t t = _mkgmtime(&tm); +#else + time_t t = timegm(&tm); +#endif + if (t == -1) { + blog(LOG_WARNING, "failed to convert timestamp %s", + timestamp.c_str()); + return false; + } + + // Reject dates that normalized to something else (e.g. Feb 30 -> Mar 2) + if (tm.tm_mday != std::stoi(m[3]) || tm.tm_mon != std::stoi(m[2]) - 1) { + blog(LOG_WARNING, + "timestamp date invalid after normalization %s", + timestamp.c_str()); + return false; + } + + const std::string &tz = m[7]; + if (tz != "Z") { + int offSign = (tz[0] == '+') ? 1 : -1; + int offH = std::stoi(tz.substr(1, 2)); + int offM = std::stoi(tz.substr(4, 2)); + if (offH > 23 || offM > 59) { + blog(LOG_WARNING, "invalid timestamp offset %s", + timestamp.c_str()); + return false; + } + time_t offsetSecs = (offH * 60 + offM) * 60; + t += (offSign > 0) ? -offsetSecs : offsetSecs; + } + + auto parsedTime = std::chrono::system_clock::from_time_t(t); + auto now = std::chrono::system_clock::now(); + auto duration = now - parsedTime; + // Clocks might be off by a bit, so allow negative values also + return duration <= 10min && duration >= -1min; +} + +} // namespace advss diff --git a/plugins/twitch/twitch-timestamp.hpp b/plugins/twitch/twitch-timestamp.hpp new file mode 100644 index 00000000..855ad149 --- /dev/null +++ b/plugins/twitch/twitch-timestamp.hpp @@ -0,0 +1,8 @@ +#pragma once +#include + +namespace advss { + +bool IsValidEventSubTimestamp(const std::string ×tamp); + +} // namespace advss diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 70c17b26..97165712 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -202,6 +202,16 @@ target_sources( ${ADVSS_SOURCE_DIR}/plugins/base/macro-condition-window.cpp ${ADVSS_SOURCE_DIR}/plugins/base/utils/window-selection.cpp) +# --- twitch timestamp --- # + +set(TWITCH_PLUGIN_DIR + "${ADVSS_SOURCE_DIR}/plugins/twitch" + CACHE INTERNAL "") +target_include_directories(${PROJECT_NAME} PRIVATE "${TWITCH_PLUGIN_DIR}") +target_sources( + ${PROJECT_NAME} PRIVATE test-twitch-timestamp.cpp + "${TWITCH_PLUGIN_DIR}/twitch-timestamp.cpp") + # --- Testing --- # enable_testing() diff --git a/tests/test-twitch-timestamp.cpp b/tests/test-twitch-timestamp.cpp new file mode 100644 index 00000000..c8386fc0 --- /dev/null +++ b/tests/test-twitch-timestamp.cpp @@ -0,0 +1,183 @@ +#include "catch.hpp" + +#include + +#include +#include +#include +#include + +// Build an RFC3339 UTC timestamp offset by 'offsetSeconds' from now. +// A positive value means that many seconds in the past. +static std::string makeTimestamp(int offsetSeconds = 0, bool withNanos = false, + const char *tzSuffix = "Z") +{ + auto now = std::chrono::system_clock::now() - + std::chrono::seconds(offsetSeconds); + time_t t = std::chrono::system_clock::to_time_t(now); + std::tm tm = {}; +#ifdef _WIN32 + gmtime_s(&tm, &t); +#else + gmtime_r(&t, &tm); +#endif + char buf[64]; + if (withNanos) { + snprintf(buf, sizeof(buf), + "%04d-%02d-%02dT%02d:%02d:%02d.123456789%s", + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec, tzSuffix); + } else { + snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02d%s", + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec, tzSuffix); + } + return buf; +} + +TEST_CASE("Valid timestamps are accepted", "[twitch][timestamp]") +{ + SECTION("Current time with Z suffix") + { + REQUIRE(advss::IsValidEventSubTimestamp(makeTimestamp(0))); + } + + SECTION("5 seconds in the past") + { + REQUIRE(advss::IsValidEventSubTimestamp(makeTimestamp(5))); + } + + SECTION("9 minutes in the past (within 10-minute window)") + { + REQUIRE(advss::IsValidEventSubTimestamp(makeTimestamp(9 * 60))); + } + + SECTION("Timestamp with nanoseconds and Z suffix") + { + REQUIRE(advss::IsValidEventSubTimestamp( + makeTimestamp(0, true, "Z"))); + } + + SECTION("Lowercase z suffix is rejected (RFC 3339 requires uppercase Z)") + { + REQUIRE_FALSE(advss::IsValidEventSubTimestamp( + makeTimestamp(0, false, "z"))); + } + + SECTION("+00:00 offset (UTC expressed as positive offset)") + { + REQUIRE(advss::IsValidEventSubTimestamp( + makeTimestamp(0, false, "+00:00"))); + } + + SECTION("-00:00 offset") + { + REQUIRE(advss::IsValidEventSubTimestamp( + makeTimestamp(0, false, "-00:00"))); + } + + SECTION("+05:30 offset (IST) with nanoseconds") + { + // Build a timestamp whose wall-clock value is now, expressed in + // IST (+05:30). The UTC equivalent must still be within the window. + auto now = std::chrono::system_clock::now(); + time_t t = std::chrono::system_clock::to_time_t(now); + // Advance the displayed time by 5h30m to represent +05:30 + t += (5 * 60 + 30) * 60; + std::tm tm = {}; +#ifdef _WIN32 + gmtime_s(&tm, &t); +#else + gmtime_r(&t, &tm); +#endif + char buf[64]; + snprintf(buf, sizeof(buf), + "%04d-%02d-%02dT%02d:%02d:%02d.000000000+05:30", + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec); + REQUIRE(advss::IsValidEventSubTimestamp(buf)); + } + + SECTION("-08:00 offset (PST)") + { + auto now = std::chrono::system_clock::now(); + time_t t = std::chrono::system_clock::to_time_t(now); + t -= 8 * 3600; + std::tm tm = {}; +#ifdef _WIN32 + gmtime_s(&tm, &t); +#else + gmtime_r(&t, &tm); +#endif + char buf[64]; + snprintf(buf, sizeof(buf), + "%04d-%02d-%02dT%02d:%02d:%02d-08:00", + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec); + REQUIRE(advss::IsValidEventSubTimestamp(buf)); + } +} + +TEST_CASE("Expired timestamps are rejected", "[twitch][timestamp]") +{ + SECTION("11 minutes in the past (outside 10-minute window)") + { + REQUIRE_FALSE(advss::IsValidEventSubTimestamp( + makeTimestamp(11 * 60))); + } + + SECTION("1 hour in the past") + { + REQUIRE_FALSE( + advss::IsValidEventSubTimestamp(makeTimestamp(3600))); + } + + SECTION("2 minutes in the future (outside -1min allowance)") + { + REQUIRE_FALSE(advss::IsValidEventSubTimestamp( + makeTimestamp(-2 * 60))); + } +} + +TEST_CASE("Invalid timestamp strings are rejected", "[twitch][timestamp]") +{ + SECTION("Empty string") + { + REQUIRE_FALSE(advss::IsValidEventSubTimestamp("")); + } + + SECTION("Completely wrong format") + { + REQUIRE_FALSE( + advss::IsValidEventSubTimestamp("not-a-timestamp")); + } + + SECTION("Missing time component") + { + REQUIRE_FALSE(advss::IsValidEventSubTimestamp("2023-07-19Z")); + } + + SECTION("Missing UTC offset") + { + REQUIRE_FALSE(advss::IsValidEventSubTimestamp( + "2023-07-19T14:56:51.634234626")); + } + + SECTION("Malformed offset - missing minutes") + { + REQUIRE_FALSE(advss::IsValidEventSubTimestamp( + "2023-07-19T14:56:51+05")); + } + + SECTION("Malformed offset - non-numeric") + { + REQUIRE_FALSE(advss::IsValidEventSubTimestamp( + "2023-07-19T14:56:51+XX:XX")); + } + + SECTION("Truncated after seconds") + { + REQUIRE_FALSE( + advss::IsValidEventSubTimestamp("2023-07-19T14:56:")); + } +}