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()

Reply via email to