Skip to content

Commit 8ba1541

Browse files
committed
core: Suppress content for 205 responses
HTTP 205 Reset Content responses must not contain content. Handle both request-generated and pre-built response buckets. PR 69186.
1 parent 3e2a882 commit 8ba1541

7 files changed

Lines changed: 337 additions & 3 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
*) core: Do not generate response content for HTTP 205 Reset Content,
2+
as required by RFC 9110 section 15.3.6. PR 69186.
3+
[Arturo Bernal]

‎modules/http/http_filters.c‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,20 @@ static apr_bucket *create_trailers_bucket(request_rec *r, apr_bucket_alloc_t *bu
6161
static apr_bucket *create_response_bucket(request_rec *r, apr_bucket_alloc_t *bucket_alloc);
6262
static void merge_response_headers(request_rec *r);
6363

64+
/* A 205 has no content either, but unlike 204 and 304 it does not end
65+
* with the header, so it needs a length to say that it is empty.
66+
*/
67+
static int status_no_content(int status)
68+
{
69+
return AP_STATUS_IS_HEADER_ONLY(status) || status == HTTP_RESET_CONTENT;
70+
}
71+
72+
static void set_empty_content_headers(apr_table_t *headers)
73+
{
74+
apr_table_unset(headers, "Transfer-Encoding");
75+
apr_table_setn(headers, "Content-Length", "0");
76+
}
77+
6478
typedef struct http_filter_ctx
6579
{
6680
apr_off_t remaining;
@@ -1218,7 +1232,10 @@ AP_CORE_DECLARE_NONSTD(apr_status_t) ap_http_header_filter(ap_filter_t *f,
12181232
if (!ctx->final_status
12191233
&& (resp->status >= HTTP_OK || resp->status == HTTP_SWITCHING_PROTOCOLS)) {
12201234
ctx->final_status = resp->status;
1221-
ctx->final_header_only = AP_STATUS_IS_HEADER_ONLY(resp->status);
1235+
ctx->final_header_only = status_no_content(resp->status);
1236+
if (resp->status == HTTP_RESET_CONTENT) {
1237+
set_empty_content_headers(resp->headers);
1238+
}
12221239
bcontent = APR_BUCKET_NEXT(e);
12231240
break;
12241241
}
@@ -1284,7 +1301,8 @@ AP_CORE_DECLARE_NONSTD(apr_status_t) ap_http_header_filter(ap_filter_t *f,
12841301
respb = create_response_bucket(r, b->bucket_alloc);
12851302
APR_BUCKET_INSERT_BEFORE(bcontent, respb);
12861303
ctx->final_status = r->status;
1287-
ctx->final_header_only = (r->header_only || AP_STATUS_IS_HEADER_ONLY(r->status));
1304+
ctx->final_header_only = (r->header_only
1305+
|| status_no_content(r->status));
12881306
r->sent_bodyct = 1; /* Whatever follows is real body stuff... */
12891307
}
12901308
}
@@ -1969,7 +1987,7 @@ apr_status_t ap_h1_response_out_filter(ap_filter_t *f,
19691987
ctx->final_response_sent = (resp->status >= HTTP_OK)
19701988
|| (!strict && resp->status < HTTP_CONTINUE);
19711989
ctx->discard_body = ctx->final_response_sent &&
1972-
(r->header_only || AP_STATUS_IS_HEADER_ONLY(resp->status));
1990+
(r->header_only || status_no_content(resp->status));
19731991

19741992
if (!ctx->tmpbb) {
19751993
ctx->tmpbb = apr_brigade_create(r->pool, c->bucket_alloc);
@@ -2121,6 +2139,11 @@ static void merge_response_headers(request_rec *r)
21212139
r->content_languages = NULL;
21222140
r->clength = r->chunked = 0;
21232141
}
2142+
else if (r->status == HTTP_RESET_CONTENT) {
2143+
set_empty_content_headers(r->headers_out);
2144+
r->clength = 0;
2145+
r->chunked = 0;
2146+
}
21242147

21252148
ctype = ap_make_content_type(r, r->content_type);
21262149
if (ctype) {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env python3
2+
import os
3+
import sys
4+
import time
5+
from urllib.parse import parse_qs
6+
7+
# Answer with the status given in the query and always try to send a body,
8+
# in as many flushed parts as asked for.
9+
query = parse_qs(os.environ.get("QUERY_STRING", ""))
10+
status = query.get("status", ["200"])[0]
11+
parts = int(query.get("parts", ["1"])[0])
12+
13+
print(f"Status: {status}")
14+
print("Content-Type: text/plain")
15+
print()
16+
for _ in range(parts):
17+
sys.stdout.write("SHOULD-NOT-BE-SENT\n")
18+
sys.stdout.flush()
19+
if parts > 1:
20+
time.sleep(0.1)
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import socket
2+
3+
import pytest
4+
5+
from pyhttpd.conf import HttpdConf
6+
7+
# the status as answered by the CGI script, or as written by mod_aptest as a
8+
# final response of its own
9+
SCRIPT = "/status_body.py?status={status}&parts={parts}"
10+
PREBUILT = "/pre?status={status}"
11+
12+
BODY = b"SHOULD-NOT-BE-SENT\n"
13+
14+
15+
class TestStatusNoBody:
16+
17+
@pytest.fixture(autouse=True, scope='class')
18+
def _class_scope(self, env):
19+
conf = HttpdConf(env, extras={
20+
"base": [
21+
"<Location /pre>",
22+
" SetHandler aptest-prebuilt",
23+
"</Location>",
24+
]
25+
})
26+
conf.add_vhost_cgi()
27+
conf.install()
28+
assert env.apache_restart() == 0
29+
30+
@staticmethod
31+
def _recv_all(sock):
32+
data = b""
33+
while True:
34+
chunk = sock.recv(65536)
35+
if not chunk:
36+
return data
37+
data += chunk
38+
39+
@staticmethod
40+
def _split_response(data, method="GET"):
41+
"""Cut the first response off a byte stream the way a client does:
42+
by status code and Content-Length, never by what the server closes.
43+
Returns the response and what is left after it."""
44+
head, sep, rest = data.partition(b"\r\n\r\n")
45+
assert sep, f"no complete response header in {data!r}"
46+
lines = head.decode("latin-1").split("\r\n")
47+
status = int(lines[0].split(" ")[1])
48+
headers = {}
49+
for line in lines[1:]:
50+
name, _, value = line.partition(":")
51+
headers[name.strip().lower()] = value.strip()
52+
if method == "HEAD" or status in (204, 304) or 100 <= status < 200:
53+
return status, headers, b"", rest
54+
if headers.get("transfer-encoding") == "chunked":
55+
body = b""
56+
while True:
57+
size_line, _, rest = rest.partition(b"\r\n")
58+
size = int(size_line.split(b";")[0], 16)
59+
if size == 0:
60+
# no trailers expected
61+
assert rest[:2] == b"\r\n", f"trailer: {rest[:20]!r}"
62+
return status, headers, body, rest[2:]
63+
body += rest[:size]
64+
assert rest[size:size + 2] == b"\r\n"
65+
rest = rest[size + 2:]
66+
assert "content-length" in headers, \
67+
f"body is not delimited, the client reads until close: {headers}"
68+
n = int(headers["content-length"])
69+
assert len(rest) >= n, f"short body: {len(rest)} < {n}"
70+
return status, headers, rest[:n], rest[n:]
71+
72+
def exchange(self, env, status, method="GET", parts=1, target=SCRIPT):
73+
"""Send two requests on one connection and parse both responses
74+
from the bytes the server wrote. The second request is a 200 which
75+
shows the connection is still correctly framed after the first."""
76+
host = f"cgi.{env.http_tld}"
77+
first = target.format(status=status, parts=parts)
78+
second = target.format(status=200, parts=1)
79+
req = (f"{method} {first} HTTP/1.1\r\n"
80+
f"Host: {host}\r\n\r\n"
81+
f"GET {second} HTTP/1.1\r\n"
82+
f"Host: {host}\r\nConnection: close\r\n\r\n").encode()
83+
with socket.create_connection(("127.0.0.1", env.http_port),
84+
timeout=10) as sock:
85+
sock.sendall(req)
86+
data = self._recv_all(sock)
87+
resp1 = self._split_response(data, method)
88+
resp2 = self._split_response(resp1[3])
89+
assert resp2[3] == b"", f"unexpected data after 2nd response: {resp2[3]!r}"
90+
return resp1[:3], resp2[:3]
91+
92+
# a 205 carries no content, whatever the handler writes
93+
def test_core_012_01(self, env):
94+
(status, headers, body), _ = self.exchange(env, 205)
95+
assert status == 205
96+
assert body == b""
97+
98+
# without content the response is delimited by Content-Length: 0
99+
def test_core_012_02(self, env):
100+
(status, headers, body), _ = self.exchange(env, 205)
101+
assert status == 205
102+
assert headers.get("content-length") == "0"
103+
assert "transfer-encoding" not in headers
104+
105+
# the connection stays usable, nothing of the discarded body shows up
106+
def test_core_012_03(self, env):
107+
_, (status, headers, body) = self.exchange(env, 205)
108+
assert status == 200
109+
assert body == BODY
110+
111+
# a HEAD request is answered without content and keeps the connection
112+
def test_core_012_04(self, env):
113+
(status, headers, body), (status2, _, body2) = \
114+
self.exchange(env, 205, method="HEAD")
115+
assert status == 205
116+
assert body == b""
117+
assert (status2, body2) == (200, BODY)
118+
119+
# 204 stays without content and without content related headers
120+
def test_core_012_05(self, env):
121+
(status, headers, body), (status2, _, body2) = self.exchange(env, 204)
122+
assert status == 204
123+
assert body == b""
124+
assert "content-length" not in headers
125+
assert "transfer-encoding" not in headers
126+
assert (status2, body2) == (200, BODY)
127+
128+
# 304 stays without content
129+
def test_core_012_06(self, env):
130+
(status, headers, body), (status2, _, body2) = self.exchange(env, 304)
131+
assert status == 304
132+
assert body == b""
133+
assert (status2, body2) == (200, BODY)
134+
135+
# other 2xx statuses still carry the body
136+
@pytest.mark.parametrize("code", [200, 201, 202])
137+
def test_core_012_07(self, env, code):
138+
(status, headers, body), _ = self.exchange(env, code)
139+
assert status == code
140+
assert body == BODY
141+
142+
# the same when the handler writes its content in several flushed parts
143+
def test_core_012_08(self, env):
144+
(status, headers, body), (status2, _, body2) = \
145+
self.exchange(env, 205, parts=3)
146+
assert status == 205
147+
assert body == b""
148+
assert headers.get("content-length") == "0"
149+
assert (status2, body2) == (200, BODY)
150+
151+
# the same when the response is a final response bucket written by the
152+
# handler itself, 204 and 304 included
153+
@pytest.mark.parametrize("code", [204, 205, 304])
154+
def test_core_012_09(self, env, code):
155+
(status, headers, body), (status2, _, body2) = \
156+
self.exchange(env, code, target=PREBUILT)
157+
assert status == code
158+
assert body == b""
159+
if code == 205:
160+
assert headers.get("content-length") == "0"
161+
assert "transfer-encoding" not in headers
162+
assert (status2, body2) == (200, BODY)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env python3
2+
import os
3+
import sys
4+
import time
5+
from urllib.parse import parse_qs
6+
7+
# Answer with the status given in the query and always try to send a body,
8+
# in as many flushed parts as asked for.
9+
query = parse_qs(os.environ.get("QUERY_STRING", ""))
10+
status = query.get("status", ["200"])[0]
11+
parts = int(query.get("parts", ["1"])[0])
12+
13+
print(f"Status: {status}")
14+
print("Content-Type: text/plain")
15+
print()
16+
for _ in range(parts):
17+
sys.stdout.write("SHOULD-NOT-BE-SENT\n")
18+
sys.stdout.flush()
19+
if parts > 1:
20+
time.sleep(0.1)
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import pytest
2+
3+
from .env import H2Conf, H2TestEnv
4+
5+
BODY = b"SHOULD-NOT-BE-SENT\n"
6+
7+
8+
@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
9+
class TestStatusNoBody:
10+
11+
@pytest.fixture(autouse=True, scope='class')
12+
def _class_scope(self, env):
13+
H2Conf(env, extras={
14+
"base": [
15+
"<Location /pre>",
16+
" SetHandler aptest-prebuilt",
17+
"</Location>",
18+
]
19+
}).add_vhost_cgi().install()
20+
assert env.apache_restart() == 0
21+
22+
def get(self, env, status, options=None):
23+
url = env.mkurl("https", "cgi", f"/status_body.py?status={status}")
24+
r = env.curl_get(url, options=["--http2"] + (options or []))
25+
assert r.response, f"no response: {r.stderr}"
26+
assert r.response["header"]["server"], "not an HTTP/2 response"
27+
return r.response
28+
29+
# a 205 carries no content, whatever the handler writes
30+
def test_h2_204_01(self, env):
31+
r = self.get(env, 205)
32+
assert r["status"] == 205
33+
assert r["body"] == b""
34+
35+
# the same when the handler writes its content in several flushed parts
36+
def test_h2_204_05(self, env):
37+
url = env.mkurl("https", "cgi", "/status_body.py?status=205&parts=3")
38+
r = env.curl_get(url, options=["--http2"])
39+
assert r.response["status"] == 205
40+
assert r.response["body"] == b""
41+
42+
# 204 and 304 stay without content
43+
@pytest.mark.parametrize("code", [204, 304])
44+
def test_h2_204_02(self, env, code):
45+
r = self.get(env, code)
46+
assert r["status"] == code
47+
assert r["body"] == b""
48+
49+
# other 2xx statuses still carry the body
50+
@pytest.mark.parametrize("code", [200, 201, 202])
51+
def test_h2_204_03(self, env, code):
52+
r = self.get(env, code)
53+
assert r["status"] == code
54+
assert r["body"] == BODY
55+
56+
# the connection is still usable after a 205
57+
def test_h2_204_04(self, env):
58+
urls = [env.mkurl("https", "cgi", f"/status_body.py?status={s}")
59+
for s in (205, 200, 205, 200)]
60+
r = env.curl_raw(urls, options=["--http2"], no_stdout_list=True)
61+
statuses = []
62+
resp = r.response
63+
while resp:
64+
statuses.append(resp["status"])
65+
resp = resp.get("previous")
66+
assert statuses[::-1] == [205, 200, 205, 200]
67+
# all content is from the two 200 responses
68+
assert r.stdout == (BODY * 2).decode()
69+
70+
# the same when the response is a final response bucket written by the
71+
# handler itself
72+
@pytest.mark.parametrize("code", [204, 205, 304])
73+
def test_h2_204_06(self, env, code):
74+
url = env.mkurl("https", "cgi", f"/pre?status={code}")
75+
r = env.curl_get(url, options=["--http2"])
76+
assert r.response["status"] == code
77+
assert r.response["body"] == b""

0 commit comments

Comments
 (0)