Source code for pcapng_utils.tshark.protocols.http2

  1import warnings
  2import logging
  3from functools import cached_property
  4from collections.abc import Set, Sequence, Mapping
  5from typing import ClassVar, Optional, Any
  6
  7from ...payload import Payload
  8from ..types import HarEntry, DictLayers, NameValueDict
  9from ..layers import FrameMixin, TCPIPMixin, get_protocols, get_har_communication, get_tcp_stream_id, get_community_id
 10from ..utils import get_tshark_bytes_from_raw, har_entry_with_common_fields
 11
 12LOGGER = logging.getLogger(__name__)
 13
 14
[docs] 15class Http2Substream(FrameMixin, TCPIPMixin): 16 """ 17 Class to represent a HTTP2 substream. 18 19 It wraps the raw HTTP2 substream and the parent layers to extract the relevant information. 20 """ 21 KEEP_LAYERS: ClassVar[Set[str]] = {'frame', 'ip', 'ipv6', 'tcp'} 22 23 def __init__(self, raw_http2_substream: Mapping[str, Any], parent_layers: DictLayers): 24 self.layers: DictLayers = { 25 layer_name: layer_data 26 for layer_name, layer_data in parent_layers.items() 27 if layer_name in self.KEEP_LAYERS 28 } 29 self.raw_http2_substream = raw_http2_substream 30 31 @property 32 def http2_flags(self) -> int: 33 return int(self.raw_http2_substream.get('http2.flags', '0x0'), 0) 34 35 @property 36 def http2_type(self) -> int: 37 return int(self.raw_http2_substream.get('http2.type', -1)) 38 39 @property 40 def raw_headers(self) -> list[dict[str, Any]]: 41 headers = self.raw_http2_substream.get('http2.header', []) 42 if isinstance(headers, dict): 43 headers = [headers] # when only 1 header tshark does not wrap it into a list 44 assert isinstance(headers, list), headers 45 return headers
46 47
[docs] 48class Http2RequestResponse: 49 """ 50 Base class to represent a HTTP2 request or response. It contains the headers and data of the request or response. 51 Implements the common properties of a HTTP2 request or response. 52 """ 53 FALLBACK_CONTENT_TYPE: ClassVar[str] = 'application/octet-stream' 54 55 def __init__(self, substreams: Sequence[Http2Substream]): 56 self.substreams = substreams 57 self.headers, self.data, self.headers_streams, self.data_streams = Http2Helper.get_headers_and_data(substreams) 58 59 def __bool__(self) -> bool: 60 return bool(self.substreams) 61 62 @property 63 def frames_nbs(self) -> Sequence[int]: 64 # ordered set of frames numbers 65 return list({s.frame_nb: 0 for s in self.substreams}) 66 67 @property 68 def timestamp(self) -> float: 69 return self.substreams[0].timestamp 70 71 @property 72 def src_host(self) -> str: 73 return self.substreams[0].src_host 74 75 @property 76 def dst_host(self) -> str: 77 return self.substreams[0].dst_host 78 79 @property 80 def src_ip(self) -> str: 81 return self.substreams[0].src_ip 82 83 @property 84 def dst_ip(self) -> str: 85 return self.substreams[0].dst_ip 86 87 @property 88 def src_port(self) -> int: 89 return self.substreams[0].src_port 90 91 @property 92 def dst_port(self) -> int: 93 return self.substreams[0].dst_port 94 95 @property 96 def http_version(self) -> str: 97 return 'HTTP/2' 98 99 @property 100 def header_length(self) -> int: 101 # The effective payload sent over network has bytes size `http2.length` <= `http2.headers.length` 102 # (because special headers - like `:status` - have predefined codes) 103 if not self: 104 return -1 105 return sum(int(s.raw_http2_substream.get('http2.length', 0)) for s in self.headers_streams) 106 107 @property 108 def body_length(self) -> int: 109 """ 110 This is number of compressed bytes (if any compression) 111 112 - `http2.length` is also populated for header substreams 113 - we do NOT always have the `http2.body.fragments` -> `http2.body.reassembled.length` 114 """ 115 if not self: 116 return -1 117 declared_size = sum(int(s.raw_http2_substream.get('http2.length', 0)) for s in self.data_streams) 118 if declared_size != self.data.size and self.headers_map.get('content-encoding', 'identity') == 'identity': 119 warnings.warn( 120 f"Content length mismatch despite no compression: " 121 f"declared ({declared_size}) != computed ({self.data.size})" 122 f"\n{self}" 123 ) 124 return declared_size 125
[docs] 126 @cached_property 127 def headers_map(self) -> dict[str, str]: 128 # <!> only last header value is taken into account if there are some collisions 129 return { 130 h['name'].lower(): h['value'] 131 for h in self.headers 132 }
133 134 @property 135 def http_status(self) -> int: 136 return int(self.headers_map.get(':status', 0)) 137 138 @property 139 def http_method(self) -> str: 140 return self.headers_map.get(':method', '') 141 142 @property 143 def content_type(self) -> str: 144 if not self or not self.data: 145 return '' 146 return self.headers_map.get('content-type', self.FALLBACK_CONTENT_TYPE) 147
[docs] 148 def get_duration_ms(self) -> float: 149 if not self: 150 return -1 151 return round(1000 * (self.substreams[-1].timestamp - self.substreams[0].timestamp), 2)
152 153
[docs] 154class Http2Request(Http2RequestResponse): 155 """ 156 Class to represent a HTTP2 request. It contains the headers and data of the request. 157 """ 158 def __init__(self, substreams: Sequence[Http2Substream]): 159 assert substreams, "At least one substream expected for a request" 160 super().__init__(substreams) 161 162 @property 163 def uri(self) -> str: 164 uris = {s.raw_http2_substream['http2.request.full_uri'] for s in self.headers_streams} 165 assert len(uris) == 1, uris 166 return next(iter(uris)) 167 168 def __str__(self): 169 return ( 170 f"Request [#{','.join(map(str, self.frames_nbs))}]: {len(self.headers_streams)}h + {len(self.data_streams)}d substreams\n\t" 171 f"URI: {self.uri}\n\tHeaders: {self.headers_map}\n\tData: {self.data}" 172 )
173 174
[docs] 175class Http2Response(Http2RequestResponse): 176 """ 177 Class to represent a HTTP2 response. It contains the headers and data of the response. 178 179 <!> May be empty for convenience (response never received) 180 """ 181 def __str__(self): 182 return ( 183 f"Response [#{','.join(map(str, self.frames_nbs))}]: {len(self.headers_streams)}h + {len(self.data_streams)}d substreams\n\t" 184 f"Headers: {self.headers_map}\n\tData: {self.data}" 185 )
186 187
[docs] 188class Http2Stream: 189 """ 190 Class to represent an entire HTTP2 stream (multiple substreams). It contains the request and response objects. 191 Http2Stream represents a single HTTP2 stream that can contain multiple substreams as follows: 192 193 .. code-block:: 194 195 +-------------------------------------- (tcp stream, http2 stream) 196 | Http2SubStream 1 | Request headers (type: 1) 197 | Http2SubStream ... | Request data (type: 0, flags: 0x0) - partial data 198 | Http2SubStream 3 | Request data (type: 0, flags: 0x1) - end of stream, contains reassembled data 199 | (Http2SubStream 4 | Request trailers (type: 1)) 200 +-------------------------------------- 201 | Http2SubStream 5 | Response headers (type: 1) 202 | Http2SubStream ... | Response data (type: 0, flags: 0x0) - partial data 203 | Http2SubStream 7 | Response data (type: 0, flags: 0x1) - end of stream, contains reassembled data 204 | (Http2SubStream 8 | Response trailers (type: 1)) 205 +-------------------------------------- 206 207 Each HTTP2 stream is uniquely identified by a tuple (tcp stream index, http2 stream index) 208 and contains both request and response objects. 209 """
[docs] 210 def __init__(self, tcp_stream_id: int, http2_stream_id: int, community_id: str): 211 """ 212 Defines a HTTP2 stream for the given TCP stream and HTTP2 stream. 213 214 :param tcp_stream_id: the ID of the TCP stream 215 :param http2_stream_id: the ID of the HTTP2 stream 216 :param community_id: the community ID (i.e. TCP|UDP + ips & ports) for this conversation 217 """ 218 self.tcp_stream_id = tcp_stream_id 219 self.http2_stream_id = http2_stream_id 220 self.community_id = community_id 221 self.request: Optional[Http2Request] = None 222 self.response: Optional[Http2Response] = None 223 self.substreams: list[Http2Substream] = []
224 225 @property 226 def id(self) -> tuple[int, int]: 227 return (self.tcp_stream_id, self.http2_stream_id) 228
[docs] 229 def append(self, raw_http2_substream: Mapping[str, Any], parent_layers: DictLayers) -> None: 230 """ 231 Append a new substream to the HTTP2 stream. 232 233 :param substream: the substream to be added 234 :param parent_layers: all layers of the frame containing the substream (a frame can contain multiple substreams) 235 """ 236 self.substreams.append(Http2Substream(raw_http2_substream, parent_layers))
237 238 @property 239 def waiting_duration(self) -> float: 240 if not self.response: 241 return 0 242 assert self.request, self.id 243 start_stream = self.request.substreams[-1] 244 resp_stream = self.response.substreams[0] 245 return round(1000 * (resp_stream.timestamp - start_stream.timestamp), 2) 246
[docs] 247 def har_entry(self) -> Optional[dict[str, Any]]: 248 """ 249 Create a HAR entry for the HTTP2 stream. It contains the request and response objects. 250 251 :return: the HAR entry for the HTTP2 stream 252 """ 253 if not self.request: # may happen if we failed to find request among substreams 254 assert not self.response, self.id 255 return None 256 assert self.response is not None, self.id 257 first_stream = self.request.headers_streams[0] 258 return har_entry_with_common_fields({ 259 '_timestamp': first_stream.timestamp, 260 'timings': { 261 'send': self.request.get_duration_ms(), 262 'wait': self.waiting_duration, 263 'receive': self.response.get_duration_ms(), 264 }, 265 'serverIPAddress': first_stream.dst_ip, 266 '_communityId': self.community_id, 267 'request': Http2Helper.to_har(self.request), 268 'response': Http2Helper.to_har(self.response), 269 })
270 271 @staticmethod 272 def _get_raw_data_one_substream(raw_http2_substream: Mapping[str, Any]) -> Payload: 273 """ 274 Note: 275 - when dealing with a reassembled data substream, `http2.data.data_raw` MAY not contain all data 276 - if the payload was compressed, tshark decompresses ALL data for us(even if data is reassembled) 277 under `Content-encoded entity body ...` -> `http2.data.data_raw` key, so we check it first 278 """ 279 for k, v in raw_http2_substream.items(): 280 if k.lower().startswith('content-encoded entity body '): 281 assert isinstance(v, dict), (k, v) 282 if 'http2.data.data_raw' not in v: 283 if 'data_raw' in v: # special case for failed decompression (not observed but as http protocol?!) 284 return Payload(get_tshark_bytes_from_raw(v['data_raw'])) 285 # also happens in special case of empty decompressed payload (observed) 286 assert v['http2.data.data'] == '', v 287 return Payload(get_tshark_bytes_from_raw(v.get('http2.data.data_raw'))) 288 if 'http2.body.fragments' in raw_http2_substream: 289 return Payload(get_tshark_bytes_from_raw(raw_http2_substream['http2.body.fragments']['http2.body.reassembled.data_raw'])) 290 return Payload(get_tshark_bytes_from_raw(raw_http2_substream.get('http2.data.data_raw'))) 291
[docs] 292 @classmethod 293 def get_raw_data(cls, raw_http2_substreams: Sequence[Mapping[str, Any]]) -> Payload: 294 """ 295 Find the data in the substreams. 296 297 :param raw_http2_substreams: the data substreams to be analyzed 298 :return: the raw reassembled data if it exists, otherwise an empty Payload 299 """ 300 # 1) search for the unique substream with reassembled data if present 301 substreams_reassembled = { 302 ix: raw_http2_substream for ix, raw_http2_substream in enumerate(raw_http2_substreams) 303 if 'http2.body.fragments' in raw_http2_substream 304 } 305 if substreams_reassembled: 306 # should be unique and for last data substream (on rare cases: != at end of stream) 307 assert len(substreams_reassembled) == 1, substreams_reassembled 308 ix_reassembled, substream_reassembled = next(iter(substreams_reassembled.items())) 309 # assert substream_reassembled['http2.flags'] & 0x01, substream_reassembled 310 assert ix_reassembled == len(raw_http2_substreams) - 1, raw_http2_substreams 311 return cls._get_raw_data_one_substream(substream_reassembled) 312 # 2) if there is none (which happens) we manually concatenate fragments 313 # <!> decompression for overall content is NOT implemented (should not happen?!) 314 return Payload.concat(*(cls._get_raw_data_one_substream(ss) for ss in raw_http2_substreams))
315
[docs] 316 def process(self) -> None: 317 """ 318 Process the substreams and create the request and response objects accordingly. Substreams are processed in 319 order, the first substreams are request headers, followed by request data, and finally the response headers and 320 data. The reassembled data is used to create the request and response objects. 321 322 Request substreams are identified by the presence of the 'http2.request.full_uri' key in the raw stream. 323 If no response substream is found, the request object is created with the first substreams. 324 325 It retrieves the source and destination IP addresses from the first substream to identify the substreams that 326 belong to the request. The response substreams are identified by checking their source IP address matches 327 the destination IP address of the first substream. 328 """ 329 assert self.substreams, self.id 330 331 # Find a request frame and its associated IPs 332 src, dst = None, None 333 for substream in self.substreams: 334 if 'http2.request.full_uri' in substream.raw_http2_substream: # This is a request 335 src, dst = substream.src_ip_port, substream.dst_ip_port 336 break 337 if not (src and dst): 338 LOGGER.warning( 339 f"Ignoring HTTP2 stream {self.id} which is lacking a request, " 340 f"substreams types = {[ss.http2_type for ss in self.substreams]}" 341 ) 342 return 343 assert src != dst, (self.id, src) 344 345 # Create the request and response objects with their associated substreams 346 req_substreams = [substream for substream in self.substreams if substream.src_ip_port == src] 347 resp_substreams = [substream for substream in self.substreams if substream.src_ip_port == dst] 348 assert len(req_substreams) + len(resp_substreams) == len(self.substreams), ( 349 self.id, len(self.substreams), len(req_substreams), len(resp_substreams) 350 ) 351 self.request = Http2Request(req_substreams) 352 self.response = Http2Response(resp_substreams) # may be empty
353 354 def __str__(self): 355 return ( 356 f'TCP Stream: {self.tcp_stream_id}, ' 357 f'HTTP2 Stream: {self.http2_stream_id}' 358 f'\n{self.request}' 359 f'\n{self.response}' 360 )
361 362
[docs] 363class Http2Helper: 364
[docs] 365 @staticmethod 366 def substream_is_header(substream: Http2Substream) -> bool: 367 """Returns whether substream is a header substream.""" 368 stream_type = substream.http2_type 369 return stream_type == 1
370
[docs] 371 @staticmethod 372 def substream_is_data(substream: Http2Substream) -> bool: 373 """Returns whether substream is a data substream.""" 374 stream_type = substream.http2_type 375 return stream_type == 0
376
[docs] 377 @staticmethod 378 def get_headers(substream: Http2Substream) -> list[NameValueDict]: 379 """ 380 Extract the headers from the substream (precondition: it is a header substream). 381 382 :param substream: the substream to be analyzed 383 :return: the headers of the substream 384 """ 385 headers: list[NameValueDict] = [] 386 for header in substream.raw_headers: 387 # cope for non-ASCII headers 388 try: 389 h_name = get_tshark_bytes_from_raw(header['http2.header.name_raw']).decode() 390 h_value = get_tshark_bytes_from_raw(header.get('http2.header.value_raw')).decode() 391 headers.append({ 392 'name': h_name.strip(), 393 'value': h_value.strip(), 394 }) 395 except Exception as e: 396 e.add_note(f"{header=}") 397 raise 398 return headers
399
[docs] 400 @staticmethod 401 def to_har(message: Http2RequestResponse) -> dict[str, Any]: 402 """ 403 Convert the HTTP2 request or response to a HAR entry. 404 405 <!> Some HTTP2 responses are missing 406 407 :param message: the HTTP2 request or response to be converted 408 :return: the HAR entry for the HTTP2 request or response 409 """ 410 entry = { 411 '_timestamp': message.timestamp if message else None, 412 '_rawFramesNumbers': message.frames_nbs, 413 'httpVersion': message.http_version, 414 'cookies': [], 415 'headers': message.headers, 416 'headersSize': message.header_length, 417 'bodySize': message.body_length, 418 } 419 if message: 420 entry['_communication'] = get_har_communication(message) 421 if isinstance(message, Http2Request): 422 entry |= { 423 'method': message.http_method, 424 'url': message.uri, 425 'queryString': [], 426 } 427 if message.data.size: 428 message.data.update_har_request(entry, message.content_type) 429 else: 430 entry |= { 431 'status': message.http_status, 432 'statusText': '', 433 'redirectURL': '', 434 } 435 message.data.update_har_response(entry, message.content_type) 436 return entry
437
[docs] 438 @staticmethod 439 def get_data(data_substreams: Sequence[Http2Substream]) -> Payload: 440 """ 441 Extract the data from the substreams (precondition: all substreams are data substreams). 442 443 :param data_substreams: the data substreams to be analyzed 444 :return: the reassembled data 445 """ 446 return Http2Stream.get_raw_data([ss.raw_http2_substream for ss in data_substreams])
447
[docs] 448 @classmethod 449 def get_headers_and_data(cls, substreams: Sequence[Http2Substream]): 450 """ 451 Identify the headers and data substreams and return them. 452 453 The substreams are identified by their types: 454 - Headers substream: type 1 455 - Data substream: type 0 456 We ignore the rest of the substreams. 457 458 Note that (flag & 0x01) identify the end of stream, usually it happens for a data-stream 459 but it may also happen for a header-stream (trailers in gRPC), 460 or even never happen. 461 462 :param substreams: the substreams of a HTTP2 stream 463 :return: the headers and data substreams regardless if it is a request or a response 464 """ 465 headers: list[NameValueDict] = [] 466 headers_streams: list[Http2Substream] = [] 467 data_streams: list[Http2Substream] = [] 468 469 for substream in substreams: 470 # Parse headers (HTTP2 substream marked as headers) 471 if cls.substream_is_header(substream): 472 headers_streams.append(substream) 473 headers += Http2Helper.get_headers(substream) 474 # Register data substreams 475 if cls.substream_is_data(substream): 476 data_streams.append(substream) 477 478 if substreams: 479 assert headers_streams, (len(substreams), data_streams) 480 481 return headers, Http2Helper.get_data(data_streams), headers_streams, data_streams
482 483
[docs] 484class Http2Traffic: 485 """ 486 Class to represent the HTTP2 traffic. It contains the HTTP2 streams and the parsed traffic data. 487 488 In HTTP/2, frames are the smallest unit of communication. 489 Each frame has a specific type and can have associated flags. 490 491 **HTTP/2 frame types and flags:** 492 493 494 HTTP/2 Frame Types: 495 496 - `DATA (0x0)`: carries arbitrary, variable-length sequences of octets associated with a stream. 497 - `HEADERS (0x1)`: used to open a stream and carry a header block fragment. 498 - `PRIORITY (0x2)`: specifies the sender-advised priority of a stream. 499 - `RST_STREAM (0x3)`: abruptly terminates a stream. 500 - `SETTINGS (0x4)`: used to communicate configuration parameters. 501 - `PUSH_PROMISE (0x5)`: used to notify the peer endpoint in advance of streams the sender intends to initiate. 502 - `PING (0x6)`: used to measure round-trip time and ensure the connection is still active. 503 - `GOAWAY (0x7)`: informs the peer to stop creating streams on this connection. 504 - `WINDOW_UPDATE (0x8)`: used to implement flow control. 505 - `CONTINUATION (0x9)`: used to continue a sequence of header block fragments. 506 507 HTTP/2 Frame Flags: 508 509 - `END_STREAM (0x1)`: indicates that the frame is the last one for the current stream. 510 - `END_HEADERS (0x4)`: indicates that the frame contains the entire header block. 511 - `PADDED (0x8)`: indicates that the frame contains padding. 512 - `PRIORITY (0x20)`: indicates that the frame contains priority information. 513 514 **TCP stream ID and the HTTP/2 stream ID** 515 The TCP stream ID identifies a unique TCP connection. Each TCP connection is assigned a unique stream ID, 516 which is used to track the packets that belong to that connection. 517 The HTTP/2 stream ID, within a single TCP connection, multiple HTTP/2 streams can exist. Each HTTP/2 stream is 518 identified by a unique stream ID within the context of that TCP connection. These stream IDs are used to 519 multiplex multiple HTTP/2 requests and responses over a single TCP connection. 520 521 A single TCP stream (connection) can contain multiple HTTP/2 streams. Each HTTP/2 stream is 522 uniquely identified within the context of its TCP stream. The combination of the TCP stream ID and the 523 HTTP/2 stream ID uniquely identifies an HTTP/2 stream within the network traffic. 524 """ 525 def __init__(self, traffic: Sequence[DictLayers]): 526 self.traffic = traffic 527 self.stream_pairs: dict[tuple[int, int], Http2Stream] = {} 528 self.parse_traffic() 529
[docs] 530 def parse_traffic(self) -> None: 531 """ 532 Parse the traffic and extract the HTTP2 streams. It creates a dictionary for each HTTP2 stream. 533 Each key is a tuple with the TCP stream ID and the HTTP2 stream ID. 534 535 Identify each HTTP2 request and its associated HTTP2 response by following these steps: 536 537 1. Iterate through packets: it loops through all packets obtained from the `traffic` object. 538 2. Extract protocols: for each packet, it extracts the protocols from the `frame.protocols` field. 539 3. Check for HTTP2 protocol: it checks if the packet contains the `http2` protocol. 540 4. Extract the TCP stream ID: it retrieves the TCP stream ID from the `tcp.stream` field. 541 5. Handle HTTP2 layer: it ensures the `http2` layer is a list of HTTP2 stream objects. 542 6. Process each HTTP2 stream: for each HTTP2 stream in the `http2` layer: 543 544 - extract stream information: it retrieves the stream type and stream ID. 545 - filter relevant streams: it ignores streams that are not data (type 0) or headers (type 1). 546 - create or update stream pair: it creates a new tuple of `(tcp_stream_id, http2_stream_id)` if it does not 547 exist and appends the substream to the list. 548 7. Process streams: after assembling the HTTP2 streams, it processes each stream to create the request and 549 response objects. 550 """ 551 # Assemble the HTTP2 streams 552 for layers in self.traffic: 553 # Ignore non-http2 packets 554 if 'http2' not in get_protocols(layers): 555 continue 556 tcp_stream_id = get_tcp_stream_id(layers) 557 community_id = get_community_id(layers) 558 559 # HTTP2 layer can be a list of streams or a single stream, force a list 560 http2_layer: list[dict[str, Any]] = layers['http2'] 561 if not isinstance(http2_layer, list): 562 http2_layer = [layers['http2']] 563 564 for http2_layer_stream in http2_layer: 565 stream = http2_layer_stream['http2.stream'] 566 assert isinstance(stream, dict), type(stream) 567 http2_frame_type = int(stream.get('http2.type', -1)) 568 # Ignore streams that are not data or headers 569 if http2_frame_type not in {0, 1}: 570 continue 571 # <!> Edge-case: reassembled body is at top-level instead of nested in its stream 572 if 'http2.body.fragments' in http2_layer_stream: 573 assert 'http2.body.fragments' not in stream, http2_layer_stream 574 stream['http2_layer_stream'] = http2_layer_stream.pop('http2.body.fragments') 575 # Create a new tuple of (tcp_stream_id, http2_stream_id) if it does not exist 576 http2_stream_id = int(stream['http2.streamid']) 577 sid = (tcp_stream_id, http2_stream_id) 578 if sid not in self.stream_pairs: 579 self.stream_pairs[sid] = Http2Stream(*sid, community_id=community_id) 580 else: 581 assert community_id == self.stream_pairs[sid].community_id, (community_id, self.stream_pairs[sid].community_id) 582 # Append the substream to the list 583 self.stream_pairs[sid].append(stream, layers) 584 585 # Process the streams, once for all 586 for http2_stream in self.stream_pairs.values(): 587 http2_stream.process()
588
[docs] 589 def get_http2_streams(self): 590 return list(self.stream_pairs.values())
591
[docs] 592 def get_har_entries(self) -> list[HarEntry]: 593 """ 594 Convert the HTTP2 traffic to HTTP Archive (HAR) format. 595 596 :return: the HTTP2 traffic in HAR format 597 """ 598 entries = [] 599 for stream in self.get_http2_streams(): 600 har_entry = stream.har_entry() 601 if har_entry: 602 entries.append(har_entry) 603 return entries