From f4ff90d12f4c92b0264b787a5696f1bec2630baa Mon Sep 17 00:00:00 2001 From: Bryan Call Date: Fri, 6 Mar 2026 14:28:34 -0800 Subject: [PATCH 1/3] Add Content-Type directive to body factory .body_factory_info The body factory previously only supported Content-Language and Content-Charset directives in .body_factory_info. This adds a Content-Type directive so operators can control the MIME type of error responses (e.g. text/plain instead of the default text/html). Also fixes HttpSM::setup_internal_transfer which was unconditionally overwriting Content-Type to text/html, masking any type set by the body factory. Now uses the presence bit check to only set the default when Content-Type is not already present in the response. --- .../body_factory/default/.body_factory_info | 21 ++-- .../monitoring/error-messages.en.rst | 39 +++++++ include/proxy/http/HttpBodyFactory.h | 3 +- src/proxy/http/HttpBodyFactory.cc | 33 +++--- src/proxy/http/HttpSM.cc | 2 +- .../body_factory_content_type.test.py | 104 ++++++++++++++++++ 6 files changed, 180 insertions(+), 22 deletions(-) create mode 100644 tests/gold_tests/body_factory/body_factory_content_type.test.py diff --git a/configs/body_factory/default/.body_factory_info b/configs/body_factory/default/.body_factory_info index e07e02197de..7cb590ea112 100644 --- a/configs/body_factory/default/.body_factory_info +++ b/configs/body_factory/default/.body_factory_info @@ -3,14 +3,21 @@ # 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 Natural language of the error pages (default: en) +# Content-Charset Character encoding (default: utf-8) +# Content-Type MIME type for the response (default: text/html) +# +# For example, to describe Korean web pages encoded in the +# iso-2022-kr character set, you might add these lines: # # Content-Language: 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. diff --git a/doc/admin-guide/monitoring/error-messages.en.rst b/doc/admin-guide/monitoring/error-messages.en.rst index eb472272fbb..06ff8a3916e 100644 --- a/doc/admin-guide/monitoring/error-messages.en.rst +++ b/doc/admin-guide/monitoring/error-messages.en.rst @@ -108,6 +108,45 @@ 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 may contain a :file:`.body_factory_info` file that provides metadata +about the error pages in that directory. 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. This value is appended to the ``Content-Type`` header + as a ``charset`` parameter. Default: ``utf-8``. + +``Content-Type`` + The MIME type for the error response. This controls the media type portion of the ``Content-Type`` + HTTP response header. Default: ``text/html``. + +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; charset=utf-8``. + +To describe Korean error pages encoded in the ``iso-2022-kr`` character set:: + + Content-Language: kr + Content-Charset: iso-2022-kr + +If the file is empty, contains only comments, or is absent, the defaults are used: English +``text/html`` in the ``utf-8`` character set. + The following table lists the hard-coded Traffic Server HTTP messages, with corresponding HTTP response codes and customizable files. 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/src/proxy/http/HttpBodyFactory.cc b/src/proxy/http/HttpBodyFactory.cc index 30a7d2e2d64..3889703381f 100644 --- a/src/proxy/http/HttpBodyFactory.cc +++ b/src/proxy/http/HttpBodyFactory.cc @@ -77,6 +77,7 @@ HttpBodyFactory::fabricate_with_old_api(const char *type, HttpTransact::State *c char *buffer = nullptr; const char *lang_ptr = nullptr; const char *charset_ptr = nullptr; + const char *type_ptr = nullptr; char url[1024]; const char *set = nullptr; bool found_requested_template = false; @@ -145,8 +146,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, + &type_ptr, &set); found_requested_template = (buffer != nullptr); } ///////////////////////////////////////////////////////////// @@ -159,7 +160,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, &type_ptr, &set); } /////////////////////////////////// @@ -181,7 +182,8 @@ 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); + const char *mime_type = type_ptr ? type_ptr : "text/html"; + snprintf(content_type_out_buf, content_type_buf_size, "%s; charset=%s", mime_type, charset_ptr); } if (enable_logging) { @@ -213,8 +215,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 // @@ -374,7 +377,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; @@ -386,6 +389,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; @@ -442,6 +446,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); @@ -523,8 +528,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". @@ -894,6 +900,7 @@ HttpBodySet::HttpBodySet() set_name = nullptr; content_language = nullptr; content_charset = nullptr; + content_type = nullptr; table_of_pages = nullptr; } @@ -903,6 +910,7 @@ HttpBodySet::~HttpBodySet() ats_free(set_name); ats_free(content_language); ats_free(content_charset); + ats_free(content_type); table_of_pages.reset(nullptr); } @@ -991,16 +999,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/HttpSM.cc b/src/proxy/http/HttpSM.cc index 8fdbaed76ce..eccfae33624 100644 --- a/src/proxy/http/HttpSM.cc +++ b/src/proxy/http/HttpSM.cc @@ -7071,7 +7071,7 @@ HttpSM::setup_internal_transfer(HttpSMHandler handler_arg) } ats_free(t_state.internal_msg_buffer_type); t_state.internal_msg_buffer_type = nullptr; - } else { + } else if (!t_state.hdr_info.client_response.presence(MIME_PRESENCE_CONTENT_TYPE)) { t_state.hdr_info.client_response.value_set(static_cast(MIME_FIELD_CONTENT_TYPE), "text/html"sv); } } else { 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..caf042d5c58 --- /dev/null +++ b/tests/gold_tests/body_factory/body_factory_content_type.test.py @@ -0,0 +1,104 @@ +''' +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; charset=utf-8 + """ + + 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( + 'Content-Type: text/html; charset=utf-8', '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( + 'Content-Type: text/plain; charset=utf-8', 'Custom body factory should produce text/plain with charset') + tr.Processes.Default.Streams.stdout += Testers.ContainsExpression('HTTP/1.1 404', 'Unmapped request should get 404') + tr.StillRunningAfter = self._ts_custom + + +BodyFactoryContentTypeTest().run() From 1b51b538d546e1f46bb9ff07588e4321afaa8648 Mon Sep 17 00:00:00 2001 From: Bryan Call Date: Fri, 6 Mar 2026 14:41:17 -0800 Subject: [PATCH 2/3] Fix Sphinx docs build: use literal markup for .body_factory_info The :file: role with nitpicky mode causes an unresolved reference warning for .body_factory_info. Use literal markup instead. --- doc/admin-guide/monitoring/error-messages.en.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/admin-guide/monitoring/error-messages.en.rst b/doc/admin-guide/monitoring/error-messages.en.rst index 06ff8a3916e..ad606283678 100644 --- a/doc/admin-guide/monitoring/error-messages.en.rst +++ b/doc/admin-guide/monitoring/error-messages.en.rst @@ -113,7 +113,7 @@ enables customization by values present in the transaction for which the error o Template Set Metadata --------------------- -Each template set directory may contain a :file:`.body_factory_info` file that provides metadata +Each template set directory may contain a ``.body_factory_info`` file that provides metadata about the error pages in that directory. This file controls the ``Content-Type``, ``Content-Language``, and character set of the HTTP response headers sent with error pages. From 0bc6d993aec36449ce81dc6c6e1d9ac8d7287814 Mon Sep 17 00:00:00 2001 From: Bryan Call Date: Fri, 6 Mar 2026 14:46:32 -0800 Subject: [PATCH 3/3] Address review feedback - Rename type_ptr to content_type_ptr to avoid confusion with the type parameter (error template name) - Fix docs: .body_factory_info is required for a template set to load, not optional. Absent file causes the directory to be skipped. - Use literal markup for .body_factory_info to fix Sphinx nitpick warning --- doc/admin-guide/monitoring/error-messages.en.rst | 10 +++++----- src/proxy/http/HttpBodyFactory.cc | 14 +++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/doc/admin-guide/monitoring/error-messages.en.rst b/doc/admin-guide/monitoring/error-messages.en.rst index ad606283678..f37724ed31a 100644 --- a/doc/admin-guide/monitoring/error-messages.en.rst +++ b/doc/admin-guide/monitoring/error-messages.en.rst @@ -113,9 +113,9 @@ enables customization by values present in the transaction for which the error o Template Set Metadata --------------------- -Each template set directory may contain a ``.body_factory_info`` file that provides metadata -about the error pages in that directory. This file controls the ``Content-Type``, -``Content-Language``, and character set of the HTTP response headers sent with error pages. +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: @@ -144,8 +144,8 @@ To describe Korean error pages encoded in the ``iso-2022-kr`` character set:: Content-Language: kr Content-Charset: iso-2022-kr -If the file is empty, contains only comments, or is absent, the defaults are used: English -``text/html`` in the ``utf-8`` character set. +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. diff --git a/src/proxy/http/HttpBodyFactory.cc b/src/proxy/http/HttpBodyFactory.cc index 3889703381f..280090da54b 100644 --- a/src/proxy/http/HttpBodyFactory.cc +++ b/src/proxy/http/HttpBodyFactory.cc @@ -74,10 +74,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; - const char *type_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; @@ -147,7 +147,7 @@ HttpBodyFactory::fabricate_with_old_api(const char *type, HttpTransact::State *c ///////////////////////////////////////////////////////// if (buffer == nullptr) { buffer = fabricate(&acpt_language_list, &acpt_charset_list, type, context, resulting_buffer_length, &lang_ptr, &charset_ptr, - &type_ptr, &set); + &content_type_ptr, &set); found_requested_template = (buffer != nullptr); } ///////////////////////////////////////////////////////////// @@ -160,7 +160,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, &type_ptr, &set); + &charset_ptr, &content_type_ptr, &set); } /////////////////////////////////// @@ -182,7 +182,7 @@ 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); - const char *mime_type = type_ptr ? type_ptr : "text/html"; + const char *mime_type = content_type_ptr ? content_type_ptr : "text/html"; snprintf(content_type_out_buf, content_type_buf_size, "%s; charset=%s", mime_type, charset_ptr); }