Skip to content
11 changes: 11 additions & 0 deletions h2/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,17 @@ class _HeadersSent(object):
pass


class _ResponseHeadersSent(_HeadersSent):
"""
The _ResponseHeadersSent event is fired whenever response headers are sent
on a stream.

This is an internal event, used to determine validation steps on
outgoing header blocks.
"""
pass

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like at this point to also add a _RequestHeadersSent field. We don't have to do it as part of this PR, but can you open a new issue to track adding the new internal event? The lack of symmetry is making me sad. ;)



class _TrailersSent(_HeadersSent):
"""
The _TrailersSent event is fired whenever trailers are sent on a
Expand Down
14 changes: 11 additions & 3 deletions h2/stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
RequestReceived, ResponseReceived, DataReceived, WindowUpdated,
StreamEnded, PushedStreamReceived, StreamReset, TrailersReceived,
InformationalResponseReceived, AlternativeServiceAvailable,
_HeadersSent, _TrailersSent
_HeadersSent, _ResponseHeadersSent, _TrailersSent
)
from .exceptions import (
ProtocolError, StreamClosedError, InvalidBodyLengthError
Expand Down Expand Up @@ -146,7 +146,7 @@ def response_sent(self, previous_state):
if self.client is True or self.client is None:
raise ProtocolError("Client cannot send responses.")
self.headers_sent = True
event = _HeadersSent()
event = _ResponseHeadersSent()
else:
assert not self.trailers_sent
self.trailers_sent = True
Expand Down Expand Up @@ -1040,18 +1040,26 @@ def _build_hdr_validation_flags(self, events):
is_trailer = isinstance(
events[0], (_TrailersSent, TrailersReceived)
)
is_response_header = isinstance(
events[0], (_ResponseHeadersSent, ResponseReceived)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Trivial) Seeing these side-by-side, I wonder if we should drop Headers from the name of this event, i.e., _ResponseSent. Better symmetry, and matches what I did for _TrailerSent.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than deal with this in this ticket, let's open a new one related to this issue and #314 that refactors the event names.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added as a comment on #314.

)
except IndexError:
# Some state changes don't emit an internal event (for example,
# sending a push promise). We *always* emit an event for trailers,
# so the absence of an event means this definitely isn't a trailer.
# Similarly, we also emit an event whenever response headers are
# sent or received. So absence of those events means this is not an
# response header either.
#
# TODO: Find any places where we don't emit anything, and emit
# an internal event, so we can do away with this branch.
is_trailer = False
is_response_header = False

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you extend the comment above to mention that we always emit an event for responses, too?


return HeaderValidationFlags(
is_client=self.state_machine.client,
is_trailer=is_trailer
is_trailer=is_trailer,
is_response_header=is_response_header
)

def _build_headers_frames(self,
Expand Down
15 changes: 14 additions & 1 deletion h2/utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ def authority_from_headers(headers):
# should be applied to a given set of headers.
HeaderValidationFlags = collections.namedtuple(
'HeaderValidationFlags',
['is_client', 'is_trailer']
['is_client', 'is_trailer', 'is_response_header']
)


Expand Down Expand Up @@ -331,6 +331,19 @@ def _reject_pseudo_header_fields(headers, hdr_validation_flags):
seen_pseudo_header_fields
)

# If ':status' pseudo-header is not there in a response header, reject it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if this comment had a reference to the exact RFC section – I know we‘ve not been super consistent on that point, but I think it’s helpful.

To save you looking it up again: RFC 7540 § 8.1.2.4.

https://tools.ietf.org/html/rfc7540#section-8.1.2.4

# Relevant RFC section: RFC 7540 § 8.1.2.4
# https://tools.ietf.org/html/rfc7540#section-8.1.2.4
if hdr_validation_flags.is_response_header:
seen_status_field = (
b':status' in seen_pseudo_header_fields or
u':status' in seen_pseudo_header_fields
)
if not seen_status_field:
raise ProtocolError(
"Response header block does not have a :status header"
)


def _validate_host_authority_header(headers):
"""
Expand Down
2 changes: 1 addition & 1 deletion test/test_basic_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,7 @@ def test_reject_trailers_not_ending_stream(self, frame_factory):
c = h2.connection.H2Connection()
c.initiate_connection()
c.send_headers(1, self.example_request_headers)
f = frame_factory.build_headers_frame(self.example_request_headers)
f = frame_factory.build_headers_frame(self.example_response_headers)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heh, nice catch! That's a good validation that the new code is working as expected!

c.receive_data(f.serialize())

# Send in trailers.
Expand Down
42 changes: 27 additions & 15 deletions test/test_invalid_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
This module contains tests that use invalid header blocks, and validates that
they fail appropriately.
"""
import itertools

import pytest

import h2.connection
Expand Down Expand Up @@ -239,21 +241,22 @@ class TestFilter(object):
]

hdr_validation_combos = [
h2.utilities.HeaderValidationFlags(is_client, is_trailer)
for is_client, is_trailer in [
(True, True),
(True, False),
(False, True),
(False, False)
]
h2.utilities.HeaderValidationFlags(
is_client, is_trailer, is_response_header
)
for is_client, is_trailer, is_response_header in (
itertools.product([True, False], repeat=3)
)
]

hdr_validation_no_trailers = [
h2.utilities.HeaderValidationFlags(is_client, is_trailer)
for is_client, is_trailer in [
(True, False),
(False, False)
]
hdr_validation_response_headers = [
flags for flags in hdr_validation_combos
if flags.is_response_header
]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like no_trailers does include responses, so we need two more with is_response_header = True.

@sunu sunu Sep 15, 2016 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @Lukasa,
Quick question before updating the PR. It looks like we use this to test if :authority and host match or not. As far as I understand, that test doesn't need to run on response headers. So we can safely ignore response headers here, can't we?

@nateprewitt nateprewitt Sep 15, 2016 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I responded somewhat hastily here without reading @Lukasa's comment clearly. @Lukasa this has the same issue that I hit in #305, where testing on specifically request headers we end up with a lot of combinations that don't work. Part of me kind of feels like this test doesn't belong in the filter class, but I didn't know where to move it while working on #305.

We'll have to continually keep tweaking hdr_validation_no_trailers (which was probably inaptly named), specifically for test_matching_authority_host_headers as we add more flags. This will likely become tedious as complexity grows. This specific test will never be a trailer, or a response. I'm also thinking it can probably only be a client too, but I'm not sure on that. Does it make sense for a server to send a request in HTTP/2?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We cannot safely ignore response headers: we still need to confirm that they don't explode. =)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand or whether we're talking about the same thing. So let me just push my changes. May be it'll be easier then.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the latest build shows the issue a bit clearer I think. @Lukasa I know we want to be checking for the broadest set of options here in TestFilter and I think that's why this test may not belong in this specific class. It is hard coded to a set of headers that by definition cannot be a response according to the HTTP/2 spec (It contains request pseudo-headers).

Since we're already testing that the failure happens in test_response_header_without_status, adding a pytest.raises() block for every combination that fundamentally cannot work for the given header set is a bit redundant.

I completely agree that we should be testing for all cases, I'm just afraid I may have accidentally started a trend of building unnecessary infrastructure (see: hdr_validation_no_trailers) around making a test pass, rather than fixing the underlying problem.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hrm. Are we able to filter out inconsistent combinations effectively? That is, can we run a filter that requires that we never have is_response_header and is_client?

@nateprewitt nateprewitt Sep 16, 2016 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can modify hdr_validation_no_trailers further (and change the name) since it was created only for use in test_matching_authority_host_headers. However, the only combination out of the whole matrix that will (or should) work for test_matching_authority_host_headers is:

(
  (is_client, is_trailer, is_response_header'), 
  (True, False, False)
)

So we lose any point of having this in the TestFilter class to begin with. We're testing all of these other combinations elsewhere in the test base.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, in that case let's just simplify that down. =)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the latest change I'm considering both the cases for is_client true and false. Because we check for authority-host match on the server side too in https://github.com/python-hyper/hyper-h2/blob/94960a5324dac853ba256ffcc05ca2198df4b633/h2/utilities.py#L383 for example.


hdr_validation_request_headers_no_trailer = [
flags for flags in hdr_validation_combos
if not (flags.is_trailer or flags.is_response_header)
]

@pytest.mark.parametrize('validation_function', validation_functions)
Expand Down Expand Up @@ -281,7 +284,7 @@ def test_invalid_pseudo_headers(self, hdr_validation_flags):

@pytest.mark.parametrize('validation_function', validation_functions)
@pytest.mark.parametrize(
'hdr_validation_flags', hdr_validation_no_trailers
'hdr_validation_flags', hdr_validation_request_headers_no_trailer
)
def test_matching_authority_host_headers(self,
validation_function,
Expand All @@ -298,7 +301,16 @@ def test_matching_authority_host_headers(self,
(b'host', b'example.com'),
]
assert headers == h2.utilities.validate_headers(
headers, hdr_validation_flags)
headers, hdr_validation_flags
)

@pytest.mark.parametrize(
'hdr_validation_flags', hdr_validation_response_headers
)
def test_response_header_without_status(self, hdr_validation_flags):
headers = [(b'content-length', b'42')]
with pytest.raises(h2.exceptions.ProtocolError):
h2.utilities.validate_headers(headers, hdr_validation_flags)


class TestOversizedHeaders(object):
Expand Down