diff --git a/CMakeLists.txt b/CMakeLists.txt
index 698816b0f18..44027720649 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -437,6 +437,7 @@ IF(BUILD_TEST_MODULES)
"test/modules/http2/mod_h2test/mod_h2test+A+Http/2 Test module. Useful only for developers and testing purposes."
"test/pytest_suite/c-modules/authany/mod_authany+A+Test module. Useful only for developers and testing purposes."
"test/pytest_suite/c-modules/client_add_filter/mod_client_add_filter+A+Test module. Useful only for developers and testing purposes."
+ "test/pytest_suite/c-modules/custom_response/mod_custom_response+A+Test module. Useful only for developers and testing purposes."
"test/pytest_suite/c-modules/eat_post/mod_eat_post+A+Test module. Useful only for developers and testing purposes."
"test/pytest_suite/c-modules/echo_post/mod_echo_post+A+Test module. Useful only for developers and testing purposes."
"test/pytest_suite/c-modules/echo_post_chunk/mod_echo_post_chunk+A+Test module. Useful only for developers and testing purposes."
@@ -484,7 +485,7 @@ IF(BUILD_TEST_MODULES)
mod_echo_post_chunk mod_fold mod_input_body_filter mod_list_modules
mod_memory_track mod_nntp_like mod_random_chunk mod_test_apr_uri
mod_test_pass_brigade mod_test_rwrite mod_test_session
- mod_test_utilities mod_test_ssl)
+ mod_test_utilities mod_test_ssl mod_custom_response)
SET(${pytest_mod}_extra_defines ${PYTEST_CMODULE_DEFINES})
ENDFOREACH()
SET(mod_test_session_extra_libs mod_session)
diff --git a/changes-entries/custom-response-ct.txt b/changes-entries/custom-response-ct.txt
new file mode 100644
index 00000000000..f86bd7b3933
--- /dev/null
+++ b/changes-entries/custom-response-ct.txt
@@ -0,0 +1,2 @@
+ *) core: Add ap_custom_response_ct() to send a custom text response with
+ a Content-Type other than text/html. PR 69430.
diff --git a/include/ap_mmn.h b/include/ap_mmn.h
index b69d07b74af..500d0aa166a 100644
--- a/include/ap_mmn.h
+++ b/include/ap_mmn.h
@@ -739,6 +739,8 @@
* 20211221.31 (2.5.1-dev) Add ap_*_timingsafe() to httpd.h
* 20211221.32 (2.5.1-dev) Add ap_cstr_stripws() to httpd.h
* 20211221.33 (2.5.1-dev) Add ap_size_parameter_parse() to httpd.h
+ * 20211221.34 (2.5.1-dev) Add ap_custom_response_ct() and response_code_cts
+ * to core_request_config.
*/
#define MODULE_MAGIC_COOKIE 0x41503235UL /* "AP25" */
@@ -746,7 +748,7 @@
#ifndef MODULE_MAGIC_NUMBER_MAJOR
#define MODULE_MAGIC_NUMBER_MAJOR 20211221
#endif
-#define MODULE_MAGIC_NUMBER_MINOR 33 /* 0...n */
+#define MODULE_MAGIC_NUMBER_MINOR 34 /* 0...n */
/**
* Determine if the server's current MODULE_MAGIC_NUMBER is at least a
diff --git a/include/http_core.h b/include/http_core.h
index 6a5744891f6..b0f09b5960f 100644
--- a/include/http_core.h
+++ b/include/http_core.h
@@ -281,9 +281,25 @@ AP_DECLARE(apr_size_t) ap_get_limit_xml_body(const request_rec *r);
* @param status The status for which the custom response should be used
* @param string The custom response. This can be a static string, a file
* or a URL
+ * @note A static string is sent as text/html, use ap_custom_response_ct() to
+ * send it with another Content-Type.
*/
AP_DECLARE(void) ap_custom_response(request_rec *r, int status, const char *string);
+/**
+ * Install a custom response for a given status, and the Content-Type to send
+ * it with. A plain text response is otherwise sent as text/html.
+ * @param r The current request
+ * @param status The status for which the custom response should be used
+ * @param string The custom response. The Content-Type is only used if this
+ * is a static string, not a file or a URL
+ * @param content_type The Content-Type of the custom response, or NULL for
+ * the default
+ */
+AP_DECLARE(void) ap_custom_response_ct(request_rec *r, int status,
+ const char *string,
+ const char *content_type);
+
/**
* Check if the current request is beyond the configured max. number of redirects or subrequests
* @param r The current request
@@ -441,6 +457,11 @@ typedef struct {
/** Should addition of charset= be suppressed for this request?
*/
int suppress_charset;
+
+ /** Content-Types registered via ap_custom_response_ct(), indexed like
+ * response_code_strings, or NULL
+ */
+ const char **response_code_cts;
} core_request_config;
/* Standard entries that are guaranteed to be accessible via
diff --git a/modules/http/http_protocol.c b/modules/http/http_protocol.c
index eb121587661..0836547d4ac 100644
--- a/modules/http/http_protocol.c
+++ b/modules/http/http_protocol.c
@@ -1218,6 +1218,8 @@ AP_DECLARE(void) ap_send_error_response(request_rec *r, int recursive_error)
int idx = ap_index_of_response(status);
char *custom_response;
const char *location = apr_table_get(r->headers_out, "Location");
+ core_request_config *reqconf =
+ ap_get_core_module_config(r->request_config);
/* At this point, we are starting the response over, so we have to reset
* this value.
@@ -1272,7 +1274,11 @@ AP_DECLARE(void) ap_send_error_response(request_rec *r, int recursive_error)
r->content_encoding = NULL;
r->clength = 0;
- if (apr_table_get(r->subprocess_env,
+ if (reqconf->response_code_cts && reqconf->response_code_cts[idx]) {
+ /* registered with ap_custom_response_ct(), sent as is below */
+ ap_set_content_type_ex(r, reqconf->response_code_cts[idx], 0);
+ }
+ else if (apr_table_get(r->subprocess_env,
"suppress-error-charset") != NULL) {
core_request_config *request_conf =
ap_get_core_module_config(r->request_config);
diff --git a/server/core.c b/server/core.c
index a6928ef916c..26c3b2ab8b8 100644
--- a/server/core.c
+++ b/server/core.c
@@ -1728,6 +1728,30 @@ AP_DECLARE(void) ap_custom_response(request_rec *r, int status,
conf->response_code_strings[idx] =
((ap_is_url(string) || (*string == '/')) && (*string != '"')) ?
apr_pstrdup(r->pool, string) : apr_pstrcat(r->pool, "\"", string, NULL);
+
+ /* a new response replaces the Content-Type of the one it replaces */
+ if (conf->response_code_cts) {
+ conf->response_code_cts[idx] = NULL;
+ }
+}
+
+AP_DECLARE(void) ap_custom_response_ct(request_rec *r, int status,
+ const char *string,
+ const char *content_type)
+{
+ core_request_config *conf = ap_get_core_module_config(r->request_config);
+ int idx = ap_index_of_response(status);
+
+ ap_custom_response(r, status, string);
+
+ if (content_type && conf->response_code_strings[idx][0] == '"') {
+ if (conf->response_code_cts == NULL) {
+ conf->response_code_cts =
+ apr_pcalloc(r->pool,
+ sizeof(*conf->response_code_cts) * RESPONSE_CODES);
+ }
+ conf->response_code_cts[idx] = apr_pstrdup(r->pool, content_type);
+ }
}
static const char *set_error_document(cmd_parms *cmd, void *conf_,
diff --git a/test/pytest_suite/c-modules/custom_response/mod_custom_response.c b/test/pytest_suite/c-modules/custom_response/mod_custom_response.c
new file mode 100644
index 00000000000..82aff8e41b3
--- /dev/null
+++ b/test/pytest_suite/c-modules/custom_response/mod_custom_response.c
@@ -0,0 +1,71 @@
+#if CONFIG_FOR_HTTPD_TEST
+
+
+ ForceType image/png
+ SetHandler custom_response_plain
+
+
+
+ ForceType image/png
+ SetHandler custom_response_ct
+
+
+
+ SetHandler custom_response_replaced
+
+
+
+ SetHandler custom_response_declined
+
+
+#endif
+
+#define APACHE_HTTPD_TEST_HANDLER custom_response_handler
+#define APACHE_HTTPD_TEST_EXTRA_HOOKS custom_response_hooks
+
+#include "apache_httpd_test.h"
+
+/* Every handler fails with 400 and a plain text body registered with
+ * ap_custom_response() or ap_custom_response_ct().
+ */
+static int custom_response_handler(request_rec *r)
+{
+ if (strcmp(r->handler, "custom_response_plain") == 0) {
+ ap_custom_response(r, HTTP_BAD_REQUEST, "Bad request");
+ return HTTP_BAD_REQUEST;
+ }
+ if (strcmp(r->handler, "custom_response_ct") == 0) {
+ ap_custom_response_ct(r, HTTP_BAD_REQUEST, "{\"error\":\"bad request\"}",
+ "application/json");
+ return HTTP_BAD_REQUEST;
+ }
+ if (strcmp(r->handler, "custom_response_replaced") == 0) {
+ ap_custom_response_ct(r, HTTP_BAD_REQUEST, "first",
+ "application/json");
+ ap_custom_response(r, HTTP_BAD_REQUEST, "second");
+ return HTTP_BAD_REQUEST;
+ }
+ if (strcmp(r->handler, "custom_response_declined") == 0) {
+ ap_custom_response(r, HTTP_BAD_REQUEST, "Bad request");
+ return HTTP_BAD_REQUEST;
+ }
+ return DECLINED;
+}
+
+/* runs before the handler above, sets a type for its own purposes and
+ * declines, so the type must not end up on the body of the next handler
+ */
+static int custom_response_decliner(request_rec *r)
+{
+ if (strcmp(r->handler, "custom_response_declined") == 0) {
+ ap_set_content_type(r, "application/x-custom-response");
+ }
+ return DECLINED;
+}
+
+static void custom_response_hooks(apr_pool_t *p)
+{
+ ap_hook_handler(custom_response_decliner, NULL, NULL, APR_HOOK_FIRST);
+}
+
+APACHE_HTTPD_TEST_MODULE(custom_response);
diff --git a/test/pytest_suite/tests/t/apache/test_custom_response.py b/test/pytest_suite/tests/t/apache/test_custom_response.py
new file mode 100644
index 00000000000..df750280d0c
--- /dev/null
+++ b/test/pytest_suite/tests/t/apache/test_custom_response.py
@@ -0,0 +1,42 @@
+r"""Content-Type of a custom response body (PR 69430).
+
+The custom_response C test module fails every request with 400 and a plain
+text body. ap_custom_response() sends it as text/html, whatever Content-Type
+is left on the request by ForceType, mod_mime or a handler that declined.
+ap_custom_response_ct() sends it with the given type, and a later
+ap_custom_response() for the same status takes that type away again. HEAD
+must announce the same type as GET.
+
+Needs: the custom_response C test module.
+"""
+
+import pytest
+
+from apache_pytest import need_module, t_cmp
+
+HTML = "text/html; charset=iso-8859-1"
+
+CASES = [
+ # type left on the request by ForceType
+ ("/custom_response/plain", HTML, "Bad request"),
+ # type registered together with the body
+ ("/custom_response/ct", "application/json",
+ '{"error":"bad request"}'),
+ # body registered again without a type
+ ("/custom_response/replaced", HTML, "second"),
+ # type set by a handler that declined
+ ("/custom_response/declined", HTML, "Bad request"),
+]
+
+
+@need_module("custom_response")
+@pytest.mark.parametrize("path,ctype,body", CASES)
+def test_custom_response_content_type(http, path, ctype, body):
+ r = http.GET(path)
+ assert t_cmp(r.status_code, 400), f"{path} GET code"
+ assert t_cmp(r.headers.get("Content-Type"), ctype), f"{path} GET type"
+ assert t_cmp(r.text, body), f"{path} GET body"
+
+ r = http.HEAD(path)
+ assert t_cmp(r.status_code, 400), f"{path} HEAD code"
+ assert t_cmp(r.headers.get("Content-Type"), ctype), f"{path} HEAD type"