From e8391121582d5fd8dd805d8225c3fd9be3929166 Mon Sep 17 00:00:00 2001 From: bneradt Date: Tue, 21 Jul 2026 15:16:11 -0500 Subject: [PATCH] Support JSON error response bodies ATS cannot reliably emit JSON error responses through header rewrite: literal bodies cannot select a MIME type, HRW4U treats JSON braces as interpolation, fetched bodies lose their Content-Type, and conditional local files are unavailable. Body factory error pages are also hardcoded to HTML. This adds optional Content-Type arguments to set-body and set-body-from, preserves fetched response types, adds configuration-time local file bodies, and teaches HRW4U literal JSON braces and MIME-aware forms. It also adds body-factory Content-Type metadata and clears stale MIME state for empty fabricated bodies. Network set-body-from remains READ_RESPONSE-only because remap hooks cannot suspend for its asynchronous fetch. This covers remap-time errors with literal or file-backed bodies instead. This incorporates Bryan Call's body-factory work from #12947. Related: #10893 Fixes: #13393 Fixes: #11480 Co-authored-by: Bryan Call --- .../body_factory/default/.body_factory_info | 24 ++-- .../monitoring/error-messages.en.rst | 42 +++++- doc/admin-guide/plugins/header_rewrite.en.rst | 48 ++++++- include/proxy/http/HttpBodyFactory.h | 3 +- plugins/header_rewrite/factory.cc | 2 + plugins/header_rewrite/operators.cc | 131 +++++++++++++++--- plugins/header_rewrite/operators.h | 22 +++ src/proxy/http/HttpBodyFactory.cc | 42 +++--- src/proxy/http/HttpTransact.cc | 4 + .../body_factory_content_type.test.py | 105 ++++++++++++++ .../body_factory/gold/http-204-custom.gold | 2 +- .../gold/http-head-no-origin.gold | 2 +- .../general-connection-failure-502.gold | 2 +- .../headers/gold/bad_protocol_number.gold | 2 +- .../gold_tests/headers/gold/bad_te_value.gold | 2 +- .../gold/invalid_character_in_te_value.gold | 2 +- tests/gold_tests/ip_allow/gold/log.gold | 4 +- .../header_rewrite-set_body_from_200.gold | 2 +- .../header_rewrite-set_body_from_success.gold | 2 +- .../header_rewrite_bundle.replay.yaml | 37 +++++ .../header_rewrite_set_body_from.test.py | 41 +++++- .../header_rewrite/rules/rule_json_body.conf | 20 +++ .../rules/rule_set_body_from_file.conf | 20 +++ .../rules/rule_set_body_from_plugin.conf | 4 + .../statichit/statichit.replay.yaml | 2 +- .../pluginTest/xdebug/x_cache_info/out.gold | 2 +- .../xdebug/x_effective_url/out.gold | 2 +- .../pluginTest/xdebug/x_remap/out.gold | 2 +- tests/gold_tests/remap/gold/remap-404.gold | 2 +- .../remap/gold/remap-hitATS-404.gold | 2 +- .../gold_tests/remap_yaml/gold/remap-404.gold | 2 +- .../remap_yaml/gold/remap-hitATS-404.gold | 2 +- tools/hrw4u/README.md | 21 +++ tools/hrw4u/grammar/hrw4u.g4 | 2 +- tools/hrw4u/schema/sandbox.schema.json | 2 + tools/hrw4u/src/hrw_symbols.py | 3 + tools/hrw4u/src/lsp/documentation.py | 45 +++++- tools/hrw4u/src/tables.py | 4 +- tools/hrw4u/src/types.py | 1 + tools/hrw4u/src/validation.py | 13 ++ tools/hrw4u/src/visitor.py | 12 +- tools/hrw4u/tests/data/ops/exceptions.txt | 2 + .../hrw4u/tests/data/ops/json-body.input.txt | 8 ++ .../hrw4u/tests/data/ops/json-body.output.txt | 6 + 44 files changed, 620 insertions(+), 80 deletions(-) create mode 100644 tests/gold_tests/body_factory/body_factory_content_type.test.py create mode 100644 tests/gold_tests/pluginTest/header_rewrite/rules/rule_json_body.conf create mode 100644 tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_file.conf create mode 100644 tools/hrw4u/tests/data/ops/json-body.input.txt create mode 100644 tools/hrw4u/tests/data/ops/json-body.output.txt diff --git a/configs/body_factory/default/.body_factory_info b/configs/body_factory/default/.body_factory_info index e07e02197de..f6e447b96a2 100644 --- a/configs/body_factory/default/.body_factory_info +++ b/configs/body_factory/default/.body_factory_info @@ -3,14 +3,22 @@ # The .body_factory_info file contains descriptive information # about the error pages in this directory. # -# Currently, .body_factory_info contains information which -# indicates the character set and natural language of the error -# pages in this directory. For example, to describe Korean -# web pages encoded in the iso-2022-kr character set, you might -# add these lines to .body_factory_info file: +# Supported directives: # -# Content-Language: kr +# Content-Language Natural language of the error pages (default: en) +# Content-Charset Character encoding (default: utf-8) +# Content-Type Complete MIME type for the response +# +# For example, to describe Korean web pages encoded in the +# iso-2022-kr character set, you might add these lines: +# +# Content-Language: ko-KR # Content-Charset: iso-2022-kr # -# If this file is empty, or only contains comments, the default is -# assumed: English text in the standard utf-8 character set. +# To serve plain text error pages instead of HTML: +# +# Content-Type: text/plain +# +# If this file is empty, or only contains comments, the defaults are +# assumed: English text/html in the utf-8 character set. An explicit +# Content-Type value is used exactly as configured. diff --git a/doc/admin-guide/monitoring/error-messages.en.rst b/doc/admin-guide/monitoring/error-messages.en.rst index 72f706d0814..50d57659f84 100644 --- a/doc/admin-guide/monitoring/error-messages.en.rst +++ b/doc/admin-guide/monitoring/error-messages.en.rst @@ -108,6 +108,47 @@ it would be used instead of ``cache#read_error`` if there is no ``apache_cache#r The text for an error message is processed as if it were a :ref:`admin-logging-fields` which enables customization by values present in the transaction for which the error occurred. +.. _body-factory-info: + +Template Set Metadata +--------------------- + +Each template set directory must contain a ``.body_factory_info`` file for the template set to be +loaded. This file controls the ``Content-Type``, ``Content-Language``, and character set of the +HTTP response headers sent with error pages. + +The following directives are supported: + +``Content-Language`` + The natural language of the error pages. This value is sent in the ``Content-Language`` HTTP + response header. Default: ``en``. + +``Content-Charset`` + The character encoding of the error pages. When ``Content-Type`` is not explicitly configured, + this value is appended to the default ``text/html`` type as a ``charset`` parameter. Default: + ``utf-8``. + +``Content-Type`` + The complete MIME type for the error response. The value is used exactly as configured, so include + a ``charset`` parameter here when one is desired. Default: ``text/html; charset=utf-8``. + +For example, to serve plain text error pages in English:: + + Content-Language: en + Content-Charset: utf-8 + Content-Type: text/plain + +This would produce the response header ``Content-Type: text/plain``. To declare the character set as +well, configure ``Content-Type: text/plain; charset=utf-8``. + +To describe Korean error pages encoded in the ``iso-2022-kr`` character set:: + + Content-Language: ko-KR + Content-Charset: iso-2022-kr + +If the file is empty or contains only comments, the defaults are used: English ``text/html`` in +the ``utf-8`` character set. If the file is absent, the entire template set directory is skipped. + The following table lists the hard-coded Traffic Server HTTP messages, with corresponding HTTP response codes and customizable files. @@ -269,4 +310,3 @@ with corresponding HTTP response codes and customizable files. Could not process this request because the request uri was too long .. ``request#uri_len_too_long`` - diff --git a/doc/admin-guide/plugins/header_rewrite.en.rst b/doc/admin-guide/plugins/header_rewrite.en.rst index cb3d53c3414..dca0ae23f93 100644 --- a/doc/admin-guide/plugins/header_rewrite.en.rst +++ b/doc/admin-guide/plugins/header_rewrite.en.rst @@ -1181,30 +1181,68 @@ set-body ~~~~~~~~ :: - set-body + set-body [] -Sets the body to ````. Can also be used to delete a body with ``""``. This is only useful when overriding the origin status, i.e. -intercepting/pre-empting a request so that you can override the body from the body-factory with your own. +Sets the body to ````. If ```` is supplied, it is used for +the response instead of the default ``text/html``. Can also be used to delete +a body with ``""``. This is only useful when overriding the origin status, +i.e. intercepting/pre-empting a request so that you can override the body from +the body-factory with your own. + +Quoted values support escaped quotes and the ``\n``, ``\r``, and ``\t`` +control characters. For example, a JSON error response can be configured as:: + + cond %{REMAP_PSEUDO_HOOK} + set-status 400 + set-body "{\"error\": \"bad request\"}\n" "application/problem+json" + +set-body-from-file +~~~~~~~~~~~~~~~~~~ +:: + + set-body-from-file [] + +Loads the file at ```` when the rule configuration is loaded and uses +its exact contents as the response body. Relative paths are resolved from the +directory containing the rule file. If ```` is omitted, the +default ``text/html`` is used. Reload the header rewrite configuration after +changing the body file. + +For example:: + + cond %{REMAP_PSEUDO_HOOK} + set-status 403 + set-body-from-file errors/forbidden.json "application/json" set-body-from ~~~~~~~~~~~~~ :: - set-body-from + set-body-from [] Will call ```` (see URL in `URL Parts`_) to retrieve a custom error response and set the body with the result. Triggering this rule on an OK transaction will send a 500 status code to the client with the desired response. If this is triggered on any error status code, that original status code will be sent to the client. +By default, the fetched response's ``Content-Type`` is also used. The optional +```` overrides that value. .. note:: - This config should only be set using READ_RESPONSE_HDR_HOOK + This operator is supported only with ``READ_RESPONSE_HDR_HOOK`` because + its fetch suspends an active transaction. To generate a literal response + at remap time, use ``set-body`` instead. An example config would look like:: cond %{READ_RESPONSE_HDR_HOOK} set-body-from http://www.example.com/second +To create a JSON error during remap without contacting another endpoint:: + + cond %{REMAP_PSEUDO_HOOK} + set-status 400 + set-body "{\"error\": \"bad request\"}\n" "application/problem+json" + Where ``http://www.example.com/second`` is the destination to retrieve the custom response from. This can be enabled per-mapping or globally. Ensure there is a remap rule for the second endpoint as well! diff --git a/include/proxy/http/HttpBodyFactory.h b/include/proxy/http/HttpBodyFactory.h index c730cfbbcdc..4163886fd30 100644 --- a/include/proxy/http/HttpBodyFactory.h +++ b/include/proxy/http/HttpBodyFactory.h @@ -121,6 +121,7 @@ class HttpBodySetRawData char *set_name; char *content_language; char *content_charset; + char *content_type; std::unique_ptr table_of_pages; }; @@ -215,7 +216,7 @@ class HttpBodyFactory private: char *fabricate(StrList *acpt_language_list, StrList *acpt_charset_list, const char *type, HttpTransact::State *context, int64_t *resulting_buffer_length, const char **content_language_return, const char **content_charset_return, - const char **set_return = nullptr); + const char **content_type_return, const char **set_return = nullptr); const char *determine_set_by_language(StrList *acpt_language_list, StrList *acpt_charset_list); const char *determine_set_by_host(HttpTransact::State *context); diff --git a/plugins/header_rewrite/factory.cc b/plugins/header_rewrite/factory.cc index 530cf4cfe59..91a4621c279 100644 --- a/plugins/header_rewrite/factory.cc +++ b/plugins/header_rewrite/factory.cc @@ -73,6 +73,8 @@ operator_factory(const std::string &op) o = new OperatorSetDebug(); } else if (op == "set-body") { o = new OperatorSetBody(); + } else if (op == "set-body-from-file") { + o = new OperatorSetBodyFromFile(); } else if (op == "set-http-cntl") { o = new OperatorSetHttpCntl(); } else if (op == "set-plugin-cntl") { diff --git a/plugins/header_rewrite/operators.cc b/plugins/header_rewrite/operators.cc index 8013990285c..beed06f71f1 100644 --- a/plugins/header_rewrite/operators.cc +++ b/plugins/header_rewrite/operators.cc @@ -23,6 +23,8 @@ #include #include #include +#include +#include #include "records/RecCore.h" #include "ts/ts.h" @@ -40,10 +42,16 @@ const unsigned int LOCAL_IP_ADDRESS = 0x0100007f; const unsigned int MAX_SIZE = 256; const int LOCAL_PORT = 8080; +struct SetBodyFromData { + TSHttpTxn http_txn; + std::string content_type; +}; + int handleFetchEvents(TSCont cont, TSEvent event, void *edata) { - TSHttpTxn http_txn = static_cast(TSContDataGet(cont)); + auto *fetch_data = static_cast(TSContDataGet(cont)); + TSHttpTxn http_txn = fetch_data->http_txn; switch (static_cast(event)) { case OperatorSetBodyFrom::TS_EVENT_FETCHSM_SUCCESS: { @@ -58,11 +66,25 @@ handleFetchEvents(TSCont cont, TSEvent event, void *edata) TSHttpHdrTypeSet(hdr_buf, hdr_loc, TS_HTTP_TYPE_RESPONSE); if (TSHttpHdrParseResp(parser, hdr_buf, hdr_loc, &data_start, data_end) == TS_PARSE_DONE) { - size_t body_len = data_end - data_start; - char *body = static_cast(TSmalloc(body_len + 1)); + char *content_type = nullptr; + size_t body_len = data_end - data_start; + char *body = static_cast(TSmalloc(body_len + 1)); + + if (!fetch_data->content_type.empty()) { + content_type = TSstrdup(fetch_data->content_type.c_str()); + } else if (TSMLoc field_loc = TSMimeHdrFieldFind(hdr_buf, hdr_loc, TS_MIME_FIELD_CONTENT_TYPE, TS_MIME_LEN_CONTENT_TYPE); + field_loc != TS_NULL_MLOC) { + int value_len = 0; + const char *value = TSMimeHdrFieldValueStringGet(hdr_buf, hdr_loc, field_loc, -1, &value_len); + + if (value != nullptr && value_len > 0) { + content_type = TSstrndup(value, value_len); + } + TSHandleMLocRelease(hdr_buf, hdr_loc, field_loc); + } memcpy(body, data_start, body_len); body[body_len] = '\0'; - TSHttpTxnErrorBodySet(http_txn, body, body_len, nullptr); + TSHttpTxnErrorBodySet(http_txn, body, body_len, content_type); } else { TSWarning("[%s] Unable to parse set-custom-body fetch response", __FUNCTION__); } @@ -83,6 +105,8 @@ handleFetchEvents(TSCont cont, TSEvent event, void *edata) TSHttpTxnReenable(http_txn, TS_EVENT_HTTP_CONTINUE); } break; case TS_EVENT_HTTP_TXN_CLOSE: { + delete fetch_data; + TSContDataSet(cont, nullptr); TSContDestroy(cont); TSHttpTxnReenable(http_txn, TS_EVENT_HTTP_CONTINUE); } break; @@ -810,8 +834,10 @@ void OperatorSetBody::initialize(Parser &p) { Operator::initialize(p); - // we want the arg since body only takes one value _value.set_value(p.get_arg(), this); + _content_type.set_value(p.get_value(), this); + require_resources(RSRC_SERVER_RESPONSE_HEADERS); + require_resources(RSRC_RESPONSE_STATUS); } void @@ -825,13 +851,75 @@ bool OperatorSetBody::exec(const Resources &res) const { std::string value; + std::string content_type; _value.append_value(value, res); + _content_type.append_value(content_type, res); char *msg = nullptr; if (!value.empty()) { msg = TSstrdup(value.c_str()); } - TSHttpTxnErrorBodySet(res.state.txnp, msg, value.size(), nullptr); + TSHttpTxnErrorBodySet(res.state.txnp, msg, value.size(), content_type.empty() ? nullptr : TSstrdup(content_type.c_str())); + return true; +} + +// OperatorSetBodyFromFile +void +OperatorSetBodyFromFile::initialize(Parser &p) +{ + Operator::initialize(p); + _content_type.set_value(p.get_value(), this); + require_resources(RSRC_SERVER_RESPONSE_HEADERS); + require_resources(RSRC_RESPONSE_STATUS); + + swoc::file::path path{p.get_arg()}; + + if (path.is_relative()) { + swoc::file::path base{TSConfigDirGet()}; + + if (has_config_location()) { + base = swoc::file::path{get_config_filename()}; + if (base.is_relative()) { + base = swoc::file::path{TSConfigDirGet()} / base; + } + base = base.parent_path(); + } + path = base / path; + } + + std::error_code ec; + + _body = swoc::file::load(path, ec); + if (ec) { + TSError("[%s] unable to load body file '%s': %s", PLUGIN_NAME, path.c_str(), ec.message().c_str()); + return; + } + _loaded = true; +} + +void +OperatorSetBodyFromFile::initialize_hooks() +{ + add_allowed_hook(TS_REMAP_PSEUDO_HOOK); + add_allowed_hook(TS_HTTP_SEND_RESPONSE_HDR_HOOK); +} + +bool +OperatorSetBodyFromFile::exec(const Resources &res) const +{ + if (!_loaded) { + return true; + } + + std::string content_type; + char *body = nullptr; + + _content_type.append_value(content_type, res); + if (!_body.empty()) { + body = static_cast(TSmalloc(_body.size())); + std::memcpy(body, _body.data(), _body.size()); + } + TSHttpTxnErrorBodySet(res.state.txnp, body, _body.size(), content_type.empty() ? nullptr : TSstrdup(content_type.c_str())); return true; } @@ -1339,8 +1427,8 @@ void OperatorSetBodyFrom::initialize(Parser &p) { Operator::initialize(p); - // we want the arg since body only takes one value _value.set_value(p.get_arg(), this); + _content_type.set_value(p.get_value(), this); require_resources(RSRC_SERVER_RESPONSE_HEADERS); require_resources(RSRC_RESPONSE_STATUS); } @@ -1361,29 +1449,30 @@ OperatorSetBodyFrom::exec(const Resources &res) const return true; } - char req_buf[MAX_SIZE]; - int req_buf_size = 0; - if (createRequestString(_value.get_value(), req_buf, &req_buf_size) == TS_SUCCESS) { + std::string url; + std::string content_type; + char req_buf[MAX_SIZE]; + int req_buf_size = 0; + + _value.append_value(url, res); + _content_type.append_value(content_type, res); + if (createRequestString(url, req_buf, &req_buf_size) == TS_SUCCESS) { TSCont fetchCont = TSContCreate(handleFetchEvents, TSMutexCreate()); - TSContDataSet(fetchCont, static_cast(res.state.txnp)); + TSContDataSet(fetchCont, new SetBodyFromData{res.state.txnp, std::move(content_type)}); TSHttpTxnHookAdd(res.state.txnp, TS_HTTP_TXN_CLOSE_HOOK, fetchCont); TSFetchEvent event_ids; - event_ids.success_event_id = TS_EVENT_FETCHSM_SUCCESS; - event_ids.failure_event_id = TS_EVENT_FETCHSM_FAILURE; - event_ids.timeout_event_id = TS_EVENT_FETCHSM_TIMEOUT; + event_ids.success_event_id = OperatorSetBodyFrom::TS_EVENT_FETCHSM_SUCCESS; + event_ids.failure_event_id = OperatorSetBodyFrom::TS_EVENT_FETCHSM_FAILURE; + event_ids.timeout_event_id = OperatorSetBodyFrom::TS_EVENT_FETCHSM_TIMEOUT; - struct sockaddr_in addr; + struct sockaddr_in addr { + }; addr.sin_family = AF_INET; addr.sin_addr.s_addr = LOCAL_IP_ADDRESS; addr.sin_port = LOCAL_PORT; - TSFetchUrl(static_cast(req_buf), req_buf_size, reinterpret_cast(&addr), fetchCont, - AFTER_BODY, event_ids); - - // Forces original status code in event TSHttpTxnErrorBodySet changed - // the code or another condition was set conflicting with this one. - // Set here because res is the only structure that contains the original status code. + TSFetchUrl(req_buf, req_buf_size, reinterpret_cast(&addr), fetchCont, AFTER_BODY, event_ids); TSHttpTxnStatusSet(res.state.txnp, res.resp_status, PLUGIN_NAME); } else { TSError(PLUGIN_NAME, "OperatorSetBodyFrom:exec:: Could not create request"); diff --git a/plugins/header_rewrite/operators.h b/plugins/header_rewrite/operators.h index 6b19d128de5..87c560d9e0a 100644 --- a/plugins/header_rewrite/operators.h +++ b/plugins/header_rewrite/operators.h @@ -438,6 +438,27 @@ class OperatorSetBody : public Operator private: Value _value; + Value _content_type; +}; + +class OperatorSetBodyFromFile : public Operator +{ +public: + OperatorSetBodyFromFile() { Dbg(dbg_ctl, "Calling CTOR for OperatorSetBodyFromFile"); } + + OperatorSetBodyFromFile(const OperatorSetBodyFromFile &) = delete; + void operator=(const OperatorSetBodyFromFile &) = delete; + + void initialize(Parser &p) override; + +protected: + void initialize_hooks() override; + bool exec(const Resources &res) const override; + +private: + std::string _body; + Value _content_type; + bool _loaded = false; }; class OperatorSetHttpCntl : public Operator @@ -541,6 +562,7 @@ class OperatorSetBodyFrom : public Operator private: Value _value; + Value _content_type; }; class OperatorSetStateFlag : public Operator diff --git a/src/proxy/http/HttpBodyFactory.cc b/src/proxy/http/HttpBodyFactory.cc index cdaaac582d8..9d4108c9426 100644 --- a/src/proxy/http/HttpBodyFactory.cc +++ b/src/proxy/http/HttpBodyFactory.cc @@ -75,9 +75,10 @@ HttpBodyFactory::fabricate_with_old_api(const char *type, HttpTransact::State *c size_t content_language_buf_size, char *content_type_out_buf, size_t content_type_buf_size, int format_size, const char *format) { - char *buffer = nullptr; - const char *lang_ptr = nullptr; - const char *charset_ptr = nullptr; + char *buffer = nullptr; + const char *lang_ptr = nullptr; + const char *charset_ptr = nullptr; + const char *content_type_ptr = nullptr; char url[1024]; const char *set = nullptr; bool found_requested_template = false; @@ -146,8 +147,8 @@ HttpBodyFactory::fabricate_with_old_api(const char *type, HttpTransact::State *c // try to fabricate the desired type of error response // ///////////////////////////////////////////////////////// if (buffer == nullptr) { - buffer = - fabricate(&acpt_language_list, &acpt_charset_list, type, context, resulting_buffer_length, &lang_ptr, &charset_ptr, &set); + buffer = fabricate(&acpt_language_list, &acpt_charset_list, type, context, resulting_buffer_length, &lang_ptr, &charset_ptr, + &content_type_ptr, &set); found_requested_template = (buffer != nullptr); } ///////////////////////////////////////////////////////////// @@ -160,7 +161,7 @@ HttpBodyFactory::fabricate_with_old_api(const char *type, HttpTransact::State *c return nullptr; } buffer = fabricate(&acpt_language_list, &acpt_charset_list, "default", context, resulting_buffer_length, &lang_ptr, - &charset_ptr, &set); + &charset_ptr, &content_type_ptr, &set); } /////////////////////////////////// @@ -182,7 +183,11 @@ HttpBodyFactory::fabricate_with_old_api(const char *type, HttpTransact::State *c if (buffer) { // got an instantiated template if (!plain_flag) { snprintf(content_language_out_buf, content_language_buf_size, "%s", lang_ptr); - snprintf(content_type_out_buf, content_type_buf_size, "text/html; charset=%s", charset_ptr); + if (content_type_ptr) { + snprintf(content_type_out_buf, content_type_buf_size, "%s", content_type_ptr); + } else { + snprintf(content_type_out_buf, content_type_buf_size, "text/html; charset=%s", charset_ptr); + } } if (enable_logging) { @@ -214,8 +219,9 @@ HttpBodyFactory::dump_template_tables(FILE *fp) for (const auto &it1 : *table_of_sets.get()) { HttpBodySet *body_set = static_cast(it1.second); if (body_set) { - fprintf(fp, "set %s: name '%s', lang '%s', charset '%s'\n", it1.first.c_str(), body_set->set_name, - body_set->content_language, body_set->content_charset); + fprintf(fp, "set %s: name '%s', lang '%s', charset '%s', type '%s'\n", it1.first.c_str(), body_set->set_name, + body_set->content_language, body_set->content_charset, + body_set->content_type ? body_set->content_type : "text/html"); /////////////////////////////////////////// // loop over body-types->body hash table // @@ -375,7 +381,7 @@ HttpBodyFactory::~HttpBodyFactory() char * HttpBodyFactory::fabricate(StrList *acpt_language_list, StrList *acpt_charset_list, const char *type, HttpTransact::State *context, int64_t *buffer_length_return, const char **content_language_return, const char **content_charset_return, - const char **set_return) + const char **content_type_return, const char **set_return) { char *buffer; const char *pType = context->txn_conf->body_factory_template_base; @@ -387,6 +393,7 @@ HttpBodyFactory::fabricate(StrList *acpt_language_list, StrList *acpt_charset_li } *content_language_return = nullptr; *content_charset_return = nullptr; + *content_type_return = nullptr; Dbg(dbg_ctl_body_factory, "calling fabricate(type '%s')", type); *buffer_length_return = 0; @@ -443,6 +450,7 @@ HttpBodyFactory::fabricate(StrList *acpt_language_list, StrList *acpt_charset_li *content_language_return = body_set->content_language; *content_charset_return = body_set->content_charset; + *content_type_return = body_set->content_type; // build the custom error page buffer = t->build_instantiated_buffer(context, buffer_length_return); @@ -524,8 +532,9 @@ HttpBodyFactory::determine_set_by_language(std::unique_ptr &table_ is_the_default_set = (strcmp(set_name, "default") == 0); - Dbg(dbg_ctl_body_factory_determine_set, " --- SET: %-8s (Content-Language '%s', Content-Charset '%s')", set_name, - body_set->content_language, body_set->content_charset); + Dbg(dbg_ctl_body_factory_determine_set, " --- SET: %-8s (Content-Language '%s', Content-Charset '%s', Content-Type '%s')", + set_name, body_set->content_language, body_set->content_charset, + body_set->content_type ? body_set->content_type : "text/html"); // if no Accept-Language hdr at all, treat as a wildcard that // slightly prefers "default". @@ -895,6 +904,7 @@ HttpBodySet::HttpBodySet() set_name = nullptr; content_language = nullptr; content_charset = nullptr; + content_type = nullptr; table_of_pages = nullptr; } @@ -904,6 +914,7 @@ HttpBodySet::~HttpBodySet() ats_free(set_name); ats_free(content_language); ats_free(content_charset); + ats_free(content_type); table_of_pages.reset(nullptr); } @@ -992,16 +1003,15 @@ HttpBodySet::init(char *set, char *dir) memcpy(value, value_s, value_e - value_s); value[value_e - value_s] = '\0'; - ////////////////////////////////////////////////// - // so far, we only support 2 pieces of metadata // - ////////////////////////////////////////////////// - if (strcasecmp(name, "Content-Language") == 0) { ats_free(this->content_language); this->content_language = ats_strdup(value); } else if (strcasecmp(name, "Content-Charset") == 0) { ats_free(this->content_charset); this->content_charset = ats_strdup(value); + } else if (strcasecmp(name, "Content-Type") == 0) { + ats_free(this->content_type); + this->content_type = ats_strdup(value); } } diff --git a/src/proxy/http/HttpTransact.cc b/src/proxy/http/HttpTransact.cc index b87412d7b29..76297925a99 100644 --- a/src/proxy/http/HttpTransact.cc +++ b/src/proxy/http/HttpTransact.cc @@ -8605,7 +8605,11 @@ HttpTransact::build_error_response(State *s, HTTPStatus status_code, const char if (len > 0) { s->hdr_info.client_response.value_set(static_cast(MIME_FIELD_CONTENT_TYPE), body_type); s->hdr_info.client_response.value_set(static_cast(MIME_FIELD_CONTENT_LANGUAGE), body_language); + if (s->internal_msg_buffer_type == nullptr) { + s->internal_msg_buffer_type = ats_strdup(body_type); + } } else { + s->internal_msg_buffer_type = static_cast(ats_free_null(s->internal_msg_buffer_type)); s->hdr_info.client_response.field_delete(static_cast(MIME_FIELD_CONTENT_TYPE)); s->hdr_info.client_response.field_delete(static_cast(MIME_FIELD_CONTENT_LANGUAGE)); } diff --git a/tests/gold_tests/body_factory/body_factory_content_type.test.py b/tests/gold_tests/body_factory/body_factory_content_type.test.py new file mode 100644 index 00000000000..dc8510ed9bb --- /dev/null +++ b/tests/gold_tests/body_factory/body_factory_content_type.test.py @@ -0,0 +1,105 @@ +''' +Tests that the Content-Type directive in .body_factory_info is honored +for body factory error responses. +''' +# 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 Content-Type directive in .body_factory_info controls error response MIME type' +Test.ContinueOnFail = True + + +class BodyFactoryContentTypeTest: + """ + Test that the Content-Type directive in .body_factory_info is used for + body factory error responses instead of the hardcoded text/html default. + + Two scenarios: + 1. Default: no Content-Type directive -> text/html; charset=utf-8 + 2. Custom: Content-Type: text/plain -> text/plain + """ + + def __init__(self): + self._setupDefaultTS() + self._setupCustomTS() + + def _setupDefaultTS(self): + """ATS instance with default body factory (no Content-Type directive).""" + self._ts_default = Test.MakeATSProcess("ts_default") + self._ts_default.Disk.records_config.update( + { + 'proxy.config.body_factory.enable_customizations': 1, + 'proxy.config.url_remap.remap_required': 1, + }) + self._ts_default.Disk.remap_config.AddLine('map http://mapped.example.com http://127.0.0.1:65535') + + body_factory_dir = self._ts_default.Variables.BODY_FACTORY_TEMPLATE_DIR + info_path = os.path.join(body_factory_dir, 'default', '.body_factory_info') + self._ts_default.Disk.File(info_path).WriteOn("Content-Language: en\nContent-Charset: utf-8\n") + + def _setupCustomTS(self): + """ATS instance with Content-Type: text/plain in .body_factory_info.""" + self._ts_custom = Test.MakeATSProcess("ts_custom") + self._ts_custom.Disk.records_config.update( + { + 'proxy.config.body_factory.enable_customizations': 1, + 'proxy.config.url_remap.remap_required': 1, + }) + self._ts_custom.Disk.remap_config.AddLine('map http://mapped.example.com http://127.0.0.1:65535') + + body_factory_dir = self._ts_custom.Variables.BODY_FACTORY_TEMPLATE_DIR + info_path = os.path.join(body_factory_dir, 'default', '.body_factory_info') + self._ts_custom.Disk.File(info_path).WriteOn("Content-Type: text/plain\n") + + def run(self): + self._testDefaultContentType() + self._testCustomContentType() + + def _testDefaultContentType(self): + """Without Content-Type directive, error responses should use text/html.""" + tr = Test.AddTestRun('Default body factory Content-Type is text/html') + tr.Processes.Default.StartBefore(self._ts_default) + tr.Processes.Default.Command = ( + f'curl -s -D- -o /dev/null' + f' -H "Host: unmapped.example.com"' + f' http://127.0.0.1:{self._ts_default.Variables.port}/') + tr.Processes.Default.ReturnCode = 0 + tr.Processes.Default.TimeOut = 5 + tr.Processes.Default.Streams.stdout += Testers.ContainsExpression( + '(?i)Content-Type:\\s*text/html\\s*;\\s*charset=utf-8(?:\\s|\\r|$)', + 'Default body factory should produce text/html with charset') + tr.Processes.Default.Streams.stdout += Testers.ContainsExpression('HTTP/1.1 404', 'Unmapped request should get 404') + tr.StillRunningAfter = self._ts_default + + def _testCustomContentType(self): + """With Content-Type: text/plain, error responses should use text/plain.""" + tr = Test.AddTestRun('Custom body factory Content-Type is text/plain') + tr.Processes.Default.StartBefore(self._ts_custom) + tr.Processes.Default.Command = ( + f'curl -s -D- -o /dev/null' + f' -H "Host: unmapped.example.com"' + f' http://127.0.0.1:{self._ts_custom.Variables.port}/') + tr.Processes.Default.ReturnCode = 0 + tr.Processes.Default.TimeOut = 5 + tr.Processes.Default.Streams.stdout += Testers.ContainsExpression( + '(?i)Content-Type:\\s*text/plain(?:\\s|\\r|$)', 'Custom body factory should produce text/plain') + tr.Processes.Default.Streams.stdout += Testers.ContainsExpression('HTTP/1.1 404', 'Unmapped request should get 404') + tr.StillRunningAfter = self._ts_custom + + +BodyFactoryContentTypeTest().run() diff --git a/tests/gold_tests/body_factory/gold/http-204-custom.gold b/tests/gold_tests/body_factory/gold/http-204-custom.gold index a4f3ee12615..38bf30e6e89 100644 --- a/tests/gold_tests/body_factory/gold/http-204-custom.gold +++ b/tests/gold_tests/body_factory/gold/http-204-custom.gold @@ -3,7 +3,7 @@ HTTP/1.1 204 No Content Connection: keep-alive `` Cache-Control: no-store -Content-Type: text/html +Content-Type: text/html; charset=utf-8 Content-Language: en Content-Length: 271 diff --git a/tests/gold_tests/body_factory/gold/http-head-no-origin.gold b/tests/gold_tests/body_factory/gold/http-head-no-origin.gold index 157ee91e03e..f898d9541ea 100644 --- a/tests/gold_tests/body_factory/gold/http-head-no-origin.gold +++ b/tests/gold_tests/body_factory/gold/http-head-no-origin.gold @@ -3,7 +3,7 @@ HTTP/1.1 404 Not Found Connection: keep-alive `` Cache-Control: no-store -Content-Type: text/html +Content-Type: text/html; charset=utf-8 Content-Language: en Content-Length: 297 diff --git a/tests/gold_tests/headers/general-connection-failure-502.gold b/tests/gold_tests/headers/general-connection-failure-502.gold index 836a41d63f7..74b13e87462 100644 --- a/tests/gold_tests/headers/general-connection-failure-502.gold +++ b/tests/gold_tests/headers/general-connection-failure-502.gold @@ -1,7 +1,7 @@ HTTP/1.1 502 `` Connection: keep-alive Cache-Control: no-store -Content-Type: text/html +Content-Type: text/html; charset=utf-8 Content-Language: en Content-Length: 247 diff --git a/tests/gold_tests/headers/gold/bad_protocol_number.gold b/tests/gold_tests/headers/gold/bad_protocol_number.gold index 6f16cc17029..3ed14105d3d 100644 --- a/tests/gold_tests/headers/gold/bad_protocol_number.gold +++ b/tests/gold_tests/headers/gold/bad_protocol_number.gold @@ -2,7 +2,7 @@ HTTP/1.1 505 Unsupported HTTP Version Date: `` Server: ATS/`` Cache-Control: no-store -Content-Type: text/html +Content-Type: text/html; charset=utf-8 Content-Language: en Content-Length: 219 diff --git a/tests/gold_tests/headers/gold/bad_te_value.gold b/tests/gold_tests/headers/gold/bad_te_value.gold index 7fae2c54bc4..348d925d70b 100644 --- a/tests/gold_tests/headers/gold/bad_te_value.gold +++ b/tests/gold_tests/headers/gold/bad_te_value.gold @@ -3,7 +3,7 @@ Date: `` Connection: keep-alive Server: ATS/`` Cache-Control: no-store -Content-Type: text/html +Content-Type: text/html; charset=utf-8 Content-Language: en Content-Length: 289 diff --git a/tests/gold_tests/headers/gold/invalid_character_in_te_value.gold b/tests/gold_tests/headers/gold/invalid_character_in_te_value.gold index 30a27d819b0..920678d6faa 100644 --- a/tests/gold_tests/headers/gold/invalid_character_in_te_value.gold +++ b/tests/gold_tests/headers/gold/invalid_character_in_te_value.gold @@ -3,7 +3,7 @@ Date:`` Connection: close Server:`` Cache-Control: no-store -Content-Type: text/html +Content-Type: text/html; charset=utf-8 Content-Language: en Content-Length:`` diff --git a/tests/gold_tests/ip_allow/gold/log.gold b/tests/gold_tests/ip_allow/gold/log.gold index e4bc2108b8a..07bb8874e21 100644 --- a/tests/gold_tests/ip_allow/gold/log.gold +++ b/tests/gold_tests/ip_allow/gold/log.gold @@ -1,3 +1,3 @@ scheme=http 127.0.0.1 TCP_MISS/200 130 GET https://127.0.0.1:SOMEPORT/get DIRECT - - - 127.0.0.1:SOMEPORT - sftover=- sftmat=- sftcls=- sftbadclf=- yra=- status_setter=- -scheme=UNKNOWN 127.0.0.1 ERR_PROXY_DENIED/403 453 CONNECT 127.0.0.1:SOMEPORT/connect DIRECT text/html - - 127.0.0.1:SOMEPORT - sftover=- sftmat=- sftcls=- sftbadclf=- yra=- status_setter=ip_allow -scheme=https 127.0.0.1 ERR_PROXY_DENIED/403 453 PUSH https://127.0.0.1:SOMEPORT/h2_push DIRECT text/html - - 127.0.0.1:SOMEPORT - sftover=- sftmat=- sftcls=- sftbadclf=- yra=- status_setter=ip_allow +scheme=UNKNOWN 127.0.0.1 ERR_PROXY_DENIED/403 468 CONNECT 127.0.0.1:SOMEPORT/connect DIRECT text/html - - 127.0.0.1:SOMEPORT - sftover=- sftmat=- sftcls=- sftbadclf=- yra=- status_setter=ip_allow +scheme=https 127.0.0.1 ERR_PROXY_DENIED/403 468 PUSH https://127.0.0.1:SOMEPORT/h2_push DIRECT text/html - - 127.0.0.1:SOMEPORT - sftover=- sftmat=- sftcls=- sftbadclf=- yra=- status_setter=ip_allow diff --git a/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_200.gold b/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_200.gold index 3e37495f65b..b65f8f49861 100644 --- a/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_200.gold +++ b/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_200.gold @@ -1 +1 @@ -Custom body found +{"error": "custom"} diff --git a/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_success.gold b/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_success.gold index 3e37495f65b..b65f8f49861 100644 --- a/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_success.gold +++ b/tests/gold_tests/pluginTest/header_rewrite/gold/header_rewrite-set_body_from_success.gold @@ -1 +1 @@ -Custom body found +{"error": "custom"} diff --git a/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_bundle.replay.yaml b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_bundle.replay.yaml index c67ad080b6a..4fb6115be25 100644 --- a/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_bundle.replay.yaml +++ b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_bundle.replay.yaml @@ -197,6 +197,13 @@ autest: args: - "rules/rule_cidr.conf" + - from: "http://www.example.com/from_21/" + to: "http://backend.ex:{SERVER_HTTP_PORT}/to_21/" + plugins: + - name: "header_rewrite.so" + args: + - "rules/rule_json_body.conf" + metric_checks: - metric: "proxy.process.plugin.header_rewrite.operators" min: 1 @@ -2343,3 +2350,33 @@ sessions: - [ X-Cidr-0, { value: "0.0.0.0", as: equal } ] - [ X-Cidr-Two-Mask, { value: "127.0.0.0", as: equal } ] - [ X-Cidr-Empty-V4, { value: "0.0.0.0", as: equal } ] + +# Test 90: set-body emits formatted JSON with its configured MIME type. +- transactions: + - client-request: + method: "GET" + version: "1.1" + url: /from_21/ + headers: + fields: + - [ Host, www.example.com ] + - [ uuid, 90 ] + + server-response: + status: 200 + reason: OK + headers: + fields: + - [ Connection, close ] + content: + data: "ATS should not serve this body" + + proxy-response: + status: 400 + headers: + fields: + - [ Content-Type, { value: "application/problem+json", as: equal } ] + - [ Content-Length, { value: "25", as: equal } ] + content: + size: 25 + data: "{\"error\": \"bad request\"}\n" diff --git a/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_from.test.py b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_from.test.py index bb03d7c3e60..77c49fc6a0a 100644 --- a/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_from.test.py +++ b/tests/gold_tests/pluginTest/header_rewrite/header_rewrite_set_body_from.test.py @@ -52,9 +52,15 @@ def setUpOriginServer(self): plugin_success_1_request_header = {"headers": "GET /plugin_success HTTP/1.1\r\nHost: www.example.com\r\n\r\n"} self.server.addResponse("sessionfile.log", plugin_success_1_request_header, response_header) + plugin_override_1_request_header = {"headers": "GET /plugin_override HTTP/1.1\r\nHost: www.example.com\r\n\r\n"} + self.server.addResponse("sessionfile.log", plugin_override_1_request_header, response_header) + # Request/response for custom body transaction that successfully retrieves body success_2_request_header = {"headers": "GET /404.html HTTP/1.1\r\nHost: www.example.com\r\n\r\n"} - success_2_response_header = {"headers": "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n", "body": "Custom body found\n"} + success_2_response_header = { + "headers": "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n", + "body": '{"error": "custom"}\n', + } self.server.addResponse("sessionfile.log", success_2_request_header, success_2_response_header) # Request/response for original transaction that triggers binary set-body-from @@ -76,6 +82,8 @@ def setUpTS(self): # Set header rewrite rules self.ts.Setup.CopyAs('rules/rule_set_body_from_remap.conf', Test.RunDirectory) self.ts.Setup.CopyAs('rules/rule_set_body_from_plugin.conf', Test.RunDirectory) + self.ts.Setup.CopyAs('rules/rule_set_body_from_file.conf', Test.RunDirectory) + self.ts.Setup.CopyAs('gold/header_rewrite-set_body_from_success.gold', Test.RunDirectory) self.ts.Disk.remap_config.AddLine( """\ @@ -85,6 +93,8 @@ def setUpTS(self): map http://www.example.com/remap_binary http://127.0.0.1:{0}/remap_binary @plugin=header_rewrite.so @pparam={1}/rule_set_body_from_remap.conf map http://www.example.com/binary_body http://127.0.0.1:{0}/binary_body map http://www.example.com/plugin_success http://127.0.0.1:{0}/plugin_success + map http://www.example.com/plugin_override http://127.0.0.1:{0}/plugin_override + map http://www.example.com/file http://127.0.0.1:{0}/file @plugin=header_rewrite.so @pparam={1}/rule_set_body_from_file.conf map http://www.example.com/plugin_fail http://127.0.0.1:{0}/plugin_fail map http://www.example.com/404.html http://127.0.0.1:{0}/404.html map http://www.example.com/plugin_no_server http://127.0.0.1::{2}/plugin_no_server @@ -118,6 +128,31 @@ def test_setBodyFromSucceeds_remap(self): tr.Processes.Default.ReturnCode = 0 tr.Processes.Default.Streams.stdout = "gold/header_rewrite-set_body_from_success.gold" tr.Processes.Default.Streams.stderr.Content = Testers.ContainsExpression("404 Not Found", "Expected 404 response") + tr.Processes.Default.Streams.stderr.Content += Testers.ContainsExpression( + "(?i)< content-type: application/json", "Expected the fetched Content-Type") + tr.StillRunningAfter = self.server + + def test_setBodyFromContentTypeOverride(self) -> None: + '''Test that an explicit set-body-from Content-Type overrides the fetched value.''' + tr = Test.AddTestRun() + tr.MakeCurlCommand( + '-s -v --proxy 127.0.0.1:{0} "http://www.example.com/plugin_override"'.format(self.ts.Variables.port), ts=self.ts) + tr.Processes.Default.ReturnCode = 0 + tr.Processes.Default.Streams.stdout = "gold/header_rewrite-set_body_from_success.gold" + tr.Processes.Default.Streams.stderr.Content = Testers.ContainsExpression("404 Not Found", "Expected 404 response") + tr.Processes.Default.Streams.stderr.Content += Testers.ContainsExpression( + "(?i)< content-type: application/problem\\+json", "Expected the configured Content-Type override") + tr.StillRunningAfter = self.server + + def test_setBodyFromFile(self) -> None: + '''Test that a local file supplies the exact response body.''' + tr = Test.AddTestRun() + tr.MakeCurlCommand('-s -v --proxy 127.0.0.1:{0} "http://www.example.com/file"'.format(self.ts.Variables.port), ts=self.ts) + tr.Processes.Default.ReturnCode = 0 + tr.Processes.Default.Streams.stdout = "gold/header_rewrite-set_body_from_success.gold" + tr.Processes.Default.Streams.stderr.Content = Testers.ContainsExpression("403 Forbidden", "Expected 403 response") + tr.Processes.Default.Streams.stderr.Content += Testers.ContainsExpression( + "(?i)< content-type: application/json", "Expected the configured local file Content-Type") tr.StillRunningAfter = self.server def test_setBodyFromSucceeds_plugin(self): @@ -131,6 +166,8 @@ def test_setBodyFromSucceeds_plugin(self): tr.Processes.Default.ReturnCode = 0 tr.Processes.Default.Streams.stdout = "gold/header_rewrite-set_body_from_success.gold" tr.Processes.Default.Streams.stderr.Content = Testers.ContainsExpression("404 Not Found", "Expected 404 response") + tr.Processes.Default.Streams.stderr.Content += Testers.ContainsExpression( + "(?i)< content-type: application/json", "Expected the fetched Content-Type") tr.StillRunningAfter = self.server def test_setBodyFromFails_plugin(self): @@ -193,6 +230,8 @@ def runTraffic(self): self.test_setBodyFromFails_remap() self.test_setBodyFromSucceeds_remap() self.test_setBodyFromSucceeds_plugin() + self.test_setBodyFromContentTypeOverride() + self.test_setBodyFromFile() self.test_setBodyFromFails_plugin() self.test_setBodyFromSucceeds_200() self.test_setBodyFromBinary() diff --git a/tests/gold_tests/pluginTest/header_rewrite/rules/rule_json_body.conf b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_json_body.conf new file mode 100644 index 00000000000..2adad7999ec --- /dev/null +++ b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_json_body.conf @@ -0,0 +1,20 @@ +# +# 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. + +cond %{REMAP_PSEUDO_HOOK} + set-status 400 + set-body "{\"error\": \"bad request\"}\n" "application/problem+json" diff --git a/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_file.conf b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_file.conf new file mode 100644 index 00000000000..53e80185ed8 --- /dev/null +++ b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_file.conf @@ -0,0 +1,20 @@ +# +# 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. + +cond %{REMAP_PSEUDO_HOOK} +set-status 403 +set-body-from-file header_rewrite-set_body_from_success.gold "application/json" diff --git a/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_plugin.conf b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_plugin.conf index c4c83abb17d..b683c23c8fc 100644 --- a/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_plugin.conf +++ b/tests/gold_tests/pluginTest/header_rewrite/rules/rule_set_body_from_plugin.conf @@ -21,6 +21,10 @@ cond %{READ_RESPONSE_HDR_HOOK} cond %{CLIENT-URL:PATH} = "plugin_success" set-body-from http://www.example.com/404.html +cond %{READ_RESPONSE_HDR_HOOK} +cond %{CLIENT-URL:PATH} = "plugin_override" +set-body-from http://www.example.com/404.html "application/problem+json" + cond %{READ_RESPONSE_HDR_HOOK} cond %{CLIENT-URL:PATH} = "plugin_fail" set-body-from http://www.example.com/plugin_no_server diff --git a/tests/gold_tests/pluginTest/statichit/statichit.replay.yaml b/tests/gold_tests/pluginTest/statichit/statichit.replay.yaml index e8d680882ba..a72d143ff89 100644 --- a/tests/gold_tests/pluginTest/statichit/statichit.replay.yaml +++ b/tests/gold_tests/pluginTest/statichit/statichit.replay.yaml @@ -65,7 +65,7 @@ sessions: headers: fields: - [ Cache-Control, { value: no-store, as: equal } ] - - [ Content-Type, { value: text/html, as: equal } ] + - [ Content-Type, { value: "text/html; charset=utf-8", as: equal } ] - [ Server, { value: ATS, as: contains } ] content: encoding: plain diff --git a/tests/gold_tests/pluginTest/xdebug/x_cache_info/out.gold b/tests/gold_tests/pluginTest/xdebug/x_cache_info/out.gold index 94dffe672cc..2b4992810e0 100644 --- a/tests/gold_tests/pluginTest/xdebug/x_cache_info/out.gold +++ b/tests/gold_tests/pluginTest/xdebug/x_cache_info/out.gold @@ -3,7 +3,7 @@ Date:`` Connection: keep-alive Server:`` Cache-Control: no-store -Content-Type: text/html +Content-Type: text/html; charset=utf-8 Content-Language: en Content-Length: 391 diff --git a/tests/gold_tests/pluginTest/xdebug/x_effective_url/out.gold b/tests/gold_tests/pluginTest/xdebug/x_effective_url/out.gold index 19346ca02c3..d590d2cc0a2 100644 --- a/tests/gold_tests/pluginTest/xdebug/x_effective_url/out.gold +++ b/tests/gold_tests/pluginTest/xdebug/x_effective_url/out.gold @@ -3,7 +3,7 @@ Date: `` Connection: close Server: ATS/`` Cache-Control: no-store -Content-Type: text/html +Content-Type: text/html; charset=utf-8 Content-Language: en X-Effective-URL: "http://none/argh" Content-Length: 391 diff --git a/tests/gold_tests/pluginTest/xdebug/x_remap/out.gold b/tests/gold_tests/pluginTest/xdebug/x_remap/out.gold index fd62fbb88fc..bf4e80264e2 100644 --- a/tests/gold_tests/pluginTest/xdebug/x_remap/out.gold +++ b/tests/gold_tests/pluginTest/xdebug/x_remap/out.gold @@ -3,7 +3,7 @@ Date: `` Connection: close Server: ATS/`` Cache-Control: no-store -Content-Type: text/html +Content-Type: text/html; charset=utf-8 Content-Language: en X-Remap: from=Not-Found, to=Not-Found X-Original-Content-Type: text/html; charset=utf-8 diff --git a/tests/gold_tests/remap/gold/remap-404.gold b/tests/gold_tests/remap/gold/remap-404.gold index 133518e2d02..b4006b8498d 100644 --- a/tests/gold_tests/remap/gold/remap-404.gold +++ b/tests/gold_tests/remap/gold/remap-404.gold @@ -8,5 +8,5 @@ < Proxy-Connection: keep-alive < Server: ATS/`` `` -< Content-Type: text/html +< Content-Type: text/html; charset=utf-8 `` diff --git a/tests/gold_tests/remap/gold/remap-hitATS-404.gold b/tests/gold_tests/remap/gold/remap-hitATS-404.gold index 67a81bc4f9a..6fbc9587494 100644 --- a/tests/gold_tests/remap/gold/remap-hitATS-404.gold +++ b/tests/gold_tests/remap/gold/remap-hitATS-404.gold @@ -7,5 +7,5 @@ < Date: `` < Connection: `` < Server: ATS/`` -< Content-Type: text/html +< Content-Type: text/html; charset=utf-8 `` diff --git a/tests/gold_tests/remap_yaml/gold/remap-404.gold b/tests/gold_tests/remap_yaml/gold/remap-404.gold index 133518e2d02..b4006b8498d 100644 --- a/tests/gold_tests/remap_yaml/gold/remap-404.gold +++ b/tests/gold_tests/remap_yaml/gold/remap-404.gold @@ -8,5 +8,5 @@ < Proxy-Connection: keep-alive < Server: ATS/`` `` -< Content-Type: text/html +< Content-Type: text/html; charset=utf-8 `` diff --git a/tests/gold_tests/remap_yaml/gold/remap-hitATS-404.gold b/tests/gold_tests/remap_yaml/gold/remap-hitATS-404.gold index 67a81bc4f9a..6fbc9587494 100644 --- a/tests/gold_tests/remap_yaml/gold/remap-hitATS-404.gold +++ b/tests/gold_tests/remap_yaml/gold/remap-hitATS-404.gold @@ -7,5 +7,5 @@ < Date: `` < Connection: `` < Server: ATS/`` -< Content-Type: text/html +< Content-Type: text/html; charset=utf-8 `` diff --git a/tools/hrw4u/README.md b/tools/hrw4u/README.md index cd2dfdbe2dd..b994d412fdb 100644 --- a/tools/hrw4u/README.md +++ b/tools/hrw4u/README.md @@ -149,6 +149,27 @@ REMAP { Passing the output from hrw4u back into u4wrh brings back the original script again! +### JSON response bodies + +Use the two-argument `set-body` form to set body content and its MIME type +together. Double braces delimit literal brace content, so JSON object braces +are not interpreted as HRW4U interpolation: + +``` +REMAP { + http.status = 400; + set-body("{{"error": "bad request"}}\n", "application/problem+json"); +} +``` + +`set-body-from(url)` preserves the fetched response's `Content-Type`. Pass a +second argument, `set-body-from(url, content_type)`, to override it. This +function is available in the `READ_RESPONSE` section. + +Use `set-body-from-file(path, content_type)` in `REMAP` or `SEND_RESPONSE` to +load a local body when the rule configuration is loaded. Relative paths are +resolved from the directory containing the rule file. + ## Build System The project uses a hybrid build system: diff --git a/tools/hrw4u/grammar/hrw4u.g4 b/tools/hrw4u/grammar/hrw4u.g4 index 335a3817b30..2cb4db5f2ee 100644 --- a/tools/hrw4u/grammar/hrw4u.g4 +++ b/tools/hrw4u/grammar/hrw4u.g4 @@ -36,7 +36,7 @@ PROCEDURE : 'procedure'; REGEX : '/' ( '\\/' | ~[/\r\n] )* '/' ; STRING : '"' ( ESCAPED_BLOCK | '\\' . | ~["\\\r\n] )* '"' ; -// {{ ... }} is an escape hatch — contents are passed through verbatim, inner quotes allowed +// {{ ... }} delimits literal brace content, allowing inner quotes without escaping. fragment ESCAPED_BLOCK : '{{' ( ~'}' | '}' ~'}' )* '}}'; IPV4_LITERAL diff --git a/tools/hrw4u/schema/sandbox.schema.json b/tools/hrw4u/schema/sandbox.schema.json index 516064ceee1..0e106dc4401 100644 --- a/tools/hrw4u/schema/sandbox.schema.json +++ b/tools/hrw4u/schema/sandbox.schema.json @@ -62,7 +62,9 @@ "no-op", "remove_query", "run-plugin", + "set-body", "set-body-from", + "set-body-from-file", "set-config", "set-debug", "set-plugin-cntl", diff --git a/tools/hrw4u/src/hrw_symbols.py b/tools/hrw4u/src/hrw_symbols.py index 0a0339b2d0d..1940972ab95 100644 --- a/tools/hrw4u/src/hrw_symbols.py +++ b/tools/hrw4u/src/hrw_symbols.py @@ -504,6 +504,9 @@ def op_to_hrw4u(self, cmd: str, args: list[str], section: SectionType | None, op rewritten_value = self._rewrite_inline_percents(value, section) return f"{var_name} = {rewritten_value}" + if cmd == "set-body" and len(args) == 2: + return self._handle_statement_function("set-body", args, section, op_state) + for lhs_key, params in tables.OPERATOR_MAP.items(): commands = params.target if params else None if (isinstance(commands, (list, tuple)) and cmd in commands) or (cmd == commands): diff --git a/tools/hrw4u/src/lsp/documentation.py b/tools/hrw4u/src/lsp/documentation.py index f09e28de12a..6d07b780c95 100644 --- a/tools/hrw4u/src/lsp/documentation.py +++ b/tools/hrw4u/src/lsp/documentation.py @@ -1012,18 +1012,49 @@ def create_regex_hover(regex_data: dict[str, str], brief: bool = False) -> str: "set-redirect(302, \"https://example.com/new-path\");", "set-redirect(301, \"https://secure.example.com{inbound.url.path}\");" ]), + "set-body": + FunctionDoc( + name="Set Body Function", + category="Statement Function", + description="Sets the response body and, optionally, its Content-Type.", + syntax="set-body(content[, content_type])", + maps_to="set-body", + usage_context="Used as a statement in response processing sections", + parameters=[ + ParameterDoc("content", "string", "Response body content"), + ParameterDoc("content_type", "string", "Optional MIME type for the response") + ], + examples=[ + "set-body(\"Not found\", \"text/plain\");", 'set-body("{{"error": "not found"}}", "application/problem+json");' + ]), "set-body-from": FunctionDoc( name="Set Body From URL Function", category="Statement Function", description="Sets the response body by fetching content from the specified URL. Used to replace the response body with content from an external source.", - syntax="set-body-from(source_url)", + syntax="set-body-from(source_url[, content_type])", maps_to="set-response-body-from-url", - usage_context="Used as a statement in code blocks to replace response content", - parameters=[ParameterDoc("source_url", "string", "URL to fetch the response body content from")], + usage_context="Used in READ_RESPONSE to replace response content", + parameters=[ + ParameterDoc("source_url", "string", "URL to fetch the response body content from"), + ParameterDoc("content_type", "string", "Optional MIME type overriding the fetched Content-Type") + ], examples=[ "set-body-from(\"https://errors.example.com/500.html\");", "set-body-from(\"http://content.example.com/body.txt\");" ]), + "set-body-from-file": + FunctionDoc( + name="Set Body From File Function", + category="Statement Function", + description="Sets the response body from a file loaded when the rule configuration is loaded.", + syntax="set-body-from-file(path[, content_type])", + maps_to="set-body-from-file", + usage_context="Used in REMAP or SEND_RESPONSE to serve local body content", + parameters=[ + ParameterDoc("path", "string", "Absolute path or path relative to the rule file"), + ParameterDoc("content_type", "string", "Optional MIME type for the response") + ], + examples=['set-body-from-file("errors/forbidden.json", "application/json");']), "run-plugin": FunctionDoc( name="Run Plugin Function", @@ -1075,7 +1106,7 @@ def create_regex_hover(regex_data: dict[str, str], brief: bool = False) -> str: LSP_STRING_LITERAL_INFO: Final[dict[str, str]] = { "name": "String Literal", "description": - "String values in HRW4U support variable interpolation using {variable} syntax. Variables and function calls can be embedded within strings for dynamic content generation. Strings must be enclosed in double quotes when they contain spaces or special characters." + "String values in HRW4U support variable interpolation using {variable} syntax. Variables and function calls can be embedded within strings for dynamic content generation. Enclose literal brace-delimited content such as a JSON object in double braces: {{\"error\": \"not found\"}}. Strings must be enclosed in double quotes when they contain spaces or special characters." } # Remove redundant LSP_HEADER_CONTEXTS - now covered by LSP_SUB_NAMESPACE_DOCUMENTATION @@ -1085,10 +1116,12 @@ def create_regex_hover(regex_data: dict[str, str], brief: bool = False) -> str: "inbound.resp.body": DocumentationInfo( name="Inbound Response Body", - description="Sets the response body content that will be sent to the client. Can be used to provide custom response content.", + description="Sets the response body content that will be sent to the client. Use set-body() when a Content-Type is also needed.", context="Response Body Assignment", usage="Used to set custom response body content in response processing sections", - examples=["inbound.resp.body = \"Custom error message\";", "inbound.resp.body = \"I am a teapot, rewritten\";"]), + examples=[ + "inbound.resp.body = \"Custom error message\";", 'set-body("{{"error": "bad request"}}", "application/json");' + ]), "outbound.resp.body": DocumentationInfo( name="Outbound Response Body", diff --git a/tools/hrw4u/src/tables.py b/tools/hrw4u/src/tables.py index 86d2de67a79..58ccc48ff4b 100644 --- a/tools/hrw4u/src/tables.py +++ b/tools/hrw4u/src/tables.py @@ -64,7 +64,9 @@ "remove_query": MapParams(target="rm-destination QUERY", validate=Validator.arg_count(1).quoted_or_simple(), sections=HTTP_SECTIONS), "keep_query": MapParams(target="rm-destination QUERY", validate=Validator.arg_count(1).quoted_or_simple(), sections=HTTP_SECTIONS), "run-plugin": MapParams(target="run-plugin", validate=Validator.min_args(1).quoted_or_simple(), sections=HTTP_SECTIONS), - "set-body-from": MapParams(target="set-body-from", validate=Validator.arg_count(1).quoted_or_simple(), sections=HTTP_SECTIONS), + "set-body": MapParams(target="set-body", validate=Validator.arg_count_range(1, 2).quoted_or_simple(), sections=HTTP_SECTIONS), + "set-body-from-file": MapParams(target="set-body-from-file", validate=Validator.arg_count_range(1, 2).quoted_or_simple(), sections={SectionType.REMAP, SectionType.SEND_RESPONSE}), + "set-body-from": MapParams(target="set-body-from", validate=Validator.arg_count_range(1, 2).quoted_or_simple(), sections={SectionType.READ_RESPONSE}), "set-config": MapParams(target="set-config", validate=Validator.arg_count(2).quoted_or_simple(), sections=HTTP_SECTIONS), "set-redirect": MapParams(target="set-redirect", validate=Validator.arg_count(2).arg_at(0, Validator.range(300, 399)).arg_at(1, Validator.quoted_or_simple()), sections=HTTP_SECTIONS), "skip-remap": MapParams(target="skip-remap", validate=Validator.arg_count(1).suffix_group(SuffixGroup.BOOL_FIELDS)._add(Validator.normalize_arg_at(0)), sections={SectionType.PRE_REMAP, SectionType.REMAP, SectionType.READ_REQUEST}), diff --git a/tools/hrw4u/src/types.py b/tools/hrw4u/src/types.py index 97c28438bea..98dd7a66096 100644 --- a/tools/hrw4u/src/types.py +++ b/tools/hrw4u/src/types.py @@ -37,6 +37,7 @@ class MagicStrings(str, Enum): SET_STATUS_REASON = "set-status-reason" SET_BODY = "set-body" SET_BODY_FROM = "set-body-from" + SET_BODY_FROM_FILE = "set-body-from-file" NO_OP = "no-op" SET_DEBUG = "set-debug" SET_CONFIG = "set-config" diff --git a/tools/hrw4u/src/validation.py b/tools/hrw4u/src/validation.py index 1b6d35e4410..85bb3dc1e74 100644 --- a/tools/hrw4u/src/validation.py +++ b/tools/hrw4u/src/validation.py @@ -57,6 +57,9 @@ def validator(args: list[str]) -> None: def arg_count(self, counts: int) -> 'ValidatorChain': return self._add(Validator.arg_count(counts)) + def arg_count_range(self, minimum: int, maximum: int) -> 'ValidatorChain': + return self._add(Validator.arg_count_range(minimum, maximum)) + def min_args(self, count: int) -> 'ValidatorChain': return self._add(Validator.min_args(count)) @@ -109,6 +112,16 @@ def validator(args: list[str]) -> None: return ValidatorChain([validator]) + @staticmethod + def arg_count_range(minimum: int, maximum: int) -> 'ValidatorChain': + + def validator(args: list[str]) -> None: + if not minimum <= len(args) <= maximum: + raise SymbolResolutionError( + str(args), f"Invalid number of arguments (expected {minimum}-{maximum}, got {len(args)})") + + return ValidatorChain([validator]) + @staticmethod def min_args(count: int) -> ValidatorChain: diff --git a/tools/hrw4u/src/visitor.py b/tools/hrw4u/src/visitor.py index f3fb38c3a39..51a62c35894 100644 --- a/tools/hrw4u/src/visitor.py +++ b/tools/hrw4u/src/visitor.py @@ -204,7 +204,17 @@ def _substitute_strings(self, s: str, ctx) -> str: def repl(m: re.Match) -> str: try: if m.group("escaped"): - return m.group("escaped") + payload = m.group("escaped")[2:-2] + escaped_payload = [] + preceding_backslashes = 0 + + for char in payload: + if char == '"' and preceding_backslashes % 2 == 0: + escaped_payload.append('\\') + escaped_payload.append(char) + preceding_backslashes = preceding_backslashes + 1 if char == '\\' else 0 + + return "{" + "".join(escaped_payload) + "}" if m.group("func"): func_name = m.group("func").strip() arg_str = m.group("args").strip() diff --git a/tools/hrw4u/tests/data/ops/exceptions.txt b/tools/hrw4u/tests/data/ops/exceptions.txt index d954a6ae38a..a991b1217a7 100644 --- a/tools/hrw4u/tests/data/ops/exceptions.txt +++ b/tools/hrw4u/tests/data/ops/exceptions.txt @@ -7,3 +7,5 @@ qsa.input: u4wrh header_value_context.input: u4wrh # HTTP-CNTL valid bools can not reverse back to the original input http_cntl_valid_bools.input: hrw4u +# Literal JSON blocks compile to native header_rewrite escaping. +json-body.input: hrw4u diff --git a/tools/hrw4u/tests/data/ops/json-body.input.txt b/tools/hrw4u/tests/data/ops/json-body.input.txt new file mode 100644 index 00000000000..4e6d572c1c0 --- /dev/null +++ b/tools/hrw4u/tests/data/ops/json-body.input.txt @@ -0,0 +1,8 @@ +REMAP { + set-body("{{"error": "bad request", "detail": {"code": 400}}}\n", "application/problem+json"); + set-body-from-file("errors/forbidden.json", "application/json"); +} + +READ_RESPONSE { + set-body-from("http://errors.example.com/not-found", "application/json"); +} diff --git a/tools/hrw4u/tests/data/ops/json-body.output.txt b/tools/hrw4u/tests/data/ops/json-body.output.txt new file mode 100644 index 00000000000..c998a85e13d --- /dev/null +++ b/tools/hrw4u/tests/data/ops/json-body.output.txt @@ -0,0 +1,6 @@ +cond %{REMAP_PSEUDO_HOOK} [AND] + set-body "{\"error\": \"bad request\", \"detail\": {\"code\": 400}}\n" "application/problem+json" + set-body-from-file "errors/forbidden.json" "application/json" + +cond %{READ_RESPONSE_HDR_HOOK} [AND] + set-body-from "http://errors.example.com/not-found" "application/json"