From b2e5a2ce345733025209f60bbcdce24c7bcdb424 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Tue, 20 Oct 2026 20:04:18 +0200 Subject: [PATCH] core: Add ap_custom_response_ct() to set the Content-Type of a custom response. ap_custom_response() always sends a plain text response as text/html. The new function registers the Content-Type together with the text, so a handler can send for example application/json. ap_custom_response() is unchanged. PR 69430. --- CMakeLists.txt | 3 +- changes-entries/custom-response-ct.txt | 2 + include/ap_mmn.h | 4 +- include/http_core.h | 21 ++++++ modules/http/http_protocol.c | 8 ++- server/core.c | 24 +++++++ .../custom_response/mod_custom_response.c | 71 +++++++++++++++++++ .../tests/t/apache/test_custom_response.py | 42 +++++++++++ 8 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 changes-entries/custom-response-ct.txt create mode 100644 test/pytest_suite/c-modules/custom_response/mod_custom_response.c create mode 100644 test/pytest_suite/tests/t/apache/test_custom_response.py 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"