From 0fc4405022de3e80e8977a00a1a30151ee2e6228 Mon Sep 17 00:00:00 2001 From: bneradt Date: Mon, 20 Jul 2026 10:39:22 -0500 Subject: [PATCH] Add HTML string escape and unescape APIs ATS does not provide reusable HTML escaping helpers to core components or plugins. This adds paired escape and unescape helpers, exposes them through the plugin API, and covers both directions with unit and replay tests. Escaping follows the WHATWG fragment serialization algorithm. Fixes: #13331 --- .../api/functions/TSStringHtmlEscape.en.rst | 81 ++++++ include/ts/ts.h | 54 ++++ include/tscore/Encoding.h | 40 +++ src/api/InkAPI.cc | 40 +++ src/tscore/Encoding.cc | 152 ++++++++++ src/tscore/unit_tests/test_Encoding.cc | 270 +++++++++++++++++- .../pluginTest/tsapi/CMakeLists.txt | 1 + .../tsapi/test_TSStringHtmlEscape.cc | 208 ++++++++++++++ .../tsapi/test_TSStringHtmlEscape.replay.yaml | 106 +++++++ .../tsapi/test_TSStringHtmlEscape.test.py | 33 +++ 10 files changed, 983 insertions(+), 2 deletions(-) create mode 100644 doc/developer-guide/api/functions/TSStringHtmlEscape.en.rst create mode 100644 tests/gold_tests/pluginTest/tsapi/test_TSStringHtmlEscape.cc create mode 100644 tests/gold_tests/pluginTest/tsapi/test_TSStringHtmlEscape.replay.yaml create mode 100644 tests/gold_tests/pluginTest/tsapi/test_TSStringHtmlEscape.test.py diff --git a/doc/developer-guide/api/functions/TSStringHtmlEscape.en.rst b/doc/developer-guide/api/functions/TSStringHtmlEscape.en.rst new file mode 100644 index 00000000000..64ae340d9f4 --- /dev/null +++ b/doc/developer-guide/api/functions/TSStringHtmlEscape.en.rst @@ -0,0 +1,81 @@ +.. 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:: ../../../common.defs + +.. default-domain:: cpp + +TSStringHtmlEscape +****************** + +Escape and unescape strings for inclusion in HTML. + +Synopsis +======== + +.. code-block:: cpp + + #include + +.. var:: constexpr bool TS_HTML_ESCAPE_USE_ATTRIBUTE_MODE = true + +.. function:: TSReturnCode TSStringHtmlEscape(const char * str, int str_len, char * dst, size_t dst_size, size_t * length, bool use_attribute_mode) +.. function:: TSReturnCode TSStringHtmlUnescape(const char * str, int str_len, char * dst, size_t dst_size, size_t * length) + +Description +=========== + +:func:`TSStringHtmlEscape` escapes the UTF-8 string in :arg:`str` according to +the `HTML fragment serialization escaping algorithm +`_. Ampersands, less-than signs, +greater-than signs, and U+00A0 NO-BREAK SPACE characters are replaced by their +HTML named character references. Pass :var:`TS_HTML_ESCAPE_USE_ATTRIBUTE_MODE` as +:arg:`use_attribute_mode` to also replace double quotes with ``"`` for use +in double-quoted HTML attribute values. Pass +:literal:`!TS_HTML_ESCAPE_USE_ATTRIBUTE_MODE` to leave double quotes unchanged. + +:func:`TSStringHtmlUnescape` is the inverse operation. It replaces ``&``, +`` ``, ``<``, ``>``, and ``"`` with the corresponding UTF-8 +characters. Other named or numeric character references are left unchanged. +References are matched case-sensitively and must include the terminating +semicolon. Unescaping is performed once from left to right, so ``&lt;`` +becomes ``<``, not ``<``. + +:arg:`str_len` is the number of bytes to read from :arg:`str`. A value of +:literal:`-1` treats :arg:`str` as NUL-terminated. The caller must provide a +non-overlapping :arg:`dst` buffer whose :arg:`dst_size` includes room for the +terminating NUL. On success, :arg:`length` is set to the number of output bytes, +excluding that NUL. On failure, :arg:`length` is set to :literal:`0` if it is +not :literal:`nullptr`. + +The worst-case destination size for :func:`TSStringHtmlEscape` is six times the +input length plus one byte for the terminating NUL. An input-length-plus-one +buffer is always sufficient for :func:`TSStringHtmlUnescape`. + +Return Values +============= + +:func:`TSStringHtmlEscape` returns :enumerator:`TS_SUCCESS` on success and +:enumerator:`TS_ERROR` if :arg:`str_len` is invalid, the escaped length +overflows, or :arg:`dst` is too small. :func:`TSStringHtmlUnescape` returns +:enumerator:`TS_ERROR` if :arg:`str_len` is invalid or :arg:`dst` is too small. + +See Also +======== + +:manpage:`TSAPI(3ts)`, +:manpage:`TSUrlPercentEncode(3ts)` diff --git a/include/ts/ts.h b/include/ts/ts.h index cff3a73b6f8..162d034f125 100644 --- a/include/ts/ts.h +++ b/include/ts/ts.h @@ -738,6 +738,60 @@ TSReturnCode TSUrlHttpFragmentSet(TSMBuffer bufp, TSMLoc offset, const char *val TSReturnCode TSStringPercentEncode(const char *str, int str_len, char *dst, size_t dst_size, size_t *length, const unsigned char *map); +/// Request double-quoted attribute escaping in TSStringHtmlEscape(). +constexpr bool TS_HTML_ESCAPE_USE_ATTRIBUTE_MODE = true; + +/** + Escape a UTF-8 string for inclusion in HTML, storing the escaped string in + the destination buffer. The output is NUL-terminated. The length parameter + is set to the escaped string length, excluding the terminating NUL, or zero + if escaping fails. + + This follows the HTML fragment serialization escaping algorithm. Ampersand, + less-than, greater-than, and U+00A0 NO-BREAK SPACE characters are always + escaped. Double quotes are also escaped when @a use_attribute_mode is @c true. + + @param[in] str string buffer to escape. + @param[in] str_len length of the string buffer, or -1 if @a str is + NUL-terminated. + @param[out] dst destination buffer. This must not overlap @a str. + @param[in] dst_size size of the destination buffer, including space for the + terminating NUL. + @param[out] length amount of data written to the destination buffer, + excluding the terminating NUL. This may be @c nullptr. + @param[in] use_attribute_mode whether to escape for a double-quoted HTML + attribute value. + + @return @c TS_SUCCESS on success, @c TS_ERROR if @a str_len is less than + -1, the output length overflows, or @a dst is too small. + */ +TSReturnCode TSStringHtmlEscape(const char *str, int str_len, char *dst, size_t dst_size, size_t *length, bool use_attribute_mode); + +/** + Unescape the HTML character references emitted by TSStringHtmlEscape(), + storing the resulting UTF-8 string in the destination buffer. The output is + NUL-terminated. The length parameter is set to the unescaped string length, + excluding the terminating NUL, or zero if unescaping fails. + + The character references @c &, @c  , @c <, @c >, and + @c " are replaced by their corresponding characters. Other character + references are preserved. Unescaping is performed once, so an unescaped + character reference is not unescaped again. + + @param[in] str string buffer to unescape. + @param[in] str_len length of the string buffer, or -1 if @a str is + NUL-terminated. + @param[out] dst destination buffer. This must not overlap @a str. + @param[in] dst_size size of the destination buffer, including space for the + terminating NUL. + @param[out] length amount of data written to the destination buffer, + excluding the terminating NUL. This may be @c nullptr. + + @return @c TS_SUCCESS on success, @c TS_ERROR if @a str_len is less than + -1 or @a dst is too small. + */ +TSReturnCode TSStringHtmlUnescape(const char *str, int str_len, char *dst, size_t dst_size, size_t *length); + /** Similar to TSStringPercentEncode(), but works on a URL object. diff --git a/include/tscore/Encoding.h b/include/tscore/Encoding.h index 63c76da7948..abd42f5153e 100644 --- a/include/tscore/Encoding.h +++ b/include/tscore/Encoding.h @@ -19,6 +19,9 @@ limitations under the License. */ +#include +#include + class Arena; /*------------------------------------------------------------------------- @@ -40,4 +43,41 @@ char *escapify_url(Arena *arena, char *url, size_t len_in, int *len_out, char *d const unsigned char *map = nullptr); char *pure_escapify_url(Arena *arena, char *url, size_t len_in, int *len_out, char *dst = nullptr, size_t dst_size = 0, const unsigned char *map = nullptr); + +/** Escape a UTF-8 string for inclusion in HTML. + + This implements the HTML fragment serialization escaping algorithm. The + characters @c &, @c <, @c >, and U+00A0 NO-BREAK SPACE are always escaped. + The @c " character is additionally escaped when @a use_attribute_mode is + @c true. + + @param[in] input string to escape. + @param[out] dst destination buffer, which must not overlap @a input. + @param[in] dst_size size of @a dst, including space for the terminating NUL. + @param[out] length amount of data written, excluding the terminating NUL. + This is set to zero if escaping fails. This may be @c nullptr. + @param[in] use_attribute_mode whether to escape for a double-quoted HTML + attribute value. + + @return @c true on success, @c false if @a dst is null, the output length + overflows, or @a dst is too small. + */ +bool html_escape(std::string_view input, char *dst, size_t dst_size, size_t *length, bool use_attribute_mode); + +/** Unescape the HTML character references emitted by html_escape(). + + The character references @c &, @c  , @c <, @c >, and + @c " are replaced by their corresponding UTF-8 characters. Other + character references are preserved. Unescaping is performed once, so an + unescaped character reference is not unescaped again. + + @param[in] input string to unescape. + @param[out] dst destination buffer, which must not overlap @a input. + @param[in] dst_size size of @a dst, including space for the terminating NUL. + @param[out] length amount of data written, excluding the terminating NUL. + This is set to zero if unescaping fails. This may be @c nullptr. + + @return @c true on success, @c false if @a dst is null or too small. + */ +bool html_unescape(std::string_view input, char *dst, size_t dst_size, size_t *length); }; // namespace Encoding diff --git a/src/api/InkAPI.cc b/src/api/InkAPI.cc index b26836e81df..a06d8f9ded7 100644 --- a/src/api/InkAPI.cc +++ b/src/api/InkAPI.cc @@ -1347,6 +1347,46 @@ TSStringPercentEncode(const char *str, int str_len, char *dst, size_t dst_size, return TS_SUCCESS; } +TSReturnCode +TSStringHtmlEscape(const char *str, int str_len, char *dst, size_t dst_size, size_t *length, bool use_attribute_mode) +{ + sdk_assert(sdk_sanity_check_null_ptr((void *)str) == TS_SUCCESS); + sdk_assert(sdk_sanity_check_null_ptr((void *)dst) == TS_SUCCESS); + + if (str_len < -1) { + if (length) { + *length = 0; + } + return TS_ERROR; + } + size_t input_length = str_len == -1 ? strlen(str) : static_cast(str_len); + + if (!Encoding::html_escape(std::string_view{str, input_length}, dst, dst_size, length, use_attribute_mode)) { + return TS_ERROR; + } + return TS_SUCCESS; +} + +TSReturnCode +TSStringHtmlUnescape(const char *str, int str_len, char *dst, size_t dst_size, size_t *length) +{ + sdk_assert(sdk_sanity_check_null_ptr((void *)str) == TS_SUCCESS); + sdk_assert(sdk_sanity_check_null_ptr((void *)dst) == TS_SUCCESS); + + if (str_len < -1) { + if (length) { + *length = 0; + } + return TS_ERROR; + } + size_t input_length = str_len == -1 ? strlen(str) : static_cast(str_len); + + if (!Encoding::html_unescape(std::string_view{str, input_length}, dst, dst_size, length)) { + return TS_ERROR; + } + return TS_SUCCESS; +} + TSReturnCode TSStringPercentDecode(const char *str, size_t str_len, char *dst, size_t dst_size, size_t *length) { diff --git a/src/tscore/Encoding.cc b/src/tscore/Encoding.cc index e7257e12535..3d1bfbb505f 100644 --- a/src/tscore/Encoding.cc +++ b/src/tscore/Encoding.cc @@ -25,11 +25,47 @@ #include "tscore/Diags.h" #include "tscore/ink_string.h" +#include +#include +#include + namespace { DbgCtl dbg_ctl_log_utils{"log-utils"}; +constexpr std::string_view HTML_AMPERSAND{"&"}; +constexpr std::string_view HTML_NO_BREAK_SPACE{" "}; +constexpr std::string_view HTML_LESS_THAN{"<"}; +constexpr std::string_view HTML_GREATER_THAN{">"}; +constexpr std::string_view HTML_QUOTATION_MARK{"""}; + +struct HtmlCharacterReference { + std::string_view encoded; + std::string_view decoded; +}; + +constexpr HtmlCharacterReference HTML_CHARACTER_REFERENCES[] = { + {HTML_AMPERSAND, "&" }, + {HTML_NO_BREAK_SPACE, "\xC2\xA0"}, + {HTML_LESS_THAN, "<" }, + {HTML_GREATER_THAN, ">" }, + {HTML_QUOTATION_MARK, "\"" }, +}; + +HtmlCharacterReference const * +html_character_reference_at(std::string_view input, size_t offset) +{ + auto remaining = input.substr(offset); + + for (auto const &reference : HTML_CHARACTER_REFERENCES) { + if (remaining.starts_with(reference.encoded)) { + return &reference; + } + } + return nullptr; +} + /*------------------------------------------------------------------------- Encoding::escapify_url_common @@ -187,4 +223,120 @@ pure_escapify_url(Arena *arena, char *url, size_t len_in, int *len_out, char *ds { return escapify_url_common(arena, url, len_in, len_out, dst, dst_size, map, true); } + +bool +html_escape(std::string_view input, char *dst, size_t dst_size, size_t *length, bool use_attribute_mode) +{ + if (length) { + *length = 0; + } + if (!dst) { + return false; + } + + auto replacement_for = [use_attribute_mode](std::string_view source, size_t offset) -> std::string_view { + switch (static_cast(source[offset])) { + case '&': + return HTML_AMPERSAND; + case '<': + return HTML_LESS_THAN; + case '>': + return HTML_GREATER_THAN; + case '"': + return use_attribute_mode ? HTML_QUOTATION_MARK : std::string_view{}; + case 0xC2: + if (offset + 1 < source.size() && static_cast(source[offset + 1]) == 0xA0) { + return HTML_NO_BREAK_SPACE; + } + break; + default: + break; + } + return {}; + }; + + size_t output_size = input.size(); + for (size_t offset = 0; offset < input.size(); ++offset) { + auto replacement = replacement_for(input, offset); + + if (!replacement.empty()) { + size_t consumed = replacement == HTML_NO_BREAK_SPACE ? 2 : 1; + size_t growth = replacement.size() - consumed; + + if (output_size > std::numeric_limits::max() - growth) { + return false; + } + output_size += growth; + offset += consumed - 1; + } + } + + if (output_size == std::numeric_limits::max() || dst_size < output_size + 1) { + return false; + } + + size_t output_offset = 0; + for (size_t input_offset = 0; input_offset < input.size(); ++input_offset) { + auto replacement = replacement_for(input, input_offset); + + if (replacement.empty()) { + dst[output_offset++] = input[input_offset]; + } else { + std::memcpy(dst + output_offset, replacement.data(), replacement.size()); + output_offset += replacement.size(); + if (replacement == HTML_NO_BREAK_SPACE) { + ++input_offset; + } + } + } + dst[output_offset] = '\0'; + + if (length) { + *length = output_offset; + } + return true; +} + +bool +html_unescape(std::string_view input, char *dst, size_t dst_size, size_t *length) +{ + if (length) { + *length = 0; + } + if (!dst) { + return false; + } + + size_t output_size = input.size(); + for (size_t offset = 0; offset < input.size();) { + if (auto const *reference = html_character_reference_at(input, offset); reference) { + output_size -= reference->encoded.size() - reference->decoded.size(); + offset += reference->encoded.size(); + } else { + ++offset; + } + } + + if (dst_size <= output_size) { + return false; + } + + size_t input_offset = 0; + size_t output_offset = 0; + while (input_offset < input.size()) { + if (auto const *reference = html_character_reference_at(input, input_offset); reference) { + std::memcpy(dst + output_offset, reference->decoded.data(), reference->decoded.size()); + input_offset += reference->encoded.size(); + output_offset += reference->decoded.size(); + } else { + dst[output_offset++] = input[input_offset++]; + } + } + dst[output_offset] = '\0'; + + if (length) { + *length = output_offset; + } + return true; +} }; // namespace Encoding diff --git a/src/tscore/unit_tests/test_Encoding.cc b/src/tscore/unit_tests/test_Encoding.cc index dc670afcc64..13917bd696a 100644 --- a/src/tscore/unit_tests/test_Encoding.cc +++ b/src/tscore/unit_tests/test_Encoding.cc @@ -21,19 +21,36 @@ limitations under the License. */ -#include -#include +#include #include #include #include #include +#include +#include #include +#include #include using namespace Encoding; +namespace +{ +constexpr bool TS_HTML_ESCAPE_USE_ATTRIBUTE_MODE = true; + +struct HtmlDestinationTestCase { + static constexpr bool USE_NULL_DESTINATION = true; + static constexpr bool EXPECT_SUCCESS = true; + + std::string_view description; + size_t destination_size; + bool use_null_destination; + bool expect_success; +}; +} // namespace + TEST_CASE("Encoding pure escapify url", "[pure_esc_url]") { char input[][32] = { @@ -105,3 +122,252 @@ TEST_CASE("Encoding escapify url", "[esc_url]") CHECK(std::string_view(output) == expected[i]); } } + +TEST_CASE("Encoding HTML escape validates destination capacity", "[html_escape]") +{ + constexpr std::string_view input{"<&"}; + constexpr std::string_view expected{"<&"}; + using TestCase = HtmlDestinationTestCase; + + // clang-format off + static constexpr TestCase test_cases[] = { + { + "rejects an undersized destination: too small for null terminator", + expected.size(), + !TestCase::USE_NULL_DESTINATION, + !TestCase::EXPECT_SUCCESS, + }, + { + "accepts the exact required destination size", + expected.size() + 1, + !TestCase::USE_NULL_DESTINATION, + TestCase::EXPECT_SUCCESS, + }, + { + "rejects a null destination", + 0, + TestCase::USE_NULL_DESTINATION, + !TestCase::EXPECT_SUCCESS, + }, + }; + // clang-format on + + auto test = GENERATE(from_range(test_cases)); + CAPTURE(test.description, test.destination_size, test.use_null_destination, test.expect_success); + + std::array output; + output.fill('x'); + char *destination = test.use_null_destination ? nullptr : output.data(); + size_t output_length = input.size(); + + CHECK(Encoding::html_escape(input, destination, test.destination_size, &output_length, !TS_HTML_ESCAPE_USE_ATTRIBUTE_MODE) == + test.expect_success); + if (test.expect_success) { + CHECK(output_length == expected.size()); + CHECK(std::string_view{output.data(), output_length} == expected); + } else { + CHECK(output_length == 0); + CHECK(output.front() == 'x'); + } +} + +TEST_CASE("Encoding HTML escape", "[html_escape]") +{ + struct TestCase { + std::string_view description; + std::string_view input; + std::string_view expected; + bool use_attribute_mode; + }; + + // clang-format off + static constexpr TestCase test_cases[] = { + { + "escapes every ampersand", + "Fish & chips & salsa", + "Fish & chips & salsa", + !TS_HTML_ESCAPE_USE_ATTRIBUTE_MODE, + }, + { + "escapes every no-break space", + "one\xC2\xA0two\xC2\xA0three", + "one two three", + !TS_HTML_ESCAPE_USE_ATTRIBUTE_MODE, + }, + { + "escapes every less-than sign", + "<>", + "tag>>", + !TS_HTML_ESCAPE_USE_ATTRIBUTE_MODE, + }, + { + "preserves double quotes outside attribute mode", + "\"one\" \"two\"", + "\"one\" \"two\"", + !TS_HTML_ESCAPE_USE_ATTRIBUTE_MODE, + }, + { + "escapes every double quote in attribute mode", + "\"one\" \"two\"", + ""one" "two"", + TS_HTML_ESCAPE_USE_ATTRIBUTE_MODE, + }, + { + "preserves apostrophes", + "it's 'quoted'", + "it's 'quoted'", + !TS_HTML_ESCAPE_USE_ATTRIBUTE_MODE, + }, + { + "does not re-escape generated references", + "<\xC2\xA0", + "&lt; ", + !TS_HTML_ESCAPE_USE_ATTRIBUTE_MODE, + }, + { + "preserves other UTF-8 and incomplete sequences", + "caf\xC3\xA9\xC2", + "caf\xC3\xA9\xC2", + !TS_HTML_ESCAPE_USE_ATTRIBUTE_MODE, + }, + }; + // clang-format on + + auto test = GENERATE(from_range(test_cases)); + CAPTURE(test.description, test.input, test.expected, test.use_attribute_mode); + + std::vector output; + size_t output_length = 0; + + output.resize(test.expected.size() + 1); + + REQUIRE(Encoding::html_escape(test.input, output.data(), output.size(), &output_length, test.use_attribute_mode)); + CHECK(output_length == test.expected.size()); + CHECK(std::string_view{output.data(), output_length} == test.expected); + CHECK(output[output_length] == '\0'); +} + +TEST_CASE("Encoding HTML unescape validates destination capacity", "[html_unescape]") +{ + constexpr std::string_view input{"<&"}; + constexpr std::string_view expected{"<&"}; + using TestCase = HtmlDestinationTestCase; + + // clang-format off + static constexpr TestCase test_cases[] = { + { + "rejects an undersized destination: too small for null terminator", + expected.size(), + !TestCase::USE_NULL_DESTINATION, + !TestCase::EXPECT_SUCCESS, + }, + { + "accepts the exact required destination size", + expected.size() + 1, + !TestCase::USE_NULL_DESTINATION, + TestCase::EXPECT_SUCCESS, + }, + { + "rejects a null destination", + 0, + TestCase::USE_NULL_DESTINATION, + !TestCase::EXPECT_SUCCESS, + }, + }; + // clang-format on + + auto test = GENERATE(from_range(test_cases)); + CAPTURE(test.description, test.destination_size, test.use_null_destination, test.expect_success); + + std::array output; + output.fill('x'); + char *destination = test.use_null_destination ? nullptr : output.data(); + size_t output_length = input.size(); + + CHECK(Encoding::html_unescape(input, destination, test.destination_size, &output_length) == test.expect_success); + if (test.expect_success) { + CHECK(output_length == expected.size()); + CHECK(std::string_view{output.data(), output_length} == expected); + } else { + CHECK(output_length == 0); + CHECK(output.front() == 'x'); + } +} + +TEST_CASE("Encoding HTML unescape", "[html_unescape]") +{ + struct TestCase { + std::string_view description; + std::string_view input; + std::string_view expected; + }; + + // clang-format off + static constexpr TestCase test_cases[] = { + { + "unescapes every ampersand reference", + "Fish & chips & salsa", + "Fish & chips & salsa", + }, + { + "unescapes every no-break space reference", + "one two three", + "one\xC2\xA0two\xC2\xA0three", + }, + { + "unescapes every less-than reference", + "<<tag", + "<>", + }, + { + "unescapes every double-quote reference", + ""one" "two"", + "\"one\" \"two\"", + }, + { + "preserves apostrophes", + "it's 'quoted'", + "it's 'quoted'", + }, + { + "unescapes one layer of nested references", + "&lt; ", + "<\xC2\xA0", + }, + { + "preserves unknown and incomplete references", + "© & & &bogus;", + "© & & &bogus;", + }, + { + "preserves other UTF-8 and incomplete sequences", + "caf\xC3\xA9\xC2", + "caf\xC3\xA9\xC2", + }, + }; + // clang-format on + + auto test = GENERATE(from_range(test_cases)); + CAPTURE(test.description, test.input, test.expected); + + std::vector output; + size_t output_length = 0; + + output.resize(test.expected.size() + 1); + + REQUIRE(Encoding::html_unescape(test.input, output.data(), output.size(), &output_length)); + CHECK(output_length == test.expected.size()); + CHECK(std::string_view{output.data(), output_length} == test.expected); + CHECK(output[output_length] == '\0'); +} diff --git a/tests/gold_tests/pluginTest/tsapi/CMakeLists.txt b/tests/gold_tests/pluginTest/tsapi/CMakeLists.txt index 516d89e514d..06c5167a2cf 100644 --- a/tests/gold_tests/pluginTest/tsapi/CMakeLists.txt +++ b/tests/gold_tests/pluginTest/tsapi/CMakeLists.txt @@ -21,3 +21,4 @@ add_autest_plugin(test_TSHttpSsnInfo test_TSHttpSsnInfo.cc) add_autest_plugin(test_TSVConnPPInfo test_TSVConnPPInfo.cc) add_autest_plugin(test_TSHttpTxnVerifiedAddr test_TSHttpTxnVerifiedAddr.cc) add_autest_plugin(test_TSHttpTxnServerAddrSet_retry test_TSHttpTxnServerAddrSet_retry.cc) +add_autest_plugin(test_TSStringHtmlEscape test_TSStringHtmlEscape.cc) diff --git a/tests/gold_tests/pluginTest/tsapi/test_TSStringHtmlEscape.cc b/tests/gold_tests/pluginTest/tsapi/test_TSStringHtmlEscape.cc new file mode 100644 index 00000000000..a4e082f04f9 --- /dev/null +++ b/tests/gold_tests/pluginTest/tsapi/test_TSStringHtmlEscape.cc @@ -0,0 +1,208 @@ +/** @file + + A test plugin that HTML-escapes and unescapes origin response bodies. + + @section license License + + 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 "ts/ts.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr char PLUGIN_NAME[] = "test_TSStringHtmlEscape"; + +enum class HtmlOperation { ESCAPE, UNESCAPE }; + +struct TransformData { + explicit TransformData(HtmlOperation operation) : operation(operation) + { + output_buffer = TSIOBufferCreate(); + output_reader = TSIOBufferReaderAlloc(output_buffer); + } + + ~TransformData() { TSIOBufferDestroy(output_buffer); } + + TSVIO output_vio = nullptr; + TSIOBuffer output_buffer = nullptr; + TSIOBufferReader output_reader = nullptr; + std::string input; + HtmlOperation operation; + bool output_complete = false; +}; + +bool +write_transformed_output(TransformData &data) +{ + if (data.input.size() > static_cast(std::numeric_limits::max())) { + return false; + } + + size_t output_capacity = data.input.size() + 1; + if (data.operation == HtmlOperation::ESCAPE) { + if (data.input.size() > (std::numeric_limits::max() - 1) / 6) { + return false; + } + output_capacity = data.input.size() * 6 + 1; + } + + std::vector output(output_capacity); + size_t output_length = 0; + + TSReturnCode result = + data.operation == HtmlOperation::ESCAPE ? + TSStringHtmlEscape(data.input.data(), static_cast(data.input.size()), output.data(), output.size(), &output_length, + !TS_HTML_ESCAPE_USE_ATTRIBUTE_MODE) : + TSStringHtmlUnescape(data.input.data(), static_cast(data.input.size()), output.data(), output.size(), &output_length); + if (result != TS_SUCCESS) { + return false; + } + if (TSIOBufferWrite(data.output_buffer, output.data(), output_length) != static_cast(output_length)) { + return false; + } + + TSVIONBytesSet(data.output_vio, output_length); + data.output_complete = true; + TSVIOReenable(data.output_vio); + return true; +} + +void +handle_transform(TSCont contp, HtmlOperation operation) +{ + TSVIO input_vio = TSVConnWriteVIOGet(contp); + auto *data = static_cast(TSContDataGet(contp)); + + if (!data) { + data = new TransformData{operation}; + data->output_vio = TSVConnWrite(TSTransformOutputVConnGet(contp), contp, data->output_reader, INT64_MAX); + TSContDataSet(contp, data); + } + + if (!TSVIOBufferGet(input_vio)) { + if (!data->output_complete && !write_transformed_output(*data)) { + TSContCall(TSVIOContGet(input_vio), TS_EVENT_ERROR, input_vio); + } + return; + } + + int64_t to_read = std::min(TSVIONTodoGet(input_vio), TSIOBufferReaderAvail(TSVIOReaderGet(input_vio))); + if (to_read > 0) { + size_t old_size = data->input.size(); + + data->input.resize(old_size + static_cast(to_read)); + TSIOBufferReaderCopy(TSVIOReaderGet(input_vio), data->input.data() + old_size, to_read); + TSIOBufferReaderConsume(TSVIOReaderGet(input_vio), to_read); + TSVIONDoneSet(input_vio, TSVIONDoneGet(input_vio) + to_read); + } + + if (TSVIONTodoGet(input_vio) > 0) { + if (to_read > 0) { + TSContCall(TSVIOContGet(input_vio), TS_EVENT_VCONN_WRITE_READY, input_vio); + } + return; + } + + if (!data->output_complete && !write_transformed_output(*data)) { + TSContCall(TSVIOContGet(input_vio), TS_EVENT_ERROR, input_vio); + return; + } + TSContCall(TSVIOContGet(input_vio), TS_EVENT_VCONN_WRITE_COMPLETE, input_vio); +} + +int +transform_handler(TSCont contp, TSEvent event, HtmlOperation operation) +{ + if (TSVConnClosedGet(contp)) { + delete static_cast(TSContDataGet(contp)); + TSContDestroy(contp); + return 0; + } + + switch (event) { + case TS_EVENT_ERROR: { + TSVIO input_vio = TSVConnWriteVIOGet(contp); + + TSContCall(TSVIOContGet(input_vio), TS_EVENT_ERROR, input_vio); + break; + } + case TS_EVENT_VCONN_WRITE_COMPLETE: + TSVConnShutdown(TSTransformOutputVConnGet(contp), 0, 1); + break; + default: + handle_transform(contp, operation); + break; + } + return 0; +} + +int +escape_transform_handler(TSCont contp, TSEvent event, void * /* edata */) +{ + return transform_handler(contp, event, HtmlOperation::ESCAPE); +} + +int +unescape_transform_handler(TSCont contp, TSEvent event, void * /* edata */) +{ + return transform_handler(contp, event, HtmlOperation::UNESCAPE); +} + +int +response_hook(TSCont /* contp */, TSEvent event, void *edata) +{ + auto txnp = static_cast(edata); + + if (event == TS_EVENT_HTTP_READ_RESPONSE_HDR) { + int url_length = 0; + char *url = TSHttpTxnEffectiveUrlStringGet(txnp, &url_length); + bool unescape = url && std::string_view{url, static_cast(url_length)}.ends_with("/unescape"); + + TSfree(url); + TSHttpTxnHookAdd(txnp, TS_HTTP_RESPONSE_TRANSFORM_HOOK, + TSTransformCreate(unescape ? unescape_transform_handler : escape_transform_handler, txnp)); + } + TSHttpTxnReenable(txnp, TS_EVENT_HTTP_CONTINUE); + return 0; +} +} // namespace + +void +TSPluginInit(int /* argc */, const char ** /* argv */) +{ + TSPluginRegistrationInfo info; + + info.plugin_name = PLUGIN_NAME; + info.vendor_name = "Apache Software Foundation"; + info.support_email = "dev@trafficserver.apache.org"; + + if (TSPluginRegister(&info) != TS_SUCCESS) { + TSError("[%s] plugin registration failed", PLUGIN_NAME); + return; + } + + TSHttpHookAdd(TS_HTTP_READ_RESPONSE_HDR_HOOK, TSContCreate(response_hook, nullptr)); +} diff --git a/tests/gold_tests/pluginTest/tsapi/test_TSStringHtmlEscape.replay.yaml b/tests/gold_tests/pluginTest/tsapi/test_TSStringHtmlEscape.replay.yaml new file mode 100644 index 00000000000..67dd37d4bee --- /dev/null +++ b/tests/gold_tests/pluginTest/tsapi/test_TSStringHtmlEscape.replay.yaml @@ -0,0 +1,106 @@ +# 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. + +meta: + version: "1.0" + +autest: + description: "Verify the HTML escape and unescape APIs with a response transform" + + server: + name: "server" + + client: + name: "client" + + ats: + name: "ts" + process_config: + enable_cache: false + + plugin_config: + - "test_TSStringHtmlEscape.so" + + remap_config: + - from: "http://html-escape.test/" + to: "http://127.0.0.1:{SERVER_HTTP_PORT}/" + +sessions: + - transactions: + - client-request: + method: "GET" + version: "1.1" + url: "/escape" + headers: + fields: + - [Host, html-escape.test] + - [uuid, html-escape] + + proxy-request: + method: "GET" + url: "/escape" + + server-response: + status: 200 + reason: "OK" + headers: + fields: + - [Content-Type, "text/plain; charset=utf-8"] + - [Content-Length, 35] + content: + encoding: plain + data: '

"fish" & ''chips''

' + + proxy-response: + status: 200 + content: + encoding: plain + data: '<p title="a&b">"fish" & ''chips''</p>' + verify: + as: equal + + - client-request: + method: "GET" + version: "1.1" + url: "/unescape" + headers: + fields: + - [Host, html-escape.test] + - [uuid, html-unescape] + + proxy-request: + method: "GET" + url: "/unescape" + + server-response: + status: 200 + reason: "OK" + headers: + fields: + - [Content-Type, "text/plain; charset=utf-8"] + - [Content-Length, 55] + content: + encoding: plain + data: '<p title="a&b">"fish" & ''chips''</p>' + + proxy-response: + status: 200 + content: + encoding: plain + data: '

"fish" & ''chips''

' + verify: + as: equal diff --git a/tests/gold_tests/pluginTest/tsapi/test_TSStringHtmlEscape.test.py b/tests/gold_tests/pluginTest/tsapi/test_TSStringHtmlEscape.test.py new file mode 100644 index 00000000000..b1b59f1bc57 --- /dev/null +++ b/tests/gold_tests/pluginTest/tsapi/test_TSStringHtmlEscape.test.py @@ -0,0 +1,33 @@ +# 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. + +import os + +Test.Summary = "Verify the HTML escape and unescape APIs with a response-transform plugin." + +plugin_name = "test_TSStringHtmlEscape" +test_run = Test.ATSReplayTest(replay_file="test_TSStringHtmlEscape.replay.yaml") +ts = test_run.Processes.ts + +plugin_path = os.path.join( + Test.Variables.AtsBuildGoldTestsDir, + "pluginTest", + "tsapi", + ".libs", + f"{plugin_name}.so", +) +ts.Setup.Copy(plugin_path, ts.Env["PROXY_CONFIG_PLUGIN_PLUGIN_DIR"])