Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changes-entries/reset-content-no-body.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*) core: Do not generate response content for HTTP 205 Reset Content,
as required by RFC 9110 section 15.3.6. PR 69186.
[Arturo Bernal]
29 changes: 26 additions & 3 deletions modules/http/http_filters.c
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ static apr_bucket *create_trailers_bucket(request_rec *r, apr_bucket_alloc_t *bu
static apr_bucket *create_response_bucket(request_rec *r, apr_bucket_alloc_t *bucket_alloc);
static void merge_response_headers(request_rec *r);

/* A 205 has no content either, but unlike 204 and 304 it does not end
* with the header, so it needs a length to say that it is empty.
*/
static int status_no_content(int status)
{
return AP_STATUS_IS_HEADER_ONLY(status) || status == HTTP_RESET_CONTENT;
}

static void set_empty_content_headers(apr_table_t *headers)
{
apr_table_unset(headers, "Transfer-Encoding");
apr_table_setn(headers, "Content-Length", "0");
}

typedef struct http_filter_ctx
{
apr_off_t remaining;
Expand Down Expand Up @@ -1218,7 +1232,10 @@ AP_CORE_DECLARE_NONSTD(apr_status_t) ap_http_header_filter(ap_filter_t *f,
if (!ctx->final_status
&& (resp->status >= HTTP_OK || resp->status == HTTP_SWITCHING_PROTOCOLS)) {
ctx->final_status = resp->status;
ctx->final_header_only = AP_STATUS_IS_HEADER_ONLY(resp->status);
ctx->final_header_only = status_no_content(resp->status);
if (resp->status == HTTP_RESET_CONTENT) {
set_empty_content_headers(resp->headers);
}
bcontent = APR_BUCKET_NEXT(e);
break;
}
Expand Down Expand Up @@ -1284,7 +1301,8 @@ AP_CORE_DECLARE_NONSTD(apr_status_t) ap_http_header_filter(ap_filter_t *f,
respb = create_response_bucket(r, b->bucket_alloc);
APR_BUCKET_INSERT_BEFORE(bcontent, respb);
ctx->final_status = r->status;
ctx->final_header_only = (r->header_only || AP_STATUS_IS_HEADER_ONLY(r->status));
ctx->final_header_only = (r->header_only
|| status_no_content(r->status));
r->sent_bodyct = 1; /* Whatever follows is real body stuff... */
}
}
Expand Down Expand Up @@ -1969,7 +1987,7 @@ apr_status_t ap_h1_response_out_filter(ap_filter_t *f,
ctx->final_response_sent = (resp->status >= HTTP_OK)
|| (!strict && resp->status < HTTP_CONTINUE);
ctx->discard_body = ctx->final_response_sent &&
(r->header_only || AP_STATUS_IS_HEADER_ONLY(resp->status));
(r->header_only || status_no_content(resp->status));

if (!ctx->tmpbb) {
ctx->tmpbb = apr_brigade_create(r->pool, c->bucket_alloc);
Expand Down Expand Up @@ -2121,6 +2139,11 @@ static void merge_response_headers(request_rec *r)
r->content_languages = NULL;
r->clength = r->chunked = 0;
}
else if (r->status == HTTP_RESET_CONTENT) {
set_empty_content_headers(r->headers_out);
r->clength = 0;
r->chunked = 0;
}

ctype = ap_make_content_type(r, r->content_type);
if (ctype) {
Expand Down
20 changes: 20 additions & 0 deletions test/modules/core/htdocs/cgi/status_body.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env python3
import os
import sys
import time
from urllib.parse import parse_qs

# Answer with the status given in the query and always try to send a body,
# in as many flushed parts as asked for. Write bytes: text mode would turn
# every "\n" into "\r\n" on Windows.
query = parse_qs(os.environ.get("QUERY_STRING", ""))
status = query.get("status", ["200"])[0]
parts = int(query.get("parts", ["1"])[0])

out = sys.stdout.buffer
out.write(f"Status: {status}\r\nContent-Type: text/plain\r\n\r\n".encode())
for _ in range(parts):
out.write(b"SHOULD-NOT-BE-SENT\n")
out.flush()
if parts > 1:
time.sleep(0.1)
162 changes: 162 additions & 0 deletions test/modules/core/test_012_status_no_body.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import socket

import pytest

from pyhttpd.conf import HttpdConf

# the status as answered by the CGI script, or as written by mod_aptest as a
# final response of its own
SCRIPT = "/status_body.py?status={status}&parts={parts}"
PREBUILT = "/pre?status={status}"

BODY = b"SHOULD-NOT-BE-SENT\n"


class TestStatusNoBody:

@pytest.fixture(autouse=True, scope='class')
def _class_scope(self, env):
conf = HttpdConf(env, extras={
"base": [
"<Location /pre>",
" SetHandler aptest-prebuilt",
"</Location>",
]
})
conf.add_vhost_cgi()
conf.install()
assert env.apache_restart() == 0

@staticmethod
def _recv_all(sock):
data = b""
while True:
chunk = sock.recv(65536)
if not chunk:
return data
data += chunk

@staticmethod
def _split_response(data, method="GET"):
"""Cut the first response off a byte stream the way a client does:
by status code and Content-Length, never by what the server closes.
Returns the response and what is left after it."""
head, sep, rest = data.partition(b"\r\n\r\n")
assert sep, f"no complete response header in {data!r}"
lines = head.decode("latin-1").split("\r\n")
status = int(lines[0].split(" ")[1])
headers = {}
for line in lines[1:]:
name, _, value = line.partition(":")
headers[name.strip().lower()] = value.strip()
if method == "HEAD" or status in (204, 304) or 100 <= status < 200:
return status, headers, b"", rest
if headers.get("transfer-encoding") == "chunked":
body = b""
while True:
size_line, _, rest = rest.partition(b"\r\n")
size = int(size_line.split(b";")[0], 16)
if size == 0:
# no trailers expected
assert rest[:2] == b"\r\n", f"trailer: {rest[:20]!r}"
return status, headers, body, rest[2:]
body += rest[:size]
assert rest[size:size + 2] == b"\r\n"
rest = rest[size + 2:]
assert "content-length" in headers, \
f"body is not delimited, the client reads until close: {headers}"
n = int(headers["content-length"])
assert len(rest) >= n, f"short body: {len(rest)} < {n}"
return status, headers, rest[:n], rest[n:]

def exchange(self, env, status, method="GET", parts=1, target=SCRIPT):
"""Send two requests on one connection and parse both responses
from the bytes the server wrote. The second request is a 200 which
shows the connection is still correctly framed after the first."""
host = f"cgi.{env.http_tld}"
first = target.format(status=status, parts=parts)
second = target.format(status=200, parts=1)
req = (f"{method} {first} HTTP/1.1\r\n"
f"Host: {host}\r\n\r\n"
f"GET {second} HTTP/1.1\r\n"
f"Host: {host}\r\nConnection: close\r\n\r\n").encode()
with socket.create_connection(("127.0.0.1", env.http_port),
timeout=10) as sock:
sock.sendall(req)
data = self._recv_all(sock)
resp1 = self._split_response(data, method)
resp2 = self._split_response(resp1[3])
assert resp2[3] == b"", f"unexpected data after 2nd response: {resp2[3]!r}"
return resp1[:3], resp2[:3]

# a 205 carries no content, whatever the handler writes
def test_core_012_01(self, env):
(status, headers, body), _ = self.exchange(env, 205)
assert status == 205
assert body == b""

# without content the response is delimited by Content-Length: 0
def test_core_012_02(self, env):
(status, headers, body), _ = self.exchange(env, 205)
assert status == 205
assert headers.get("content-length") == "0"
assert "transfer-encoding" not in headers

# the connection stays usable, nothing of the discarded body shows up
def test_core_012_03(self, env):
_, (status, headers, body) = self.exchange(env, 205)
assert status == 200
assert body == BODY

# a HEAD request is answered without content and keeps the connection
def test_core_012_04(self, env):
(status, headers, body), (status2, _, body2) = \
self.exchange(env, 205, method="HEAD")
assert status == 205
assert body == b""
assert (status2, body2) == (200, BODY)

# 204 stays without content and without content related headers
def test_core_012_05(self, env):
(status, headers, body), (status2, _, body2) = self.exchange(env, 204)
assert status == 204
assert body == b""
assert "content-length" not in headers
assert "transfer-encoding" not in headers
assert (status2, body2) == (200, BODY)

# 304 stays without content
def test_core_012_06(self, env):
(status, headers, body), (status2, _, body2) = self.exchange(env, 304)
assert status == 304
assert body == b""
assert (status2, body2) == (200, BODY)

# other 2xx statuses still carry the body
@pytest.mark.parametrize("code", [200, 201, 202])
def test_core_012_07(self, env, code):
(status, headers, body), _ = self.exchange(env, code)
assert status == code
assert body == BODY

# the same when the handler writes its content in several flushed parts
def test_core_012_08(self, env):
(status, headers, body), (status2, _, body2) = \
self.exchange(env, 205, parts=3)
assert status == 205
assert body == b""
assert headers.get("content-length") == "0"
assert (status2, body2) == (200, BODY)

# the same when the response is a final response bucket written by the
# handler itself, 204 and 304 included
@pytest.mark.parametrize("code", [204, 205, 304])
def test_core_012_09(self, env, code):
(status, headers, body), (status2, _, body2) = \
self.exchange(env, code, target=PREBUILT)
assert status == code
assert body == b""
if code == 205:
assert headers.get("content-length") == "0"
assert "transfer-encoding" not in headers
assert (status2, body2) == (200, BODY)
20 changes: 20 additions & 0 deletions test/modules/http2/htdocs/cgi/status_body.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env python3
import os
import sys
import time
from urllib.parse import parse_qs

# Answer with the status given in the query and always try to send a body,
# in as many flushed parts as asked for. Write bytes: text mode would turn
# every "\n" into "\r\n" on Windows.
query = parse_qs(os.environ.get("QUERY_STRING", ""))
status = query.get("status", ["200"])[0]
parts = int(query.get("parts", ["1"])[0])

out = sys.stdout.buffer
out.write(f"Status: {status}\r\nContent-Type: text/plain\r\n\r\n".encode())
for _ in range(parts):
out.write(b"SHOULD-NOT-BE-SENT\n")
out.flush()
if parts > 1:
time.sleep(0.1)
77 changes: 77 additions & 0 deletions test/modules/http2/test_204_status_no_body.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import pytest

from .env import H2Conf, H2TestEnv

BODY = b"SHOULD-NOT-BE-SENT\n"


@pytest.mark.skipif(condition=H2TestEnv.is_unsupported(), reason="mod_http2 not supported here")
class TestStatusNoBody:

@pytest.fixture(autouse=True, scope='class')
def _class_scope(self, env):
H2Conf(env, extras={
"base": [
"<Location /pre>",
" SetHandler aptest-prebuilt",
"</Location>",
]
}).add_vhost_cgi().install()
assert env.apache_restart() == 0

def get(self, env, status, options=None):
url = env.mkurl("https", "cgi", f"/status_body.py?status={status}")
r = env.curl_get(url, options=["--http2"] + (options or []))
assert r.response, f"no response: {r.stderr}"
assert r.response["header"]["server"], "not an HTTP/2 response"
return r.response

# a 205 carries no content, whatever the handler writes
def test_h2_204_01(self, env):
r = self.get(env, 205)
assert r["status"] == 205
assert r["body"] == b""

# the same when the handler writes its content in several flushed parts
def test_h2_204_05(self, env):
url = env.mkurl("https", "cgi", "/status_body.py?status=205&parts=3")
r = env.curl_get(url, options=["--http2"])
assert r.response["status"] == 205
assert r.response["body"] == b""

# 204 and 304 stay without content
@pytest.mark.parametrize("code", [204, 304])
def test_h2_204_02(self, env, code):
r = self.get(env, code)
assert r["status"] == code
assert r["body"] == b""

# other 2xx statuses still carry the body
@pytest.mark.parametrize("code", [200, 201, 202])
def test_h2_204_03(self, env, code):
r = self.get(env, code)
assert r["status"] == code
assert r["body"] == BODY

# the connection is still usable after a 205
def test_h2_204_04(self, env):
urls = [env.mkurl("https", "cgi", f"/status_body.py?status={s}")
for s in (205, 200, 205, 200)]
r = env.curl_raw(urls, options=["--http2"], no_stdout_list=True)
statuses = []
resp = r.response
while resp:
statuses.append(resp["status"])
resp = resp.get("previous")
assert statuses[::-1] == [205, 200, 205, 200]
# all content is from the two 200 responses
assert r.stdout == (BODY * 2).decode()

# the same when the response is a final response bucket written by the
# handler itself
@pytest.mark.parametrize("code", [204, 205, 304])
def test_h2_204_06(self, env, code):
url = env.mkurl("https", "cgi", f"/pre?status={code}")
r = env.curl_get(url, options=["--http2"])
assert r.response["status"] == code
assert r.response["body"] == b""
Loading
Loading