Skip to content

Commit 0631e6b

Browse files
authored
fix(application): rebuild a cut HTTP/2 frame after the connection preface (#2665) (#2675)
A frame the capture cuts short after the connection preface rebuilds from HTTPv2.from_data as the preface's first octets. replay_short_read measured its cut from the first octet of the rebuilt data, which the frame header opens only when nothing is ahead of it, and HTTP.pack writes the preface there. - replay_short_read starts the cut where the header starts, past what is written ahead of it: a header cut short has no payload past it, so it ends the rebuilt data. Every other replay still starts at the first octet. - Tests: SETTINGS, DATA, HEADERS and PING frames after the preface, cut at every offset through TCP and through HTTPv2, rebuilt from info, its dict and the HTTP base; each whole frame as the control; and a short-read sweep case. HTTP, HTTP/2, schema, sweep, option, doc-claim, member-order and RFC-constants modules pass; captures parse and rebuild identically to main.
1 parent 61092a5 commit 0631e6b

3 files changed

Lines changed: 150 additions & 2 deletions

File tree

‎pcapkit/protocols/schema/schema.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1528,7 +1528,11 @@ def replay_short_read(protocol: '_PB', data: 'Any') -> '_PB':
15281528
zeros they were read as. The ``__short_read__`` record that
15291529
:func:`keep_short_read` left in ``data`` names the field the capture ended
15301530
inside and how much of it there was, so everything from that point on is
1531-
dropped (:issue:`1458`). A header cut short has no payload past it.
1531+
dropped (:issue:`1458`). A header cut short has no payload past it, so it
1532+
ends the rebuilt data, and what the protocol writes ahead of it is kept:
1533+
the HTTP/2 connection preface ``HTTP.pack`` writes (:issue:`2665`). This
1534+
holds while ``pack`` writes nothing after the header schema, as no protocol
1535+
rebuilt here does; a trailer would keep as many of the cut octets.
15321536
15331537
"""
15341538
record = data.get('__short_read__') if isinstance(data, collections.abc.Mapping) else None
@@ -1537,7 +1541,9 @@ def replay_short_read(protocol: '_PB', data: 'Any') -> '_PB':
15371541
return protocol
15381542

15391543
name, count = record
1540-
offset = 0
1544+
# NOTE: Where the header starts, past what is written ahead of it (#2665); the
1545+
# width is summed from the buffers, as ``Schema.unpack`` sums it, not repacked.
1546+
offset = len(protocol._data) - sum(map(len, header.__buffer__.values())) # pylint: disable=protected-access
15411547
for key in header.__fields__:
15421548
if key == name:
15431549
offset += min(count, len(header.__buffer__[key]))
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# -*- coding: utf-8 -*-
2+
"""A cut HTTP/2 frame after the connection preface rebuilds as captured (#2665).
3+
4+
``HTTP.pack`` writes the 24-octet preface ahead of the frame header, and
5+
``replay_short_read`` cuts the rebuild from where that header starts, so
6+
``HTTPv2.from_data`` gives back the preface and the octets of the frame the capture
7+
kept, through TCP and on its own.
8+
9+
Every case builds its own octets.
10+
11+
"""
12+
13+
import unittest
14+
import warnings
15+
16+
from tests._support import reimport_once_per_class
17+
from tests.foundation import _roundtrip as wire
18+
19+
#: The HTTP/2 connection preface (RFC 9113, section 3.4).
20+
PREFACE = b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n'
21+
22+
23+
def _frame(type_: int, flags: int, sid: int, payload: bytes) -> bytes:
24+
"""An HTTP/2 frame; its Length counts the payload alone (RFC 9113, section 4.1)."""
25+
return len(payload).to_bytes(3, 'big') + bytes([type_, flags]) + sid.to_bytes(4, 'big') + payload
26+
27+
28+
#: The frame header fields and their widths (RFC 9113, section 4.1); ``stream`` holds
29+
#: the reserved bit and the Stream Identifier.
30+
HEADER = (('length', 3), ('type', 1), ('flags', 1), ('stream', 4))
31+
32+
#: A frame of each type, with what its whole parse holds.
33+
FRAMES = {
34+
# identifier and value pairs (RFC 9113, section 6.5.2)
35+
'SETTINGS': (_frame(0x4, 0x0, 0, bytes.fromhex('000100001000' '000300000064' '000400010000')),
36+
lambda info: [(int(k), v) for k, v in info.settings.items(multi=True)],
37+
[(0x1, 4096), (0x3, 100), (0x4, 65536)]),
38+
'DATA': (_frame(0x0, 0x0, 1, b'hello, world'), lambda info: info.data, b'hello, world'),
39+
# PADDED and PRIORITY, so every field of the frame is present (RFC 9113, section 6.2)
40+
'HEADERS': (_frame(0x1, 0x2C, 1, bytes([2]) + bytes.fromhex('8000000310')
41+
+ bytes.fromhex('828684418aa0e41d139d09b8f01e07') + bytes(2)),
42+
lambda info: (info.pad_len, info.stream_dependency, info.weight, info.fragment),
43+
(2, 3, 17, bytes.fromhex('828684418aa0e41d139d09b8f01e07'))),
44+
'PING': (_frame(0x6, 0x0, 0, bytes(range(1, 9))), lambda info: info.data, bytes(range(1, 9))),
45+
}
46+
47+
48+
def _datagram(app: bytes) -> bytes:
49+
"""``app`` to TCP port 80, where HTTP is bound, in an IPv4 datagram declaring all of it."""
50+
return wire.ipv4(wire.tcp(app, seq=1, ack=1, dport=80), proto=6)
51+
52+
53+
def _record(keep: int) -> 'tuple[str, int]':
54+
"""The field a frame cut after ``keep`` octets ends inside, and how much of it is there."""
55+
for name, width in HEADER:
56+
if keep < width:
57+
return name, keep
58+
keep -= width
59+
return 'frame', keep
60+
61+
62+
class PrefaceCutTests(unittest.TestCase):
63+
"""A frame after the preface, cut at every offset (#2665)."""
64+
65+
def setUp(self) -> None:
66+
reimport_once_per_class(self)
67+
68+
def assert_cut(self, http: 'object', wire_: bytes, keep: int) -> None:
69+
"""``http`` holds the preface and the first ``keep`` octets of ``wire_``, and rebuilds them."""
70+
from pcapkit.protocols.application.http import HTTP
71+
from pcapkit.protocols.application.httpv2 import HTTPv2
72+
73+
cut = PREFACE + wire_[:keep]
74+
self.assertIsInstance(http, HTTPv2)
75+
self.assertEqual(http.data, cut)
76+
self.assertEqual(http.info.preface, PREFACE)
77+
self.assertEqual(http.info['__short_read__'], _record(keep))
78+
if keep >= 3:
79+
self.assertEqual(http.info.length, len(wire_) - 9)
80+
for name, data in (('info', http.info), ('dict', http.info.to_dict())):
81+
with self.subTest(rebuilt_from=name):
82+
rebuilt = HTTPv2.from_data(data)
83+
self.assertEqual(rebuilt.data, cut)
84+
self.assertEqual(rebuilt.info.preface, PREFACE)
85+
self.assertEqual(HTTP.from_data(http.info).data, cut)
86+
87+
def test_cut_through_tcp_at_every_offset(self) -> None:
88+
from pcapkit.protocols.internet.ipv4 import IPv4
89+
90+
for label, (wire_, _, _) in FRAMES.items():
91+
packet = _datagram(PREFACE + wire_)
92+
head = len(packet) - len(wire_)
93+
for keep in range(1, len(wire_)):
94+
raw = packet[:head + keep]
95+
with self.subTest(frame=label, keep=keep):
96+
with warnings.catch_warnings():
97+
warnings.simplefilter('ignore')
98+
ipv4 = IPv4(raw, len(raw))
99+
self.assertEqual(str(ipv4.protochain), 'IPv4:TCP:HTTP/2')
100+
self.assert_cut(ipv4.payload.payload, wire_, keep)
101+
self.assertEqual(IPv4.from_data(ipv4.info).data, raw)
102+
self.assertEqual(IPv4.from_data(ipv4.info.to_dict()).data, raw)
103+
104+
def test_cut_through_httpv2_at_every_offset(self) -> None:
105+
from pcapkit.protocols.application.httpv2 import HTTPv2
106+
107+
for label, (wire_, _, _) in FRAMES.items():
108+
for keep in range(1, len(wire_)):
109+
with self.subTest(frame=label, keep=keep):
110+
with warnings.catch_warnings():
111+
warnings.simplefilter('ignore')
112+
http = HTTPv2(PREFACE + wire_[:keep], len(PREFACE + wire_))
113+
self.assert_cut(http, wire_, keep)
114+
115+
def test_whole_frame_is_the_control(self) -> None:
116+
from pcapkit.protocols.application.httpv2 import HTTPv2
117+
from pcapkit.protocols.internet.ipv4 import IPv4
118+
119+
for label, (wire_, held, expected) in FRAMES.items():
120+
app = PREFACE + wire_
121+
packet = _datagram(app)
122+
ipv4 = IPv4(packet, len(packet))
123+
for via, http in (('TCP', ipv4.payload.payload), ('HTTPv2', HTTPv2(app, len(app)))):
124+
with self.subTest(frame=label, via=via):
125+
self.assertIsInstance(http, HTTPv2)
126+
self.assertNotIn('__short_read__', http.info)
127+
self.assertEqual(http.info.preface, PREFACE)
128+
self.assertEqual(http.info.length, len(wire_) - 9)
129+
self.assertEqual(held(http.info), expected)
130+
self.assertEqual(http.data, app)
131+
self.assertEqual(HTTPv2.from_data(http.info).data, app)
132+
self.assertEqual(HTTPv2.from_data(http.info.to_dict()).data, app)
133+
self.assertEqual(IPv4.from_data(ipv4.info).data, packet)
134+
135+
136+
if __name__ == '__main__':
137+
unittest.main()

‎tests/protocols/test_short_read_roundtrip_unit.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@ def _ipv6(code: int, body: bytes) -> bytes:
113113
('httpv2-settings', 'internet.ipv4:IPv4', {}, bytes.fromhex(
114114
'450000431234000040067c4ac0000201c6336402' '9c40005000000001000000015010ffff00000000'
115115
'000012040000000000' '000100001000' '000300000064' '000400010000')),
116+
# the same, after the connection preface (#2665)
117+
('httpv2-preface', 'internet.ipv4:IPv4', {}, bytes.fromhex(
118+
'4500005b1234000040067c32c0000201c6336402' '9c40005000000001000000015010ffff00000000'
119+
'505249202a20485454502f322e300d0a0d0a534d0d0a0d0a'
120+
'000012040000000000' '000100001000' '000300000064' '000400010000')),
116121
)
117122

118123

0 commit comments

Comments
 (0)