Skip to content

Commit 6159341

Browse files
authored
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.
1 parent f2e959f commit 6159341

File tree

9 files changed

+374
-113
lines changed

9 files changed

+374
-113
lines changed

plugins/xdebug/CMakeLists.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@
1515
#
1616
#######################
1717

18-
add_atsplugin(xdebug xdebug.cc xdebug_headers.cc xdebug_transforms.cc xdebug_utils.cc)
18+
add_atsplugin(xdebug xdebug.cc xdebug_headers.cc xdebug_transforms.cc xdebug_utils.cc xdebug_escape.cc)
1919
target_link_libraries(xdebug PRIVATE libswoc::libswoc)
2020
verify_global_plugin(xdebug)
2121

2222
if(BUILD_TESTING)
23-
add_executable(test_xdebug unit_tests/test_xdebug_utils.cc xdebug_utils.cc)
23+
add_executable(test_xdebug unit_tests/test_xdebug_utils.cc xdebug_utils.cc xdebug_escape.cc)
2424
target_include_directories(test_xdebug PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
2525
target_link_libraries(test_xdebug PRIVATE Catch2::Catch2WithMain libswoc::libswoc)
2626
add_catch2_test(NAME test_xdebug COMMAND test_xdebug)

plugins/xdebug/unit_tests/test_xdebug_utils.cc

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@
2121

2222
#include "xdebug_utils.h"
2323
#include "xdebug_types.h"
24+
#include "xdebug_escape.h"
2425
#include <catch2/catch_test_macros.hpp>
26+
#include <catch2/generators/catch_generators.hpp>
27+
#include <catch2/generators/catch_generators_range.hpp>
28+
#include <sstream>
29+
#include <vector>
2530

2631
TEST_CASE("xdebug::parse_probe_full_json_field_value basic functionality", "[xdebug][utils]")
2732
{
@@ -193,3 +198,128 @@ TEST_CASE("xdebug::is_textual_content_type functionality", "[xdebug][utils]")
193198
REQUIRE(xdebug::is_textual_content_type("has-xml-in-name"));
194199
}
195200
}
201+
202+
// Helper function to process a string through EscapeCharForJson.
203+
static std::string
204+
escape_string(std::string_view input, bool full_json)
205+
{
206+
xdebug::EscapeCharForJson escaper(full_json);
207+
std::stringstream ss;
208+
for (char c : input) {
209+
ss << escaper(c);
210+
}
211+
return ss.str();
212+
}
213+
214+
struct EscapeTestCase {
215+
std::string description;
216+
bool full_json;
217+
std::string input;
218+
std::string expected;
219+
};
220+
221+
TEST_CASE("xdebug::EscapeCharForJson escaping", "[xdebug][headers]")
222+
{
223+
// clang-format off
224+
static std::vector<EscapeTestCase> const tests = {
225+
// Single quotes are NOT escaped in either mode.
226+
{"full JSON: single quotes are not escaped",
227+
xdebug::FULL_JSON,
228+
R"('self')",
229+
R"('self')"},
230+
231+
{"full JSON: CSP header with multiple single-quoted directives",
232+
xdebug::FULL_JSON,
233+
R"(child-src blob: 'self'; connect-src 'self' 'unsafe-inline')",
234+
R"(child-src blob: 'self'; connect-src 'self' 'unsafe-inline')"},
235+
236+
{"legacy: single quotes are not escaped",
237+
!xdebug::FULL_JSON,
238+
R"('self')",
239+
R"('self')"},
240+
241+
{"legacy: CSP header with multiple single-quoted directives",
242+
!xdebug::FULL_JSON,
243+
R"(child-src blob: 'self'; connect-src 'self' 'unsafe-inline')",
244+
R"(child-src blob: 'self'; connect-src 'self' 'unsafe-inline')"},
245+
246+
// Common escapes work the same in both modes.
247+
{"full JSON: double quotes are escaped",
248+
xdebug::FULL_JSON,
249+
R"(say "hello")",
250+
R"(say \"hello\")"},
251+
252+
{"legacy: double quotes are escaped",
253+
!xdebug::FULL_JSON,
254+
R"(say "hello")",
255+
R"(say \"hello\")"},
256+
257+
{"full JSON: backslashes are escaped",
258+
xdebug::FULL_JSON,
259+
R"(path\to\file)",
260+
R"(path\\to\\file)"},
261+
262+
{"legacy: backslashes are escaped",
263+
!xdebug::FULL_JSON,
264+
R"(path\to\file)",
265+
R"(path\\to\\file)"},
266+
267+
{"full JSON: tab characters are escaped",
268+
xdebug::FULL_JSON,
269+
"line1\tline2",
270+
R"(line1\tline2)"},
271+
272+
{"full JSON: backspace characters are escaped",
273+
xdebug::FULL_JSON,
274+
"a\bb",
275+
R"(a\bb)"},
276+
277+
{"full JSON: form feed characters are escaped",
278+
xdebug::FULL_JSON,
279+
"a\fb",
280+
R"(a\fb)"},
281+
282+
{"full JSON: plain text passes through unchanged",
283+
xdebug::FULL_JSON,
284+
R"(hello world)",
285+
R"(hello world)"},
286+
287+
{"legacy: plain text passes through unchanged",
288+
!xdebug::FULL_JSON,
289+
R"(hello world)",
290+
R"(hello world)"},
291+
};
292+
// clang-format on
293+
294+
auto const &test = GENERATE_REF(from_range(tests));
295+
CAPTURE(test.description, test.full_json, test.input, test.expected);
296+
297+
std::string result = escape_string(test.input, test.full_json);
298+
REQUIRE(result == test.expected);
299+
}
300+
301+
TEST_CASE("xdebug::EscapeCharForJson backup calculation", "[xdebug][headers]")
302+
{
303+
struct BackupTestCase {
304+
std::string description;
305+
bool full_json;
306+
std::size_t expected_backup;
307+
};
308+
309+
// clang-format off
310+
static std::vector<BackupTestCase> const tests = {
311+
{R"(full JSON uses "," separator (backup = 2))",
312+
xdebug::FULL_JSON,
313+
2},
314+
315+
{R"(legacy uses "',\n\t'" separator (backup = 4))",
316+
!xdebug::FULL_JSON,
317+
4},
318+
};
319+
// clang-format on
320+
321+
auto const &test = GENERATE_REF(from_range(tests));
322+
CAPTURE(test.description, test.full_json, test.expected_backup);
323+
324+
REQUIRE(xdebug::EscapeCharForJson::backup(test.full_json) == test.expected_backup);
325+
}

plugins/xdebug/xdebug_escape.cc

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/** @file
2+
*
3+
* XDebug plugin JSON escaping implementation.
4+
*
5+
* Licensed to the Apache Software Foundation (ASF) under one
6+
* or more contributor license agreements. See the NOTICE file
7+
* distributed with this work for additional information
8+
* regarding copyright ownership. The ASF licenses this file
9+
* to you under the Apache License, Version 2.0 (the
10+
* "License"); you may not use this file except in compliance
11+
* with the License. You may obtain a copy of the License at
12+
*
13+
* http://www.apache.org/licenses/LICENSE-2.0
14+
*
15+
* Unless required by applicable law or agreed to in writing, software
16+
* distributed under the License is distributed on an "AS IS" BASIS,
17+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* See the License for the specific language governing permissions and
19+
* limitations under the License.
20+
*/
21+
22+
#include "xdebug_escape.h"
23+
24+
namespace xdebug
25+
{
26+
27+
std::string_view
28+
EscapeCharForJson::operator()(char const &c)
29+
{
30+
if ((_state != IN_VALUE) && ((' ' == c) || ('\t' == c))) {
31+
return {""};
32+
}
33+
if ((IN_NAME == _state) && (':' == c)) {
34+
_state = BEFORE_VALUE;
35+
if (_full_json) {
36+
return {R"(":")"};
37+
} else {
38+
return {"' : '"};
39+
}
40+
}
41+
if ('\r' == c) {
42+
return {""};
43+
}
44+
if ('\n' == c) {
45+
std::string_view result{_after_value(_full_json)};
46+
47+
if (BEFORE_NAME == _state) {
48+
return {""};
49+
} else if (BEFORE_VALUE == _state) {
50+
result = _handle_empty_value(_full_json);
51+
}
52+
_state = BEFORE_NAME;
53+
return result;
54+
}
55+
if (BEFORE_NAME == _state) {
56+
_state = IN_NAME;
57+
} else if (BEFORE_VALUE == _state) {
58+
_state = IN_VALUE;
59+
}
60+
switch (c) {
61+
case '"':
62+
return {"\\\""};
63+
case '\\':
64+
return {"\\\\"};
65+
case '\b':
66+
return {"\\b"};
67+
case '\f':
68+
return {"\\f"};
69+
case '\t':
70+
return {"\\t"};
71+
default:
72+
return {&c, 1};
73+
}
74+
}
75+
76+
std::size_t
77+
EscapeCharForJson::backup(bool full_json)
78+
{
79+
return _after_value(full_json).size() - 1;
80+
}
81+
82+
std::string_view
83+
EscapeCharForJson::_after_value(bool full_json)
84+
{
85+
if (full_json) {
86+
return {R"(",")"};
87+
} else {
88+
return {"',\n\t'"};
89+
}
90+
}
91+
92+
std::string_view
93+
EscapeCharForJson::_handle_empty_value(bool full_json)
94+
{
95+
if (full_json) {
96+
return {R"(",")"};
97+
} else {
98+
return {"',\n\t'"};
99+
}
100+
}
101+
102+
} // namespace xdebug

plugins/xdebug/xdebug_escape.h

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/** @file
2+
*
3+
* XDebug plugin JSON escaping functionality.
4+
*
5+
* Licensed to the Apache Software Foundation (ASF) under one
6+
* or more contributor license agreements. See the NOTICE file
7+
* distributed with this work for additional information
8+
* regarding copyright ownership. The ASF licenses this file
9+
* to you under the Apache License, Version 2.0 (the
10+
* "License"); you may not use this file except in compliance
11+
* with the License. You may obtain a copy of the License at
12+
*
13+
* http://www.apache.org/licenses/LICENSE-2.0
14+
*
15+
* Unless required by applicable law or agreed to in writing, software
16+
* distributed under the License is distributed on an "AS IS" BASIS,
17+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* See the License for the specific language governing permissions and
19+
* limitations under the License.
20+
*/
21+
22+
#pragma once
23+
24+
#include <string_view>
25+
26+
namespace xdebug
27+
{
28+
29+
/**
30+
* Whether to print the headers for the "probe-full-json" format.
31+
*/
32+
static constexpr bool FULL_JSON = true;
33+
34+
/** Functor to escape characters for JSON or legacy probe output.
35+
*
36+
* This class is used to process HTTP header content character by character,
37+
* handling the state transitions between header name and value, and escaping
38+
* characters appropriately for the output format.
39+
*
40+
*/
41+
class EscapeCharForJson
42+
{
43+
public:
44+
/** Construct an EscapeCharForJson functor.
45+
*
46+
* @param full_json If true, produce valid JSON output. If false, produce
47+
* the legacy probe format which uses single-quoted strings.
48+
*/
49+
EscapeCharForJson(bool full_json) : _full_json(full_json) {}
50+
51+
/** Process a single character and return the escaped output.
52+
*
53+
* @param c The character to process.
54+
* @return The escaped string view for this character.
55+
*/
56+
std::string_view operator()(char const &c);
57+
58+
/** Get the number of characters to back up after processing all headers.
59+
*
60+
* After the last header line, the output will have a trailing separator
61+
* that needs to be removed. This returns how many characters to back up.
62+
*
63+
* @param full_json Whether full JSON format is being used.
64+
* @return The number of characters to back up.
65+
*/
66+
static std::size_t backup(bool full_json);
67+
68+
private:
69+
static std::string_view _after_value(bool full_json);
70+
static std::string_view _handle_empty_value(bool full_json);
71+
72+
enum _State { BEFORE_NAME, IN_NAME, BEFORE_VALUE, IN_VALUE };
73+
74+
_State _state{BEFORE_VALUE};
75+
bool _full_json = false;
76+
};
77+
78+
} // namespace xdebug

0 commit comments

Comments
 (0)