This is an automated email from the ASF dual-hosted git repository.
bneradt pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git
The following commit(s) were added to refs/heads/master by this push:
new 61593411e3 xdebug: probe-full-json fix single quote escapes (#12742)
61593411e3 is described below
commit 61593411e354271c0fa9cd14889dcccb7b875169
Author: Brian Neradt <[email protected]>
AuthorDate: Mon Dec 29 12:49:23 2025 -0600
xdebug: probe-full-json fix single quote escapes (#12742)
The xdebug plugin was incorrectly escaping single quotes as \' in
probe-full-json output, which is not a valid JSON escape sequence and
broke JSON parsing for headers like Content-Security-Policy that contain
CSP directives with single quotes (e.g., 'self', 'unsafe-inline'). This
fix modifies the EscapeCharForJson class to only escape single quotes in
legacy probe format (which uses single-quoted strings) while leaving
them unescaped in full JSON mode, producing RFC 8259-compliant JSON
output that can be piped directly to tools like jq.
---
plugins/xdebug/CMakeLists.txt | 4 +-
plugins/xdebug/unit_tests/test_xdebug_utils.cc | 130 +++++++++++++++++++++
plugins/xdebug/xdebug_escape.cc | 102 ++++++++++++++++
plugins/xdebug/xdebug_escape.h | 78 +++++++++++++
plugins/xdebug/xdebug_headers.cc | 103 ----------------
plugins/xdebug/xdebug_headers.h | 6 +-
.../xdebug/x_probe_full_json/gold/jq_hex.gold | 2 +-
.../x_probe_full_json.replay.yaml | 43 ++++++-
.../x_probe_full_json/x_probe_full_json.test.py | 19 +++
9 files changed, 374 insertions(+), 113 deletions(-)
diff --git a/plugins/xdebug/CMakeLists.txt b/plugins/xdebug/CMakeLists.txt
index 61632a60ec..716583536f 100644
--- a/plugins/xdebug/CMakeLists.txt
+++ b/plugins/xdebug/CMakeLists.txt
@@ -15,12 +15,12 @@
#
#######################
-add_atsplugin(xdebug xdebug.cc xdebug_headers.cc xdebug_transforms.cc
xdebug_utils.cc)
+add_atsplugin(xdebug xdebug.cc xdebug_headers.cc xdebug_transforms.cc
xdebug_utils.cc xdebug_escape.cc)
target_link_libraries(xdebug PRIVATE libswoc::libswoc)
verify_global_plugin(xdebug)
if(BUILD_TESTING)
- add_executable(test_xdebug unit_tests/test_xdebug_utils.cc xdebug_utils.cc)
+ add_executable(test_xdebug unit_tests/test_xdebug_utils.cc xdebug_utils.cc
xdebug_escape.cc)
target_include_directories(test_xdebug PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
target_link_libraries(test_xdebug PRIVATE Catch2::Catch2WithMain
libswoc::libswoc)
add_catch2_test(NAME test_xdebug COMMAND test_xdebug)
diff --git a/plugins/xdebug/unit_tests/test_xdebug_utils.cc
b/plugins/xdebug/unit_tests/test_xdebug_utils.cc
index 182ba2a6f2..02fb56a075 100644
--- a/plugins/xdebug/unit_tests/test_xdebug_utils.cc
+++ b/plugins/xdebug/unit_tests/test_xdebug_utils.cc
@@ -21,7 +21,12 @@
#include "xdebug_utils.h"
#include "xdebug_types.h"
+#include "xdebug_escape.h"
#include <catch2/catch_test_macros.hpp>
+#include <catch2/generators/catch_generators.hpp>
+#include <catch2/generators/catch_generators_range.hpp>
+#include <sstream>
+#include <vector>
TEST_CASE("xdebug::parse_probe_full_json_field_value basic functionality",
"[xdebug][utils]")
{
@@ -193,3 +198,128 @@ TEST_CASE("xdebug::is_textual_content_type
functionality", "[xdebug][utils]")
REQUIRE(xdebug::is_textual_content_type("has-xml-in-name"));
}
}
+
+// Helper function to process a string through EscapeCharForJson.
+static std::string
+escape_string(std::string_view input, bool full_json)
+{
+ xdebug::EscapeCharForJson escaper(full_json);
+ std::stringstream ss;
+ for (char c : input) {
+ ss << escaper(c);
+ }
+ return ss.str();
+}
+
+struct EscapeTestCase {
+ std::string description;
+ bool full_json;
+ std::string input;
+ std::string expected;
+};
+
+TEST_CASE("xdebug::EscapeCharForJson escaping", "[xdebug][headers]")
+{
+ // clang-format off
+ static std::vector<EscapeTestCase> const tests = {
+ // Single quotes are NOT escaped in either mode.
+ {"full JSON: single quotes are not escaped",
+ xdebug::FULL_JSON,
+ R"('self')",
+ R"('self')"},
+
+ {"full JSON: CSP header with multiple single-quoted directives",
+ xdebug::FULL_JSON,
+ R"(child-src blob: 'self'; connect-src 'self' 'unsafe-inline')",
+ R"(child-src blob: 'self'; connect-src 'self' 'unsafe-inline')"},
+
+ {"legacy: single quotes are not escaped",
+ !xdebug::FULL_JSON,
+ R"('self')",
+ R"('self')"},
+
+ {"legacy: CSP header with multiple single-quoted directives",
+ !xdebug::FULL_JSON,
+ R"(child-src blob: 'self'; connect-src 'self' 'unsafe-inline')",
+ R"(child-src blob: 'self'; connect-src 'self' 'unsafe-inline')"},
+
+ // Common escapes work the same in both modes.
+ {"full JSON: double quotes are escaped",
+ xdebug::FULL_JSON,
+ R"(say "hello")",
+ R"(say \"hello\")"},
+
+ {"legacy: double quotes are escaped",
+ !xdebug::FULL_JSON,
+ R"(say "hello")",
+ R"(say \"hello\")"},
+
+ {"full JSON: backslashes are escaped",
+ xdebug::FULL_JSON,
+ R"(path\to\file)",
+ R"(path\\to\\file)"},
+
+ {"legacy: backslashes are escaped",
+ !xdebug::FULL_JSON,
+ R"(path\to\file)",
+ R"(path\\to\\file)"},
+
+ {"full JSON: tab characters are escaped",
+ xdebug::FULL_JSON,
+ "line1\tline2",
+ R"(line1\tline2)"},
+
+ {"full JSON: backspace characters are escaped",
+ xdebug::FULL_JSON,
+ "a\bb",
+ R"(a\bb)"},
+
+ {"full JSON: form feed characters are escaped",
+ xdebug::FULL_JSON,
+ "a\fb",
+ R"(a\fb)"},
+
+ {"full JSON: plain text passes through unchanged",
+ xdebug::FULL_JSON,
+ R"(hello world)",
+ R"(hello world)"},
+
+ {"legacy: plain text passes through unchanged",
+ !xdebug::FULL_JSON,
+ R"(hello world)",
+ R"(hello world)"},
+ };
+ // clang-format on
+
+ auto const &test = GENERATE_REF(from_range(tests));
+ CAPTURE(test.description, test.full_json, test.input, test.expected);
+
+ std::string result = escape_string(test.input, test.full_json);
+ REQUIRE(result == test.expected);
+}
+
+TEST_CASE("xdebug::EscapeCharForJson backup calculation", "[xdebug][headers]")
+{
+ struct BackupTestCase {
+ std::string description;
+ bool full_json;
+ std::size_t expected_backup;
+ };
+
+ // clang-format off
+ static std::vector<BackupTestCase> const tests = {
+ {R"(full JSON uses "," separator (backup = 2))",
+ xdebug::FULL_JSON,
+ 2},
+
+ {R"(legacy uses "',\n\t'" separator (backup = 4))",
+ !xdebug::FULL_JSON,
+ 4},
+ };
+ // clang-format on
+
+ auto const &test = GENERATE_REF(from_range(tests));
+ CAPTURE(test.description, test.full_json, test.expected_backup);
+
+ REQUIRE(xdebug::EscapeCharForJson::backup(test.full_json) ==
test.expected_backup);
+}
diff --git a/plugins/xdebug/xdebug_escape.cc b/plugins/xdebug/xdebug_escape.cc
new file mode 100644
index 0000000000..4e9dcc2059
--- /dev/null
+++ b/plugins/xdebug/xdebug_escape.cc
@@ -0,0 +1,102 @@
+/** @file
+ *
+ * XDebug plugin JSON escaping implementation.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "xdebug_escape.h"
+
+namespace xdebug
+{
+
+std::string_view
+EscapeCharForJson::operator()(char const &c)
+{
+ if ((_state != IN_VALUE) && ((' ' == c) || ('\t' == c))) {
+ return {""};
+ }
+ if ((IN_NAME == _state) && (':' == c)) {
+ _state = BEFORE_VALUE;
+ if (_full_json) {
+ return {R"(":")"};
+ } else {
+ return {"' : '"};
+ }
+ }
+ if ('\r' == c) {
+ return {""};
+ }
+ if ('\n' == c) {
+ std::string_view result{_after_value(_full_json)};
+
+ if (BEFORE_NAME == _state) {
+ return {""};
+ } else if (BEFORE_VALUE == _state) {
+ result = _handle_empty_value(_full_json);
+ }
+ _state = BEFORE_NAME;
+ return result;
+ }
+ if (BEFORE_NAME == _state) {
+ _state = IN_NAME;
+ } else if (BEFORE_VALUE == _state) {
+ _state = IN_VALUE;
+ }
+ switch (c) {
+ case '"':
+ return {"\\\""};
+ case '\\':
+ return {"\\\\"};
+ case '\b':
+ return {"\\b"};
+ case '\f':
+ return {"\\f"};
+ case '\t':
+ return {"\\t"};
+ default:
+ return {&c, 1};
+ }
+}
+
+std::size_t
+EscapeCharForJson::backup(bool full_json)
+{
+ return _after_value(full_json).size() - 1;
+}
+
+std::string_view
+EscapeCharForJson::_after_value(bool full_json)
+{
+ if (full_json) {
+ return {R"(",")"};
+ } else {
+ return {"',\n\t'"};
+ }
+}
+
+std::string_view
+EscapeCharForJson::_handle_empty_value(bool full_json)
+{
+ if (full_json) {
+ return {R"(",")"};
+ } else {
+ return {"',\n\t'"};
+ }
+}
+
+} // namespace xdebug
diff --git a/plugins/xdebug/xdebug_escape.h b/plugins/xdebug/xdebug_escape.h
new file mode 100644
index 0000000000..ab5dbb20ab
--- /dev/null
+++ b/plugins/xdebug/xdebug_escape.h
@@ -0,0 +1,78 @@
+/** @file
+ *
+ * XDebug plugin JSON escaping functionality.
+ *
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include <string_view>
+
+namespace xdebug
+{
+
+/**
+ * Whether to print the headers for the "probe-full-json" format.
+ */
+static constexpr bool FULL_JSON = true;
+
+/** Functor to escape characters for JSON or legacy probe output.
+ *
+ * This class is used to process HTTP header content character by character,
+ * handling the state transitions between header name and value, and escaping
+ * characters appropriately for the output format.
+ *
+ */
+class EscapeCharForJson
+{
+public:
+ /** Construct an EscapeCharForJson functor.
+ *
+ * @param full_json If true, produce valid JSON output. If false, produce
+ * the legacy probe format which uses single-quoted strings.
+ */
+ EscapeCharForJson(bool full_json) : _full_json(full_json) {}
+
+ /** Process a single character and return the escaped output.
+ *
+ * @param c The character to process.
+ * @return The escaped string view for this character.
+ */
+ std::string_view operator()(char const &c);
+
+ /** Get the number of characters to back up after processing all headers.
+ *
+ * After the last header line, the output will have a trailing separator
+ * that needs to be removed. This returns how many characters to back up.
+ *
+ * @param full_json Whether full JSON format is being used.
+ * @return The number of characters to back up.
+ */
+ static std::size_t backup(bool full_json);
+
+private:
+ static std::string_view _after_value(bool full_json);
+ static std::string_view _handle_empty_value(bool full_json);
+
+ enum _State { BEFORE_NAME, IN_NAME, BEFORE_VALUE, IN_VALUE };
+
+ _State _state{BEFORE_VALUE};
+ bool _full_json = false;
+};
+
+} // namespace xdebug
diff --git a/plugins/xdebug/xdebug_headers.cc b/plugins/xdebug/xdebug_headers.cc
index 92bfdcf15e..4b0db528dc 100644
--- a/plugins/xdebug/xdebug_headers.cc
+++ b/plugins/xdebug/xdebug_headers.cc
@@ -38,109 +38,6 @@ namespace
DbgCtl dbg_ctl_hdrs{DEBUG_TAG_LOG_HEADERS};
}
-class EscapeCharForJson
-{
-public:
- EscapeCharForJson(bool full_json) : _full_json(full_json) {}
-
- std::string_view
- operator()(char const &c)
- {
- if ((_state != IN_VALUE) && ((' ' == c) || ('\t' == c))) {
- return {""};
- }
- if ((IN_NAME == _state) && (':' == c)) {
- _state = BEFORE_VALUE;
- if (_full_json) {
- return {R"(":")"};
- } else {
- return {"' : '"};
- }
- }
- if ('\r' == c) {
- return {""};
- }
- if ('\n' == c) {
- std::string_view result{_after_value(_full_json)};
-
- if (BEFORE_NAME == _state) {
- return {""};
- } else if (BEFORE_VALUE == _state) {
- // The header field has no value.
- result = _handle_empty_value(_full_json);
- }
- _state = BEFORE_NAME;
- return result;
- }
- if (BEFORE_NAME == _state) {
- _state = IN_NAME;
- } else if (BEFORE_VALUE == _state) {
- _state = IN_VALUE;
- }
- switch (c) {
- case '\'':
- return {"\\\'"};
- case '"':
- return {"\\\""};
- case '\\':
- return {"\\\\"};
- case '\b':
- return {"\\b"};
- case '\f':
- return {"\\f"};
- case '\t':
- return {"\\t"};
- default:
- return {&c, 1};
- }
- }
-
- // After last header line, back up and throw away everything but the closing
quote.
- //
- static std::size_t
- backup(bool full_json)
- {
- return _after_value(full_json).size() - 1;
- }
-
-private:
- /** The separator content between fields. */
- static std::string_view
- _after_value(bool full_json)
- {
- if (full_json) {
- return {R"(",")"};
- } else {
- return {"',\n\t'"};
- }
- }
-
- /** The separator content when there is an empty value.
- */
- static std::string_view
- _handle_empty_value(bool full_json)
- {
- if (full_json) {
- return {R"(",")"};
- } else {
- return {"',\n\t'"};
- }
- }
-
-private:
- enum _State { BEFORE_NAME, IN_NAME, BEFORE_VALUE, IN_VALUE };
-
- /// Initial state is BEFORE_VALUE to parse the start line.
- _State _state{BEFORE_VALUE};
-
- /** Whether to print the headers for x-probe-full-json.
- *
- * The legacy "probe" format is not JSON-compliant. The new "probe-full-json"
- * format is JSON-compliant.
- */
- bool _full_json = false;
-};
-
///////////////////////////////////////////////////////////////////////////
// Dump a header on stderr, useful together with Dbg().
void
diff --git a/plugins/xdebug/xdebug_headers.h b/plugins/xdebug/xdebug_headers.h
index 0276b5ff4b..e57d10927b 100644
--- a/plugins/xdebug/xdebug_headers.h
+++ b/plugins/xdebug/xdebug_headers.h
@@ -23,15 +23,11 @@
#include <sstream>
#include "ts/ts.h"
+#include "xdebug_escape.h"
namespace xdebug
{
-/**
- * Whether to print the headers for the "probe-full-json" format.
- */
-static constexpr bool FULL_JSON = true;
-
/**
* Print headers to a stringstream with JSON-like formatting.
* @param bufp The TSMBuffer containing the headers.
diff --git
a/tests/gold_tests/pluginTest/xdebug/x_probe_full_json/gold/jq_hex.gold
b/tests/gold_tests/pluginTest/xdebug/x_probe_full_json/gold/jq_hex.gold
index 1740eda1a5..160e2397f3 100644
--- a/tests/gold_tests/pluginTest/xdebug/x_probe_full_json/gold/jq_hex.gold
+++ b/tests/gold_tests/pluginTest/xdebug/x_probe_full_json/gold/jq_hex.gold
@@ -1,3 +1,3 @@
"2"
-"42696e617279206461746120776974682071756f746573210000"
+"2242696e617279206461746120776974682071756f7465732122"
"binary-data"
diff --git
a/tests/gold_tests/pluginTest/xdebug/x_probe_full_json/x_probe_full_json.replay.yaml
b/tests/gold_tests/pluginTest/xdebug/x_probe_full_json/x_probe_full_json.replay.yaml
index cb86b789c4..846dbd7457 100644
---
a/tests/gold_tests/pluginTest/xdebug/x_probe_full_json/x_probe_full_json.replay.yaml
+++
b/tests/gold_tests/pluginTest/xdebug/x_probe_full_json/x_probe_full_json.replay.yaml
@@ -117,7 +117,7 @@ sessions:
- [ X-Response, "binary-data" ]
content:
encoding: plain
- data: "Binary data with quotes!"
+ data: '"Binary data with quotes!"'
proxy-response:
status: 200
@@ -128,5 +128,44 @@ sessions:
- [ X-Original-Content-Type, { value: "application/octet-stream", as:
equal } ]
content:
# Expect hex encoding: "Binary data with quotes!" -> hex
- data: '42696e617279206461746120776974682071756f74657321'
+ data: '"2242696e617279206461746120776974682071756f7465732122",'
+ verify: { as: contains }
+
+ # Test 3: CSP header with single quotes (should NOT be escaped in JSON)
+ - client-request:
+ method: GET
+ url: /csp-test
+ version: '1.1'
+ headers:
+ fields:
+ - [ uuid, '3' ]
+ - [ Host, example.com ]
+ - [ X-Debug, probe-full-json ]
+
+ proxy-request:
+ headers:
+ fields:
+ - [ x-debug, { as: absent } ]
+
+ server-response:
+ status: 200
+ headers:
+ fields:
+ - [ Content-Type, "text/html" ]
+ - [ Content-Security-Policy, "default-src 'self'; script-src 'self'
'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'" ]
+ - [ X-Response, "csp-test" ]
+ content:
+ encoding: plain
+ data: "<html><body>CSP Test</body></html>"
+
+ proxy-response:
+ status: 200
+ headers:
+ fields:
+ - [ Content-Type, { value: "application/json", as: equal } ]
+ - [ X-Original-Content-Type, { value: "text/html", as: equal } ]
+ content:
+ # Single quotes in CSP should NOT be escaped as \' (invalid JSON)
+ # They should appear as plain single quotes (valid JSON)
+ data: "default-src 'self'; script-src 'self' 'unsafe-inline'
'unsafe-eval'"
verify: { as: contains }
diff --git
a/tests/gold_tests/pluginTest/xdebug/x_probe_full_json/x_probe_full_json.test.py
b/tests/gold_tests/pluginTest/xdebug/x_probe_full_json/x_probe_full_json.test.py
index 505f07b1a0..4edac9c3df 100644
---
a/tests/gold_tests/pluginTest/xdebug/x_probe_full_json/x_probe_full_json.test.py
+++
b/tests/gold_tests/pluginTest/xdebug/x_probe_full_json/x_probe_full_json.test.py
@@ -83,6 +83,10 @@ class XDebugProbeFullJsonTest:
tr.Processes.Default.Streams.stdout += Testers.ContainsExpression(
'X-Original-Content-Type: application/octet-stream',
"X-Original-Content-Type of application/octet-stream should be
present")
+ # CSP test should produce valid JSON (single quotes not escaped)
+ tr.Processes.Default.Streams.stdout += Testers.ContainsExpression(
+ "default-src 'self'; script-src 'self' 'unsafe-inline'",
+ "CSP header with single quotes should be present in valid JSON")
def _setupJqValidation(self) -> None:
"""Use curl to get the response body and pipe through jq to validate
JSON.
@@ -138,6 +142,21 @@ class XDebugProbeFullJsonTest:
tr.Processes.Default.ReturnCode = 0
tr.Processes.Default.Streams.stdout += "gold/jq_nobody.gold"
+ tr = Test.AddTestRun("CSP header with single quotes (valid JSON)")
+ self._startServersIfNeeded(tr)
+ tr.MakeCurlCommand(
+ f'-s -H"uuid: 3" -H "Host: example.com" -H "X-Debug:
probe-full-json" '
+ f'http://127.0.0.1:{self._ts.Variables.port}/csp-test | '
+ "jq '.\"server-response\".\"content-security-policy\"'",
+ ts=self._ts)
+ tr.Processes.Default.ReturnCode = 0
+ # Verify jq can parse it (would fail if single quotes were escaped as
\')
+ # and that the CSP header contains single quotes
+ tr.Processes.Default.Streams.stdout += Testers.ContainsExpression(
+ "'self'", "CSP header should contain single quotes (not escaped)")
+ tr.Processes.Default.Streams.stdout += Testers.ContainsExpression(
+ "'unsafe-inline'", "CSP header should contain 'unsafe-inline'
directive")
+
# Execute the test
XDebugProbeFullJsonTest()