HTTP/2: Show human-readable error messages (#4462)

This commit is contained in:
Maximilian Hils 2021-02-20 12:49:21 +01:00 committed by GitHub
parent 001cf6c10a
commit 593dd93cf8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 97 additions and 52 deletions

View File

@ -36,6 +36,7 @@ UNSUPPORTED_MEDIA_TYPE = 415
REQUESTED_RANGE_NOT_SATISFIABLE = 416
EXPECTATION_FAILED = 417
IM_A_TEAPOT = 418
NO_RESPONSE = 444
CLIENT_CLOSED_REQUEST = 499
INTERNAL_SERVER_ERROR = 500

View File

@ -2,18 +2,18 @@ import collections
import enum
import time
from dataclasses import dataclass
from typing import Optional, Tuple, Union, Dict, DefaultDict, List
from typing import DefaultDict, Dict, List, Optional, Tuple, Union
from mitmproxy import flow, http
from mitmproxy.connection import Connection, ConnectionState, Server
from mitmproxy.connection import Connection, Server
from mitmproxy.net import server_spec
from mitmproxy.net.http import url
from mitmproxy.net.http import status_codes, url
from mitmproxy.proxy import commands, events, layer, tunnel
from mitmproxy.proxy.layers import tls, websocket, tcp
from mitmproxy.proxy.layers import tcp, tls, websocket
from mitmproxy.proxy.layers.http import _upstream_proxy
from mitmproxy.proxy.utils import expect
from mitmproxy.utils import human
from ._base import HttpCommand, ReceiveHttp, StreamId, HttpConnection
from ._base import HttpCommand, HttpConnection, ReceiveHttp, StreamId
from ._events import HttpEvent, RequestData, RequestEndOfMessage, RequestHeaders, RequestProtocolError, ResponseData, \
ResponseEndOfMessage, ResponseHeaders, ResponseProtocolError
from ._hooks import HttpConnectHook, HttpErrorHook, HttpRequestHeadersHook, HttpRequestHook, HttpResponseHeadersHook, \
@ -354,13 +354,11 @@ class HttpStream(layer.Layer):
if killed_by_us or killed_by_remote:
if emit_error_hook:
yield HttpErrorHook(self.flow)
# For HTTP/2 we only want to kill the specific stream, for HTTP/1 we want to kill the connection
# *without* sending an HTTP response (that could be achieved by the user by setting flow.response).
if self.context.client.alpn == b"h2":
yield SendHttp(ResponseProtocolError(self.stream_id, "killed"), self.context.client)
else:
if self.context.client.state & ConnectionState.CAN_WRITE:
yield commands.CloseConnection(self.context.client)
# Use the special NO_RESPONSE status code to make sure that no error message is sent to the client.
yield SendHttp(
ResponseProtocolError(self.stream_id, "killed", status_codes.NO_RESPONSE),
self.context.client
)
self._handle_event = self.state_errored
return True
return False

View File

@ -1,7 +1,10 @@
import html
import textwrap
from dataclasses import dataclass
from mitmproxy.proxy import events, layer, commands
from mitmproxy import http
from mitmproxy.connection import Connection
from mitmproxy.proxy import commands, events, layer
from mitmproxy.proxy.context import Context
StreamId = int
@ -35,8 +38,16 @@ class ReceiveHttp(HttpCommand):
return f"Receive({self.event})"
__all__ = [
"HttpConnection",
"StreamId",
"HttpEvent",
]
def format_error(status_code: int, message: str) -> bytes:
reason = http.status_codes.RESPONSES.get(status_code, "Unknown")
return textwrap.dedent(f"""
<html>
<head>
<title>{status_code} {reason}</title>
</head>
<body>
<h1>{status_code} {reason}</h1>
<p>{html.escape(message)}</p>
</body>
</html>
""").strip().encode("utf8", "replace")

View File

@ -1,19 +1,18 @@
import abc
import html
from typing import Union, Optional, Callable, Type
from typing import Callable, Optional, Type, Union
import h11
from h11._readers import ChunkedReader, ContentLengthReader, Http10Reader
from h11._receivebuffer import ReceiveBuffer
from mitmproxy import http, version
from mitmproxy.connection import Connection, ConnectionState
from mitmproxy.net.http import http1, status_codes
from mitmproxy.proxy import commands, events, layer
from mitmproxy.connection import Connection, ConnectionState
from mitmproxy.proxy.layers.http._base import ReceiveHttp, StreamId
from mitmproxy.proxy.utils import expect
from mitmproxy.utils import human
from ._base import HttpConnection
from ._base import HttpConnection, format_error
from ._events import HttpEvent, RequestData, RequestEndOfMessage, RequestHeaders, RequestProtocolError, ResponseData, \
ResponseEndOfMessage, ResponseHeaders, ResponseProtocolError
from ...context import Context
@ -212,11 +211,20 @@ class Http1Server(Http1Connection):
yield commands.SendData(self.conn, b"0\r\n\r\n")
yield from self.mark_done(response=True)
elif isinstance(event, ResponseProtocolError):
if not self.response:
resp = make_error_response(event.code, event.message)
if not self.response and event.code != status_codes.NO_RESPONSE:
resp = http.Response.make(
event.code,
format_error(event.code, event.message),
http.Headers(
Server=version.MITMPROXY,
Connection="close",
Content_Type="text/html",
)
)
raw = http1.assemble_response(resp)
yield commands.SendData(self.conn, raw)
yield commands.CloseConnection(self.conn)
if self.conn.state & ConnectionState.CAN_WRITE:
yield commands.CloseConnection(self.conn)
else:
raise AssertionError(f"Unexpected event: {event}")
@ -368,25 +376,9 @@ def make_error_response(
status_code: int,
message: str = "",
) -> http.Response:
body: bytes = """
<html>
<head>
<title>{status_code} {reason}</title>
</head>
<body>
<h1>{status_code} {reason}</h1>
<p>{message}</p>
</body>
</html>
""".strip().format(
status_code=status_code,
reason=http.status_codes.RESPONSES.get(status_code, "Unknown"),
message=html.escape(message),
).encode("utf8", "replace")
return http.Response.make(
status_code,
body,
format_error(status_code, message),
http.Headers(
Server=version.MITMPROXY,
Connection="close",

View File

@ -1,7 +1,7 @@
import collections
import time
from enum import Enum
from typing import ClassVar, DefaultDict, Dict, List, Optional, Tuple, Type, Union, Sequence
from typing import ClassVar, DefaultDict, Dict, List, Optional, Sequence, Tuple, Type, Union
import h2.config
import h2.connection
@ -12,13 +12,13 @@ import h2.settings
import h2.stream
import h2.utilities
from mitmproxy import http
from mitmproxy import http, version
from mitmproxy.connection import Connection
from mitmproxy.net.http import url, status_codes
from mitmproxy.net.http import status_codes, url
from mitmproxy.utils import human
from . import RequestData, RequestEndOfMessage, RequestHeaders, RequestProtocolError, ResponseData, \
ResponseEndOfMessage, ResponseHeaders, ResponseProtocolError
from ._base import HttpConnection, HttpEvent, ReceiveHttp
from ._base import HttpConnection, HttpEvent, ReceiveHttp, format_error
from ._http_h2 import BufferedH2Connection, H2ConnectionLogger
from ...commands import CloseConnection, Log, SendData
from ...context import Context
@ -106,7 +106,25 @@ class Http2Connection(HttpConnection):
code = {
status_codes.CLIENT_CLOSED_REQUEST: h2.errors.ErrorCodes.CANCEL,
}.get(event.code, h2.errors.ErrorCodes.INTERNAL_ERROR)
self.h2_conn.reset_stream(event.stream_id, code)
stream: h2.stream.H2Stream = self.h2_conn.streams[event.stream_id]
send_error_message = (
isinstance(event, ResponseProtocolError)
and not stream.state_machine.headers_sent
and event.code != status_codes.NO_RESPONSE
)
if send_error_message:
self.h2_conn.send_headers(event.stream_id, [
(b":status", b"%d" % event.code),
(b"server", version.MITMPROXY.encode()),
(b"content-type", b"text/html"),
])
self.h2_conn.send_data(
event.stream_id,
format_error(event.code, event.message),
end_stream=True
)
else:
self.h2_conn.reset_stream(event.stream_id, code)
else:
raise AssertionError(f"Unexpected event: {event}")
data_to_send = self.h2_conn.data_to_send()

View File

@ -5,7 +5,6 @@ from mitmproxy.proxy.commands import SendData
from mitmproxy.proxy.events import DataReceived
from mitmproxy.proxy.layers.http import Http1Server, ReceiveHttp, RequestHeaders, RequestEndOfMessage, \
ResponseHeaders, ResponseEndOfMessage, RequestData, Http1Client, ResponseData
from mitmproxy.proxy.layers.http._http1 import make_error_response
from test.mitmproxy.proxy.tutils import Placeholder, Playbook
@ -200,7 +199,3 @@ class TestClient:
>> RequestHeaders(3, req, True)
<< SendData(tctx.server, Placeholder(bytes))
)
def test_make_error_response():
assert make_error_response(543, 'foobar')

View File

@ -103,6 +103,36 @@ def test_simple(tctx):
assert flow().response.text == "Hello, World!"
def test_upstream_error(tctx):
playbook, cff = start_h2_client(tctx)
flow = Placeholder(HTTPFlow)
server = Placeholder(Server)
err = Placeholder(bytes)
assert (
playbook
>> DataReceived(tctx.client,
cff.build_headers_frame(example_request_headers, flags=["END_STREAM"]).serialize())
<< http.HttpRequestHeadersHook(flow)
>> reply()
<< http.HttpRequestHook(flow)
>> reply()
<< OpenConnection(server)
>> reply("oops server <> error")
<< http.HttpErrorHook(flow)
>> reply()
<< SendData(tctx.client, err)
)
frames = decode_frames(err())
assert [type(x) for x in frames] == [
hyperframe.frame.HeadersFrame,
hyperframe.frame.DataFrame,
]
d = frames[1]
assert isinstance(d, hyperframe.frame.DataFrame)
assert b"502 Bad Gateway" in d.data
assert b"server &lt;&gt; error" in d.data
@pytest.mark.parametrize("stream", ["stream", ""])
@pytest.mark.parametrize("when", ["request", "response"])
@pytest.mark.parametrize("how", ["RST", "disconnect", "RST+disconnect"])