Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions configs/body_factory/default/.body_factory_info
Original file line number Diff line number Diff line change
Expand Up @@ -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.
39 changes: 39 additions & 0 deletions doc/admin-guide/monitoring/error-messages.en.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 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. 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
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Content-Language: kr is not a standard language tag for Korean (commonly ko / ko-KR per BCP 47). Since this section is newly documenting metadata directives, consider updating the example to use a standard tag to avoid propagating incorrect configuration patterns.

Suggested change
Content-Language: kr
Content-Language: ko-KR

Copilot uses AI. Check for mistakes.
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.

Expand Down
3 changes: 2 additions & 1 deletion include/proxy/http/HttpBodyFactory.h
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ class HttpBodySetRawData
char *set_name;
char *content_language;
char *content_charset;
char *content_type;
std::unique_ptr<TemplateTable> table_of_pages;
};

Expand Down Expand Up @@ -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);
Expand Down
39 changes: 23 additions & 16 deletions src/proxy/http/HttpBodyFactory.cc
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +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;
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;
Expand Down Expand Up @@ -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,
&content_type_ptr, &set);
found_requested_template = (buffer != nullptr);
}
/////////////////////////////////////////////////////////////
Expand All @@ -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, &content_type_ptr, &set);
}

///////////////////////////////////
Expand All @@ -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 = content_type_ptr ? content_type_ptr : "text/html";
snprintf(content_type_out_buf, content_type_buf_size, "%s; charset=%s", mime_type, charset_ptr);
Comment on lines +185 to +186
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Content-Type from .body_factory_info is appended with ; charset=... unconditionally. If an operator configures Content-Type with parameters (e.g. application/json; charset=utf-16), this will generate an invalid header with duplicate charset parameters. Consider either (a) treating .body_factory_info's Content-Type as the full header value and not appending charset, or (b) parsing/stripping parameters (or at least detecting an existing charset=) before appending.

Copilot uses AI. Check for mistakes.
}

if (enable_logging) {
Expand Down Expand Up @@ -213,8 +215,9 @@ HttpBodyFactory::dump_template_tables(FILE *fp)
for (const auto &it1 : *table_of_sets.get()) {
HttpBodySet *body_set = static_cast<HttpBodySet *>(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 //
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -523,8 +528,9 @@ HttpBodyFactory::determine_set_by_language(std::unique_ptr<BodySetTable> &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".
Expand Down Expand Up @@ -894,6 +900,7 @@ HttpBodySet::HttpBodySet()
set_name = nullptr;
content_language = nullptr;
content_charset = nullptr;
content_type = nullptr;

table_of_pages = nullptr;
}
Expand All @@ -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);
}

Expand Down Expand Up @@ -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);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/proxy/http/HttpSM.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string_view>(MIME_FIELD_CONTENT_TYPE), "text/html"sv);
}
} else {
Expand Down
104 changes: 104 additions & 0 deletions tests/gold_tests/body_factory/body_factory_content_type.test.py
Original file line number Diff line number Diff line change
@@ -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')
Comment on lines +83 to +84
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion is potentially brittle if the server emits a different charset casing (e.g. UTF-8) or slightly different formatting/spacing. If ContainsExpression is regex-based, consider using a case-insensitive pattern and/or a more flexible match for the charset portion to reduce test flakiness across platforms/builds.

Copilot uses AI. Check for mistakes.
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()