diff --git a/changes-entries/negotiation-qs-content-type.txt b/changes-entries/negotiation-qs-content-type.txt
new file mode 100644
index 00000000000..03630352a5b
--- /dev/null
+++ b/changes-entries/negotiation-qs-content-type.txt
@@ -0,0 +1,2 @@
+ *) mod_negotiation: Do not send the "qs" source quality parameter of the selected
+ variant in the Content-Type of a response negotiated by MultiViews. PR 53595
diff --git a/modules/mappers/mod_negotiation.c b/modules/mappers/mod_negotiation.c
index af96b263867..63cd7f239e6 100644
--- a/modules/mappers/mod_negotiation.c
+++ b/modules/mappers/mod_negotiation.c
@@ -3068,6 +3068,41 @@ static int handle_map_file(request_rec *r)
return OK;
}
+/* The "qs" parameter of a variant's media type is only its source quality
+ * for the selection, see get_entry(), and does not belong in the
+ * Content-Type of the response. Return content_type without it, all
+ * other parameters are kept as they are.
+ */
+static const char *strip_source_quality(apr_pool_t *p,
+ const char *content_type)
+{
+ const char *line = content_type;
+ char *stripped;
+ int found = 0;
+
+ if (!ap_strcasestr(content_type, "qs")) {
+ return content_type;
+ }
+
+ stripped = ap_get_token(p, &line, 0);
+ while (*line == ';') {
+ const char *param = line++;
+ const char *name = ap_get_token(p, &line, 1);
+ apr_size_t len = strcspn(name, "= \t");
+
+ if (len == 2 && name[len] && !ap_cstr_casecmpn(name, "qs", 2)) {
+ found = 1;
+ }
+ else {
+ stripped = apr_pstrcat(p, stripped,
+ apr_pstrmemdup(p, param, line - param),
+ NULL);
+ }
+ }
+
+ return found ? apr_pstrcat(p, stripped, line, NULL) : content_type;
+}
+
static int handle_multi(request_rec *r)
{
negotiation_state *neg;
@@ -3124,6 +3159,14 @@ static int handle_multi(request_rec *r)
/* now do a "fast redirect" ... promotes the sub_req into the main req */
ap_internal_fast_redirect(sub_req, r);
+ if (r->content_type) {
+ const char *type = strip_source_quality(r->pool, r->content_type);
+
+ if (type != r->content_type) {
+ ap_set_content_type_ex(r, type, AP_REQUEST_IS_TRUSTED_CT(r));
+ }
+ }
+
/* give no advise for time on this subrequest. Perhaps we
* should tally the last mtime among all variants, and date
* the most recent, but that could confuse the proxies.
diff --git a/test/pytest_suite/t/conf/extra.conf.in b/test/pytest_suite/t/conf/extra.conf.in
index cb3acce9336..4581e41f005 100644
--- a/test/pytest_suite/t/conf/extra.conf.in
+++ b/test/pytest_suite/t/conf/extra.conf.in
@@ -135,6 +135,15 @@
LanguagePriority zh-TW fr fu en de
+
+ Options +MultiViews
+ AddType text/html;qs=0.9 .html
+ AddType application/xhtml+xml;qs=1.0 .xhtml
+ AddType "text/html;charset=utf-8;qs=0.8" .qsafter
+ AddType "text/html;QS=0.8;charset=utf-8" .qsbefore
+ AddType "application/example;foo=\"a;b\";q=0.7;qs=0.5;bar=\"c, d\"" .quoted
+
+
diff --git a/test/pytest_suite/t/htdocs/modules/negotiation/qs/after.qsafter b/test/pytest_suite/t/htdocs/modules/negotiation/qs/after.qsafter
new file mode 100644
index 00000000000..294186e497a
--- /dev/null
+++ b/test/pytest_suite/t/htdocs/modules/negotiation/qs/after.qsafter
@@ -0,0 +1 @@
+after
diff --git a/test/pytest_suite/t/htdocs/modules/negotiation/qs/before.qsbefore b/test/pytest_suite/t/htdocs/modules/negotiation/qs/before.qsbefore
new file mode 100644
index 00000000000..90be1f3056c
--- /dev/null
+++ b/test/pytest_suite/t/htdocs/modules/negotiation/qs/before.qsbefore
@@ -0,0 +1 @@
+before
diff --git a/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.html b/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.html
new file mode 100644
index 00000000000..1936cc1d441
--- /dev/null
+++ b/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.html
@@ -0,0 +1 @@
+html
diff --git a/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.xhtml b/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.xhtml
new file mode 100644
index 00000000000..f723f596fdc
--- /dev/null
+++ b/test/pytest_suite/t/htdocs/modules/negotiation/qs/doc.xhtml
@@ -0,0 +1 @@
+xhtml
diff --git a/test/pytest_suite/t/htdocs/modules/negotiation/qs/quoted.quoted b/test/pytest_suite/t/htdocs/modules/negotiation/qs/quoted.quoted
new file mode 100644
index 00000000000..b04fedbe6c3
--- /dev/null
+++ b/test/pytest_suite/t/htdocs/modules/negotiation/qs/quoted.quoted
@@ -0,0 +1 @@
+quoted
diff --git a/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm1.htm b/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm1.htm
new file mode 100644
index 00000000000..1936cc1d441
--- /dev/null
+++ b/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm1.htm
@@ -0,0 +1 @@
+html
diff --git a/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm2.xht b/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm2.xht
new file mode 100644
index 00000000000..f31fc40aa5f
--- /dev/null
+++ b/test/pytest_suite/t/htdocs/modules/negotiation/qs/tm2.xht
@@ -0,0 +1 @@
+xht
diff --git a/test/pytest_suite/t/htdocs/modules/negotiation/qs/typemap.var b/test/pytest_suite/t/htdocs/modules/negotiation/qs/typemap.var
new file mode 100644
index 00000000000..b8464c88f0c
--- /dev/null
+++ b/test/pytest_suite/t/htdocs/modules/negotiation/qs/typemap.var
@@ -0,0 +1,5 @@
+URI: tm1.htm
+Content-Type: text/html; qs=0.9
+
+URI: tm2.xht
+Content-Type: application/xhtml+xml; qs=1.0
diff --git a/test/pytest_suite/tests/t/modules/test_negotiation.py b/test/pytest_suite/tests/t/modules/test_negotiation.py
index b56b6218083..69144282aa5 100644
--- a/test/pytest_suite/tests/t/modules/test_negotiation.py
+++ b/test/pytest_suite/tests/t/modules/test_negotiation.py
@@ -3,12 +3,15 @@
Covers default-language selection, explicit variant requests, Accept-Language
obedience (plain + compressed + typemap), quality-rating preferences, a
non-existent highest-quality fallback, a typemap query-string case, and
-Accept content-type negotiation (with 406 cases).
+Accept content-type negotiation (with 406 cases), and the source quality
+("qs") of a variant not showing up in the Content-Type of the response.
Perl original used ``need_module('negotiation') && need_cgi &&
need_module('mime')``.
"""
+import re
+
import pytest
from apache_pytest import need_cgi, need_module, t_cmp
@@ -153,3 +156,39 @@ def test_content_type(http, accept, expected):
else:
assert t_cmp(r.status_code, 406), \
f"expect Not Acceptable for Accept: {accept}"
+
+
+XML = "application/xhtml+xml"
+
+# "qs" only decides between variants, the other parameters are kept
+QS_PARAMS = [
+ ("after", "text/html;charset=utf-8"),
+ ("before", "text/html;charset=utf-8"),
+ ("quoted", 'application/example;foo="a;b";q=0.7;bar="c, d"'),
+]
+
+
+def _content_type(resp):
+ assert resp.status_code == 200
+ return re.sub(r";\s+", ";", resp.headers["content-type"])
+
+
+@need_module("negotiation", "mime")
+@pytest.mark.parametrize("path", ["doc", "typemap.var"])
+@pytest.mark.parametrize("accept,expected", [
+ (f"text/html, {XML}", XML),
+ ("text/html", "text/html"),
+ (f"text/html, {XML};q=0.5", "text/html"),
+])
+def test_source_quality(http, path, accept, expected):
+ r = http.GET(f"/modules/negotiation/qs/{path}", headers={"Accept": accept})
+ assert t_cmp(_content_type(r), expected), \
+ "qs selects the variant but is not sent in Content-Type"
+
+
+@need_module("negotiation", "mime")
+@pytest.mark.parametrize("name,expected", QS_PARAMS, ids=[n for n, _ in QS_PARAMS])
+def test_source_quality_parameters(http, name, expected):
+ r = http.GET(f"/modules/negotiation/qs/{name}", headers={"Accept": "*/*"})
+ assert t_cmp(_content_type(r), expected), \
+ "only qs is removed from the Content-Type"