Merge pull request #5051 from AJ0070/fix/go2rtc-encoding

fix: percent-encode non-ASCII bytes in UriEncode and escape go2rtc JSON fields
This commit is contained in:
Isaac Connor authored and GitHub committed 2026-08-17 18:03:18 -04:00
commit c7bca2af3f
3 files changed
+115 -22

No files matched your search

+11 -7
View File
@@ -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<CURLcode, std::string> 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) {
+44 -15
View File
@@ -24,6 +24,7 @@
#include <array>
#include <cctype>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <fcntl.h> /* Definition of AT_* constants */
#include <unistd.h>
@@ -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<unsigned char>(*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<char>(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<char>(c);
}
break;
}
}
return output;
}
+60
View File
@@ -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") {