From 2f00700aa855b28db8945b6530b3f501a01ce746 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Sun, 11 Oct 2026 07:21:08 +0200 Subject: [PATCH] core, mod_proxy: Send the supported methods in Allow for a denied TRACE. With TraceEnable off the 405 for a TRACE request carried an empty Allow field, although GET, HEAD and OPTIONS work. The 405 is sent before any handler has declared the methods of the resource, so list the ones httpd answers for any resource. BZ 65357. --- changes-entries/trace-disabled-allow.txt | 3 + modules/http/http_filters.c | 7 +++ modules/proxy/mod_proxy.c | 2 + test/modules/core/test_010_trace_allow.py | 68 +++++++++++++++++++++++ 4 files changed, 80 insertions(+) create mode 100644 changes-entries/trace-disabled-allow.txt create mode 100644 test/modules/core/test_010_trace_allow.py diff --git a/changes-entries/trace-disabled-allow.txt b/changes-entries/trace-disabled-allow.txt new file mode 100644 index 00000000000..514fd435d2d --- /dev/null +++ b/changes-entries/trace-disabled-allow.txt @@ -0,0 +1,3 @@ + *) core, mod_proxy: Send GET, HEAD and OPTIONS in the Allow field of the 405 + response to a TRACE request denied by TraceEnable, instead of an empty + list. PR 65357 diff --git a/modules/http/http_filters.c b/modules/http/http_filters.c index 8170db9d6ac..9afc19ddec0 100644 --- a/modules/http/http_filters.c +++ b/modules/http/http_filters.c @@ -1057,6 +1057,7 @@ AP_DECLARE_NONSTD(int) ap_send_http_trace(request_rec *r) apr_size_t bodylen = 0; apr_size_t bodybuf; long res = -1; /* init to avoid gcc -Wall warning */ + request_rec *cur = r; if (r->method_number != M_TRACE) { return DECLINED; @@ -1071,6 +1072,12 @@ AP_DECLARE_NONSTD(int) ap_send_http_trace(request_rec *r) if (conf->trace_enable == AP_TRACE_DISABLE) { apr_table_setn(r->notes, "error-notes", "TRACE denied by server configuration"); + /* No handler has run yet, so the methods of the resource are not + * known. The Allow field of the 405 (RFC 9110 15.5.6) lists the + * ones httpd itself answers for any resource: GET and HEAD + * (RFC 9110 9.1) and OPTIONS. + */ + ap_allow_standard_methods(cur, REPLACE_ALLOW, M_GET, M_OPTIONS, -1); return HTTP_METHOD_NOT_ALLOWED; } diff --git a/modules/proxy/mod_proxy.c b/modules/proxy/mod_proxy.c index 3c2a28d9251..dec7f804516 100644 --- a/modules/proxy/mod_proxy.c +++ b/modules/proxy/mod_proxy.c @@ -1341,6 +1341,8 @@ static int proxy_handler(request_rec *r) apr_table_setn(r->notes, "verbose-error-to", "*"); ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01139) "TRACE forbidden by server configuration"); + /* The methods of the backend resource are not known here */ + ap_allow_standard_methods(r, REPLACE_ALLOW, M_GET, M_OPTIONS, -1); return HTTP_METHOD_NOT_ALLOWED; } diff --git a/test/modules/core/test_010_trace_allow.py b/test/modules/core/test_010_trace_allow.py new file mode 100644 index 00000000000..7e6991ce895 --- /dev/null +++ b/test/modules/core/test_010_trace_allow.py @@ -0,0 +1,68 @@ +import pytest + +from pyhttpd.conf import HttpdConf + + +def _conf(env, trace): + conf = HttpdConf(env) + conf.add(f"TraceEnable {trace}") + conf.start_vhost(domains=[f"test1.{env.http_tld}"], port=env.http_port, + doc_root="htdocs/test1", with_ssl=False) + # the backend does not exist, a denied TRACE is not forwarded + conf.add("ProxyPass /proxy/ http://127.0.0.1:1/") + conf.end_vhost() + conf.install() + assert env.apache_restart() == 0 + + +def _request(env, method, path): + options = ['-I'] if method == 'HEAD' else ['-X', method] + r = env.curl_get(env.mkurl("http", "test1", path), options=options) + assert r.response, f"no response: {r.stderr}" + # TRACE is denied on purpose + env.httpd_error_log.ignore_recent(lognos=["AH01139"]) + allow = r.response["header"].get("allow") + methods = None if allow is None else \ + {m.strip() for m in allow.split(',') if m.strip()} + return r.response["status"], methods + + +class TestTraceDisabledAllow: + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + _conf(env, "off") + + # the 405 of a TRACE denied by TraceEnable lists methods that do work + @pytest.mark.parametrize("path", ["/index.html", "/no-such-file", "/proxy/x"]) + def test_core_010_001(self, env, path): + status, allow = _request(env, "TRACE", path) + assert status == 405 + assert allow is not None, "no Allow field" + assert {"GET", "HEAD", "OPTIONS"} <= allow, f"Allow: {allow}" + assert "TRACE" not in allow, f"Allow: {allow}" + + # the advertised methods are really supported + @pytest.mark.parametrize("method", ["GET", "HEAD", "OPTIONS"]) + def test_core_010_002(self, env, method): + assert _request(env, method, "/index.html")[0] == 200 + + # OPTIONS does not offer TRACE either + def test_core_010_003(self, env): + status, allow = _request(env, "OPTIONS", "/index.html") + assert status == 200 + assert "TRACE" not in allow + + +class TestTraceEnabledAllow: + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + _conf(env, "on") + + # unchanged: TRACE works and is offered + def test_core_010_101(self, env): + assert _request(env, "TRACE", "/index.html")[0] == 200 + status, allow = _request(env, "OPTIONS", "/index.html") + assert status == 200 + assert "TRACE" in allow