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"])