diff --git a/src/zm_monitor_go2rtc.cpp b/src/zm_monitor_go2rtc.cpp index 164ef535c..130dd5bde 100644 --- a/src/zm_monitor_go2rtc.cpp +++ b/src/zm_monitor_go2rtc.cpp @@ -69,8 +69,12 @@ Monitor::Go2RTCManager::Go2RTCManager(Monitor *parent_) rtsp_second_path = parent->GetSecondPath(); if (!parent->user.empty()) { - rtsp_username = escape_json_string(parent->user); - rtsp_password = escape_json_string(parent->pass); + // Stored raw. These end up in an RTSP URL, not in JSON, and the JSON + // payloads that embed the assembled path escape it themselves at the point + // it becomes JSON. Escaping here as well meant a password containing a + // backslash reached go2rtc doubled. + rtsp_username = parent->user; + rtsp_password = parent->pass; if (rtsp_path.find("rtsp://") == 0) { rtsp_path = "rtsp://" + rtsp_username + ":" + rtsp_password + "@" + rtsp_path.substr(7, std::string::npos); } else { @@ -227,7 +231,7 @@ int Monitor::Go2RTCManager::add_to_Go2RTC() { Debug(1, "Go2RTC: Adding primary stream (monitor ID) - path: %s%s", primary_path.c_str(), Use_RTSP_Restream ? " (via RTSP restreamer)" : ""); std::string endpoint = Go2RTC_endpoint + "/streams?name=" + id_str + "&src=" + UriEncode(primary_path); - std::string postData = "{\"name\" : \"" + std::string(parent->Name()) + "\", \"src\": \"" + primary_path + "\" }"; + std::string postData = "{\"name\" : \"" + escape_json_string(parent->Name()) + "\", \"src\": \"" + escape_json_string(primary_path) + "\" }"; Debug(2, "Go2RTC: PUT to %s with data: %s", endpoint.c_str(), postData.c_str()); std::pair response = CURL_PUT(endpoint, postData); if (response.first != CURLE_OK) { @@ -247,7 +251,7 @@ int Monitor::Go2RTCManager::add_to_Go2RTC() { std::string transcode_src = "ffmpeg:" + id_str + "#video=h264"; Debug(1, "Go2RTC: Adding H.264 transcode stream - src: %s", transcode_src.c_str()); endpoint = Go2RTC_endpoint + "/streams?name=" + id_str + "_h264&src=" + UriEncode(transcode_src); - postData = "{\"name\" : \"" + std::string(parent->Name()) + " H264\", \"src\": \"" + transcode_src + "\" }"; + postData = "{\"name\" : \"" + escape_json_string(parent->Name()) + " H264\", \"src\": \"" + escape_json_string(transcode_src) + "\" }"; Debug(2, "Go2RTC: PUT to %s", endpoint.c_str()); response = CURL_PUT(endpoint, postData); if (response.first == CURLE_OK) { @@ -259,7 +263,7 @@ int Monitor::Go2RTCManager::add_to_Go2RTC() { if (Use_RTSP_Restream) { Debug(1, "Go2RTC: Adding ZoneMinderPrimary stream - path: %s", rtsp_restream_path.c_str()); endpoint = Go2RTC_endpoint + "/streams?name=" + id_str + "_ZoneMinderPrimary&src=" + UriEncode(rtsp_restream_path); - postData = "{\"name\" : \"" + std::string(parent->Name()) + " ZoneMinder Primary\", \"src\": \"" + rtsp_restream_path + "\" }"; + postData = "{\"name\" : \"" + escape_json_string(parent->Name()) + " ZoneMinder Primary\", \"src\": \"" + escape_json_string(rtsp_restream_path) + "\" }"; Debug(2, "Go2RTC: PUT to %s", endpoint.c_str()); response = CURL_PUT(endpoint, postData); if (response.first == CURLE_OK) { @@ -271,7 +275,7 @@ int Monitor::Go2RTCManager::add_to_Go2RTC() { if (!rtsp_path.empty()) { Debug(1, "Go2RTC: Adding CameraDirectPrimary stream - path: %s", rtsp_path.c_str()); endpoint = Go2RTC_endpoint + "/streams?name=" + id_str + "_CameraDirectPrimary&src=" + UriEncode(rtsp_path); - postData = "{\"name\" : \"" + std::string(parent->Name()) + " Camera Direct Primary\", \"src\": \"" + rtsp_path + "\" }"; + postData = "{\"name\" : \"" + escape_json_string(parent->Name()) + " Camera Direct Primary\", \"src\": \"" + escape_json_string(rtsp_path) + "\" }"; Debug(2, "Go2RTC: PUT to %s", endpoint.c_str()); response = CURL_PUT(endpoint, postData); if (response.first == CURLE_OK) { @@ -282,7 +286,7 @@ int Monitor::Go2RTCManager::add_to_Go2RTC() { if (!rtsp_second_path.empty()) { Debug(1, "Go2RTC: Adding CameraDirectSecondary stream - path: %s", rtsp_second_path.c_str()); endpoint = Go2RTC_endpoint + "/streams?name=" + id_str + "_CameraDirectSecondary&src=" + UriEncode(rtsp_second_path); - postData = "{\"name\" : \"" + std::string(parent->Name()) + " Camera Direct Secondary\", \"src\": \"" + rtsp_second_path + "\" }"; + postData = "{\"name\" : \"" + escape_json_string(parent->Name()) + " Camera Direct Secondary\", \"src\": \"" + escape_json_string(rtsp_second_path) + "\" }"; Debug(2, "Go2RTC: PUT to %s", endpoint.c_str()); response = CURL_PUT(endpoint, postData); if (response.first == CURLE_OK) { diff --git a/src/zm_utils.cpp b/src/zm_utils.cpp index 5c0c78b10..b2160e21c 100644 --- a/src/zm_utils.cpp +++ b/src/zm_utils.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include /* Definition of AT_* constants */ #include @@ -403,15 +404,19 @@ std::string UriEncode(const std::string &value) { std::string retbuf; retbuf.reserve(value.length() * 3); // at most all characters get replaced with the escape - char tmp[5] = ""; + char tmp[4] = ""; while (*src) { - std::string::value_type c = *src; + // Must be unsigned: a signed char sign-extends bytes >= 0x80 when promoted + // for isalnum()/snprintf(), which mangles every non-ASCII UTF-8 byte. + const unsigned char c = static_cast(*src); if (c == ' ') { retbuf.append("%20"); - } else if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { - retbuf.push_back(c); + } else if ((c < 0x80) && (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~')) { + // The unreserved set is ASCII-only, so bytes >= 0x80 are always escaped + // rather than left to a locale-dependent isalnum(). + retbuf.push_back(static_cast(c)); } else { - snprintf(tmp, 4, "%%%02X", c); + snprintf(tmp, sizeof(tmp), "%%%02X", c); retbuf.append(tmp); } src++; @@ -545,14 +550,38 @@ std::string remove_newlines( std::string str ) { return str; } -std::string escape_json_string( std::string input ) { - std::string tmp; - tmp = regex_replace(input, std::regex("\n"), "\\n"); - tmp = regex_replace(tmp, std::regex("\b"), "\\b"); - tmp = regex_replace(tmp, std::regex("\f"), "\\f"); - tmp = regex_replace(tmp, std::regex("\r"), "\\r"); - tmp = regex_replace(tmp, std::regex("\t"), "\\t"); - tmp = regex_replace(tmp, std::regex("\""), "\\\""); - tmp = regex_replace(tmp, std::regex("[\\\\]"), "\\\\"); - return tmp; +std::string escape_json_string(std::string input) { + // One pass over the input, so each byte produces its escape exactly once. + // The previous implementation ran a chain of regex_replace calls with the + // backslash pass last, which re-escaped the backslashes the earlier passes + // had just introduced: a quote came out as \\" , an escaped backslash + // followed by a bare quote, which terminates the JSON string it is sitting + // in. It also left control characters other than \b \f \n \r \t unescaped, + // which RFC 8259 does not allow. + std::string output; + output.reserve(input.size()); + + for (unsigned char c : input) { + switch (c) { + case '"': output += "\\\""; break; + case '\\': output += "\\\\"; break; + case '\b': output += "\\b"; break; + case '\f': output += "\\f"; break; + case '\n': output += "\\n"; break; + case '\r': output += "\\r"; break; + case '\t': output += "\\t"; break; + default: + if (c < 0x20) { + // Control characters with no short form have to go out as \u00XX. + char buffer[7]; + snprintf(buffer, sizeof(buffer), "\\u%04x", c); + output += buffer; + } else { + // Bytes >= 0x80 are passed through so UTF-8 stays intact. + output += static_cast(c); + } + break; + } + } + return output; } diff --git a/tests/zm_utils.cpp b/tests/zm_utils.cpp index 397e50ef4..ba68406f8 100644 --- a/tests/zm_utils.cpp +++ b/tests/zm_utils.cpp @@ -207,6 +207,66 @@ TEST_CASE("UriEncode") { // Round-trip test: encode then decode should return original std::string original = "hello world!"; REQUIRE(UriDecode(UriEncode(original)) == original); + + // Non-ASCII bytes must each be escaped as a single %XX pair. A signed char + // sign-extends here and yields "%FF" (truncated from "FFFFFFD0") instead. + REQUIRE(UriEncode("\xD0\xB2") == "%D0%B2"); + REQUIRE(UriEncode("\xFF") == "%FF"); + REQUIRE(UriEncode("\x80") == "%80"); + + // A Cyrillic name (U+0432 U+0445 U+043E U+0434) - the kind of name the + // utf8mb4 switch made storable. Written as bytes so this file stays ASCII. + REQUIRE(UriEncode("\xD0\xB2\xD1\x85\xD0\xBE\xD0\xB4") == "%D0%B2%D1%85%D0%BE%D0%B4"); + + // Mixed ASCII and UTF-8, and a multi-byte round trip. + REQUIRE(UriEncode("Cam \xC3\xA9") == "Cam%20%C3%A9"); + REQUIRE(UriDecode(UriEncode("\xD0\xB2\xD1\x85")) == "\xD0\xB2\xD1\x85"); +} + +TEST_CASE("escape_json_string") { + // Nothing to do. + REQUIRE(escape_json_string("") == ""); + REQUIRE(escape_json_string("Front Door") == "Front Door"); + + // A quote must come out as one backslash then a quote. The previous + // implementation escaped quotes before backslashes, so it emitted \\" here: + // an escaped backslash followed by a bare quote, which closed the JSON + // string it was embedded in. + REQUIRE(escape_json_string("\"") == "\\\""); + REQUIRE(escape_json_string("Front \"Door\"") == "Front \\\"Door\\\""); + + // A backslash doubles, and that doubling must not itself be re-doubled. + REQUIRE(escape_json_string("\\") == "\\\\"); + REQUIRE(escape_json_string("back\\slash") == "back\\\\slash"); + + // A backslash adjacent to a quote is where ordering errors surface. + REQUIRE(escape_json_string("\\\"") == "\\\\\\\""); + + // Named control character escapes. + REQUIRE(escape_json_string("\n") == "\\n"); + REQUIRE(escape_json_string("\r") == "\\r"); + REQUIRE(escape_json_string("\t") == "\\t"); + REQUIRE(escape_json_string("\b") == "\\b"); + REQUIRE(escape_json_string("\f") == "\\f"); + REQUIRE(escape_json_string("line\nbreak") == "line\\nbreak"); + + // Control characters without a short form must still be escaped. + REQUIRE(escape_json_string(std::string(1, '\a')) == "\\u0007"); + REQUIRE(escape_json_string(std::string(1, '\x01')) == "\\u0001"); + REQUIRE(escape_json_string(std::string(1, '\x1f')) == "\\u001f"); + + // An embedded NUL is a control character like any other and must survive. + REQUIRE(escape_json_string(std::string("a\0b", 3)) == "a\\u0000b"); + + // 0x7f is not a JSON control character, and UTF-8 passes through intact. + REQUIRE(escape_json_string(std::string(1, '\x7f')) == std::string(1, '\x7f')); + REQUIRE(escape_json_string("\xD0\xB2\xD1\x85") == "\xD0\xB2\xD1\x85"); + + // Escaping twice is not the same as escaping once. This is the double + // escape the go2rtc credential path was hitting. + const std::string once = escape_json_string("pa\\ss"); + REQUIRE(once == "pa\\\\ss"); + REQUIRE(escape_json_string(once) == "pa\\\\\\\\ss"); } TEST_CASE("QueryString") {