Skip to content

http: _guess_version's HTTP/2 arm is unreachable, and PayloadField.protocol does no case folding #787

Description

@JarryShaw

Found while investigating #682, and independent of how that issue is decided. Two separate defects in the same neighbourhood; both measured.

1. _guess_version's HTTP/2 arm is unreachable

pcapkit/protocols/application/http.py:202-207 tries HTTP/1 then HTTP/2, each under contextlib.suppress(ProtocolError):

with contextlib.suppress(ProtocolError):
    return HTTPv1(self._data, length, **kwargs)
with contextlib.suppress(ProtocolError):
    return HTTPv2(self._data, length, **kwargs)

But httpv1.HTTP raises a bare ValueError on HTTP/2 wire bytes, not a ProtocolError. ValueError is not suppressed, so it propagates out of the first arm and the HTTP/2 arm is never reached.

Measured on b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n' plus a SETTINGS frame: httpv2.HTTP parses it and reports version='2', while the proxy raises ValueError: not enough values to unpack (expected 2, got 1).

The asymmetry is visible two screens up: the explicit version= path at :115-120 does wrap it —

except ProtocolError:
    raise
except ValueError as error:
    raise ProtocolError(f'HTTP/{version}: invalid format') from error

— so read(version=2) works and read() cannot. Combined with the port bindings (tcp.py:330-331 binds 80/8080 to concrete HTTP/1, and httpv2.HTTP is the value of no port on any transport), HTTP/2 is reachable only by explicit version=2 or direct instantiation, never automatically. That undercuts the proxy's purpose as a dispatcher.

Either suppress ValueError alongside ProtocolError in _guess_version, or make httpv1.HTTP raise a ProtocolError for a malformed request line the way the explicit path already normalises it to. The second is the better fix if ValueError from that constructor is never meaningful to a caller — worth checking before choosing.

2. PayloadField.protocol does no case folding

pcapkit/corekit/fields/misc.py:265-268:

if isinstance(protocol, str):
    from pcapkit.protocols import __proto__
    protocol = cast('Type[_TP]', __proto__.get(protocol))

__proto__ is keyed on protocol.__name__.upper() (pcapkit/foundation/registry/protocols.py:219), but this reader does not .upper() its argument and uses .get, so a lowercase or mixed-case name silently resolves to None. PayloadField(protocol='http') therefore yields Raw rather than HTTP, with no warning — today, independent of any registry displacement.

Every other reader compares against id() as well and so tolerates the miss; this one does not. One line plus a test.

Both are separable from #682's design question and from each other.

Activity

  1. added
    bugIssues reporting a defect (set by the bug report template; a default, not an assessment)
    fixPull requests that fix a defect (fix: subject prefix)
    wipWork in flight - a covering PR is open or an agent is actively on it
    on Sep 25, 2026
  2. JarryShaw commented on Sep 25, 2026

    @JarryShaw
    OwnerAuthor

    Correcting this issue's own reproduction. #789's worker challenged it and is right — and the truth is worse than what I wrote. Measured on main:

    -- CONSTRUCTOR, which this issue named as the repro --
      PayloadField(protocol='http'):  _protocol='http'  .protocol=http      <- a bare str, not Raw
      PayloadField(protocol='HTTP'):  _protocol='HTTP'  .protocol=HTTP      <- also broken
      PayloadField(protocol='TCP'):   _protocol='TCP'   .protocol=TCP
    
    -- SETTER --
      f.protocol = 'http'  -> _protocol=None  .protocol=<class '...misc.raw.Raw'>
      f.protocol = 'HTTP'  -> _protocol=<class '...application.http.HTTP'>
      f.protocol = 'tcp'   -> _protocol=None  .protocol=<class '...misc.raw.Raw'>
    

    So there are two defects on different paths and I conflated them:

    1. The constructor never resolves at all. __init__ assigned _protocol directly, bypassing the property, so .protocol returns the bare string — for 'HTTP' exactly as much as for 'http'. Case was never this path's problem, and the value is not Raw, it is a str where a class is expected.
    2. The setter case-folds wrongly, which is the .upper() omission this issue described — real, but only reachable by assigning to the property.

    My repro line paired path 1 with path 2's symptom. #789 fixes both by routing __init__ through the property; the registry import stays inside the isinstance(str) branch so schema class bodies still do not import pcapkit.protocols mid-import.

    Worth recording that the two agents that measured this did not disagree — one measured the setter, one the constructor. The issue text was the thing that was wrong.

  3. JarryShaw commented on Sep 25, 2026

    @JarryShaw
    OwnerAuthor

    Fixed and merged — #789 landed at 18:34:14Z as 0928abb2c.

    Closing by hand: #789 carried no Closes keyword, so this stayed open despite being fixed. Same defect I caught on #784 earlier today and did not check for here.

    What landed, against this issue's two halves:

    • _guess_version's HTTP/2 arm is reachable. httpv1 now raises a chained ProtocolError at the sites a non-HTTP/1 payload lands short on, rather than a bare ValueError that suppress(ProtocolError) could not catch. A bounding test, test_httpv1_never_lets_a_bare_exception_escape, asserts nothing escapes httpv1 that is not a ProtocolError — 13 subtest failures against the pre-fix library, so it bites. The cross-review then proved the raise-site enumeration complete two independent ways, including a 20,000-payload fuzz that found exactly three exception kinds, no fourth.
    • PayloadField name resolution. Both halves fixed. Worth recording that this issue's own reproduction was wrong, corrected on the thread at 14:43Z: PayloadField(protocol='http') returned the bare string 'http', not Raw, because __init__ assigned _protocol directly and bypassed the property — and 'HTTP' was equally broken, so case was never the constructor's problem. The .upper() omission was real but only reachable through the setter. Two defects on two paths, conflated in what I filed.

    One consequence disclosed rather than hidden: four input classes that previously read UDP:Raw now read UDP:HTTP/2, listed per conversion site in the PR body rather than by example — the count had crept 1→3→4→5 across review rounds precisely because it was being enumerated by example.

    Two follow-ups carry the remainder, both tracked: #799 — httpv2's frame guard tests the declared length rather than the buffer, so a 4-octet frame can report length=16777215; fixing it lets _guess_version drop the struct.error suppression entirely and retire the residual this PR had to accept. And the HTTPv2-direct sub-9 struct.error, which #799 covers as the same defect from a more consequential angle.

  4. removed
    wipWork in flight - a covering PR is open or an agent is actively on it
    on Sep 25, 2026
  5. added this to the 1.5 milestone on Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugIssues reporting a defect (set by the bug report template; a default, not an assessment)fixPull requests that fix a defect (fix: subject prefix)

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions