Skip to content

fix(http): identify the HTTP version before parsing, not by trial and error (#800) - #814

Merged
JarryShaw merged 1 commit into
mainfrom
fix/800-http-version-positive-identification
Sep 25, 2026
Merged

JarryShaw merged 1 commit into
mainfrom
fix/800-http-version-positive-identification

Conversation

@JarryShaw

@JarryShaw JarryShaw commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Please follow the guide below

What is the purpose of your pull request?

  • fix — corrects a defect
  • feat — adds a feature
  • perf — changes performance, not behaviour
  • refactor — changes neither behaviour nor performance
  • test — tests only
  • docs — documentation only
  • ci — workflows or build tooling
  • chore — anything else

Description of your pull request and other information

Closes #800.

_guess_version now identifies before it parses. Measured on 4530424df vs this branch:

payload before after
preface + SETTINGS ProtocolError: unknown HTTP version version='2', SETTINGS parsed
preface alone (24 octets) ProtocolError: unknown HTTP version ProtocolError: HTTP/2: connection preface with no frame
b'foo bar baz\r\nX: y\r\n\r\n' ProtocolError: unknown HTTP version unchanged — and now by identification, not by #802's length guard
bare SETTINGS (mid-stream) version='2' unchanged, via the fall-through
Upgrade: h2c request / 101 version='1.1' unchanged
malformed HTTP/1 (no-colon field) unknown HTTP version HTTP: invalid format, from the version identified

The preface is skipped rather than fed to httpv2, and counts as header, so info is byte-identical to reading that frame alone — without which the injected packet=self.packet.payload sliced from octet 9 of a buffer whose frame starts at 24 and reported preface remnants as payload.

Out of scope, deliberately: Upgrade: h2c needs per-connection state (one payload here, no flow context), and a mid-stream frame is undecidable — no type <= 9 heuristic, since that is what misfires on binary HTTP/1 bodies. Both are documented in _guess_version and pinned by tests.

Protochain over all 23 sample captures (1604 frames, 231 HTTP-bearing) is byte-identical to 4530424df; pytest tests/protocols/application/ 118 passed / 420 subtests, cross-checked at 59 tests OK under unittest; coverage 100% on http.py and httpv1.py.

… error (#800)

`HTTP._guess_version` decided the version by trial-parsing -- try `httpv1`,
and if it declines, try `httpv2` -- which answers "did a parser accept this?"
where the question is "what is this?", and got both directions wrong. The
HTTP/2 connection preface came back `version='2'` only because `httpv2` read
its `b'PRI'` as a declared frame length of 5,265,993, and garbage text came
back `version='2'` the same way. #802 made that inconsistency a refusal, which
left a real HTTP/2 connection opening reported as not-HTTP at all.

* positively identify HTTP/2 by prefix-comparing the first 24 octets against
  `b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n'` (RFC 9113 section 3.4), then parse the
  frame that follows the preface rather than the preface itself; a preface
  counts as header, so `length` includes it and `info.packet` no longer reports
  the tail of the preface as payload
* positively identify HTTP/1 with `_test_start_line`, which applies the
  parser's own anchored patterns and unpackings, and commit to that version
  instead of re-offering a malformed HTTP/1 message to the HTTP/2 arm
* keep the trial parse as a last resort only, for a mid-stream segment that
  carries neither preface nor start line; no frame-header heuristic is added
* leave `Upgrade: h2c` out of scope -- it is stateful and correctly HTTP/1.1
  on the wire -- and document why, with a test pinning it

`pytest tests/protocols/application/` 118 passed, 420 subtests; `unittest`
59 tests OK; protochain over all 23 sample captures (1604 frames, 231
HTTP-bearing) byte-identical to `4530424df`; coverage 100% on both changed
modules.
@JarryShaw JarryShaw added bug fix Pull requests that fix a defect (fix: subject prefix) test Pull requests that add or correct tests (test: subject prefix) docs Pull requests that change documentation only (docs: subject prefix) review: pending No verdict for the current head - never reviewed, or the head moved since the last one labels Sep 25, 2026
@JarryShaw

Copy link
Copy Markdown
Owner Author

Two corrections from the author, both verified by me, and the second changes what this PR should be credited with.

1. My brief named the wrong base. I wrote f046b38f8; origin/main was already 4530424df (#803 landed in between). The author based on and measured against 4530424df, which is correct.

2. Garbage text does not read HTTP/2 on stock — #802 already closed that half. Measured by me on the real 4530424df:

PROVENANCE: /tmp/m4530/pcapkit/__init__.py
garbage text b'foo bar baz\r\nX: y\r\n\r\n'  -> ProtocolError: unknown HTTP version
real preface + SETTINGS                       -> ProtocolError: unknown HTTP version

So #800's headline had two halves and only one was live. The version=2-on-garbage figure in the issue was measured pre-#802, and #802's schema.length > length check already fixed it — any text payload's first three ASCII octets declare ≥ 2,105,376. The live defect was the false negative: a real HTTP/2 connection opening read as not-HTTP at all, which this PR fixes. The author said so unprompted rather than claiming both halves, and labelled its own garbage-text test a regression guard rather than a fix demonstration. That is the right call and I am recording it so #800 is not closed on an overstated basis.

Two defects its own probes caught that would otherwise have shipped, both worth noting because each would have been invisible in review:

  • The start-line predicate claimed the preface. The preface is deliberately a well-formed HTTP/1.1 request line, so testing only the first line returned True for it — mirroring read()'s \r\n\r\n split first is what fixes it, since the preface's header is PRI * HTTP/2.0 with no CRLF left. Only arm ordering was hiding it; the agreement test found it.
  • info.packet leaked the preface tail. ProtocolBase.__init__ injects packet=self.packet.payload sliced at self.length, so with the frame at octet 24 and length 9, twenty-four octets of preface remnant came back as payload. Fixed with _preface_length so info is byte-identical to reading the frame alone.

Coverage discipline worth crediting: the first pass after the fix dropped http.py to 98% (lines 318-319, the struct.error normalisation), and rather than leave it the author added the preface + 16-octet GOAWAY case — the #805 residual — to reach 100%/100% again.

Cross-review running. Upgrade: h2c and mid-stream both correctly left out of scope, with a passing regression test pinning h2c as HTTP/1.1 so a later "h2c support" attempt has to change a test that explains why not.

@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review verdict: GOOD TO GO (sonnet; author was opus). All seven load-bearing claims independently measured against both trees, nothing left unverified in budget.

The preface is now positively identified, and the diagnosis is honest. preface + SETTINGS before → ProtocolError: unknown HTTP version; after → version='2', info.packet == b'', info.length == 9 — not 5265993. preface alone after → ProtocolError: HTTP/2: connection preface with no frame, a distinct answer rather than a generic one. The 24-byte compare runs before any import of httpv1/httpv2, so the trial parse is strictly downstream.

The false-positive attack went further than I asked and the fix holds. Beyond POST/PUT/PATCH/PROPFIND, the reviewer tried PRIVATE — a method literally starting with PRI — and b'PRI / HTTP/1.1\r\nHost: x\r\n\r\n', the reserved method one octet from a false positive. Both correctly return version='1.1', because the compare is an exact 24-byte match rather than a method-prefix heuristic.

Corpus regression clean, and it instrumented the corollary rather than inferring it. 23 captures, total_frames=1604 http_frames=231 on both trees, full per-frame protochain diff byte-for-byte empty. Then it instrumented _guess_version directly and confirmed it is entered 0 times across the whole corpus — tcp.py:330-331 binds httpv1.HTTP for TCP:80/8080, and no fixture uses UDP:80/8080. So all real coverage of this fix is synthetic, and the corpus-identical result is a regression guard with zero coverage of the new path. Worth stating in the PR body, which currently only implies it.

Scope discipline confirmed: no frame-header heuristic anywhere, httpv2.py untouched, and the trial-parse fall-through moved down but otherwise byte-identical. httpv1.py's +65 lines are one pure function in a single hunk, not called from within httpv1.py at all, so read()/_read_http_header() are untouched.

Fail-before is 6 of 8, with the two exceptions named and legitimate. test_guess_version_reaches_http2_on_the_connection_preface (the explicit version=2 path, untouched by this fix) and test_guess_version_keeps_an_upgrade_h2c_exchange_on_http1 (h2c out of scope) both pass on stock — each self-declared in its own docstring as a regression pin rather than a fix demonstration. Honest intentional exception, not a hidden gap. Full file on head: 59/59 under unittest.

One discrepancy, non-load-bearing: the body claims 420 pytest subtests, measured 388. unittest's 59/59 matches the body and is the authority here.

The reviewer caught its own probe artefact and said so — its first synthetic frame helper used payload-only length instead of this library's whole-frame convention, producing a false "preface+SETTINGS still fails" that contradicted the PR's table. It fixed it by diffing against the test file's real http2_frame_bytes helper rather than trusting its own construction. Twelfth artefact this session, and the fourth caught by the agent that made it.

Flipping to review: good-to-go.

@JarryShaw JarryShaw added review: good-to-go Cross-review at the current head says ready; CI state is separate and removed review: pending No verdict for the current head - never reviewed, or the head moved since the last one labels Sep 25, 2026
@JarryShaw
JarryShaw merged commit 3cbdf89 into main Sep 25, 2026
31 checks passed
@JarryShaw
JarryShaw deleted the fix/800-http-version-positive-identification branch September 25, 2026 23:38
@JarryShaw JarryShaw removed the review: good-to-go Cross-review at the current head says ready; CI state is separate label Sep 25, 2026
JarryShaw added a commit that referenced this pull request Sep 26, 2026
`main` moved from 4530424 to 3cbdf89 while this PR sat open. Four are the
new PRs merged in that window (#811-#814); the other four are older defects
(#704, #723, #739, #743/#746) whose fixes had merged earlier but were never
cited. Eight new bullets cover them, appended in merge order:

- #704 -- `SystemdJournalExportBlock.post_process` skipped a binary field's
  trailing newline by reading to EOF, discarding every field behind it.
- #723 -- the same block split entries on a bare `b'\n\n'`, shredding binary
  data that contains that byte pair; fixed alongside an independent
  trailing-separator/EOF ambiguity.
- #739 -- five more registrars (`register_engine`/`_reassembly`/`_traceflow`,
  `register_dumper` x2) sat outside #718/#726's identity guard.
- #743, #746 -- `pypcapfile`'s `IP.src`/`.dst` are dotted-decimal text, not
  packed bytes, and its frames need un-hexlifying before decoding; the two
  fixes are cross-dependent and landed together.
- #805 -- `FieldBase.length`'s `struct.calcsize` on a negative resolved
  length raised a bare `struct.error`; now `ProtocolError`. Closes the
  follow-on #802's own entry filed as out of scope.
- #796 -- thirteen `re.sub` sites under `pcapkit/vendor/` passed
  `re.MULTILINE` positionally as `count`, not as `flags=`.
- #800 -- `httpv2._guess_version` now identifies a connection preface before
  parsing it, rather than by trial and error.

Derived the gap by diffing `git log 73f09ae..origin/main` against
`gh pr view --json state,mergedAt` for every candidate number, not from
commit-subject text alone. `CHANGELOG.md` regenerated with
`util/changelog_md.py`; `--check` exit 0 and `test_changelog_md.py`'s 47
tests pass. Sphinx's full-site build did not finish inside budget --
`pcapkit.const.reg`'s autodoc page is slow regardless of this change --
so verified instead with `docutils --report=1`, which parses the updated
file with zero messages.
JarryShaw added a commit that referenced this pull request Sep 26, 2026
…eneric wrap

- test_guess_version_reports_a_preface_with_no_frame_as_such's GOAWAY case
  asserted str(exception) == 'HTTP/2: invalid format', which #814 produced
  when the sixteen-octet GOAWAY's oversized declared length drove the
  ``debug`` field negative and struct.calcsize raised a bare struct.error.
- #811 (fix(fields): raise ProtocolError, not struct.error, on a negative
  resolved field length) landed after #814's branch point and fixed that
  exact case at its root: FieldBase.length now raises ProtocolError itself,
  so http.py's ``except ProtocolError: raise`` passes it through unchanged
  instead of reaching the ``except (ValueError, struct.error)`` wrap that
  produced #814's message. The assertion was stale, not the behaviour.
- Update the assertion to the field-level message ("Field debug resolved to
  a negative length; template='-1s'") while keeping every other check #814
  cared about: still a catchable BaseError, still not a bare struct.error,
  still chained to the original struct.error via __cause__.

Verified the failure on stock 3cbdf89 (CPython 3.14) and the fix passing
on both 3.14 and 3.10; tests/protocols/application/ and tests/corekit/
otherwise pass (338 passed, 16 skipped -- 5 unrelated pre-existing failures
in test_http_runtime.py were just missing generated sample captures, fixed
by running examples/generators/make_samples.py). No production code
changed, so coverage of pcapkit/protocols/application/http.py (98%) and
pcapkit/corekit/fields/field.py (75%) is unchanged.

Closes #822
JarryShaw added a commit that referenced this pull request Sep 26, 2026
`TCP.__proto__` bound `httpv1.HTTP` directly for ports 80 and 8080, so a
segment on either port was HTTP/1 by assertion of the port number: an HTTP/2
payload there was refused by the HTTP/1 parser and reported as `Raw`. Both
versions share those ports on the wire, so the port cannot decide the version
and the payload has to.

* `pcapkit/protocols/transport/tcp.py` — ports 80 and 8080 now resolve to
  `pcapkit.protocols.application.http.HTTP`, which identifies the version
  before parsing (#800, landed via #814) rather than trial-parsing.
* `examples/generators/dispatch.py` — `PINNED_TARGETS` for `tcp/80` and
  `tcp/8080` follow the repoint.
* `pcapkit/protocols/transport/udp.py`, `docs/source/pep.rst` — the prose
  documenting the TCP/UDP asymmetry is now stale; both tables agree.
* `tests/protocols/transport/test_tcp_http_dispatch_unit.py` — new; pins the
  descriptor, the TCP/UDP parity, and that HTTP/2 on port 80/8080 decodes as
  HTTP/2 while HTTP/1.1 still decodes as HTTP/1.1.

All 231 HTTP/1.1 frames in the 23-capture corpus keep their chain; 9 frames in
`options-transport.pcap` change `TCP:Raw` -> `TCP:HTTP/2`, which is the defect
being fixed. `_guess_version` entry count over the corpus goes 0 -> 252.
JarryShaw added a commit that referenced this pull request Sep 26, 2026
…s in #817 and #821

Two bullets, both non-breaking, appended after the #800 entry in merge
order. Bullet count 128 to 130 (`grep -cE '^\* \*\*'`).

- #804 (PR #817) -- the three `__repr__` methods #798 left `%`-formatted
  are f-strings now, dropping `consider-using-f-string` from both const
  modules and both vendor templates; the other bespoke templates in
  `{const,vendor}/{ftp,http}/` still carry the disable, so #804's claim
  holds for this pair only, not for those directories.

- #682 (PR #821) -- `TCP.__proto__` no longer binds `httpv1.HTTP` directly
  for ports 80/8080; both repoint to the generic HTTP proxy `_guess_version`
  identifies through, which only became reliable once #800/#814 landed.
  `udp.py` already pointed there, so that side of the PR is prose-only
  (its port rows and docstring), not a code change, and the entry says so.
  Protochain over the 23 sample captures is *not* byte-identical: 9 frames
  in `options-transport.pcap` go `Raw` to `HTTP/2`, all 231 HTTP/1.1 frames
  are unaffected, and `_guess_version`'s entry count goes 0 to 252.

  Not marked `**a breaking change to**`: PR #821's own labels are
  `bug,fix,docs,test`, no `breaking`, unlike #759/#783 and #805/#811 last
  round, whose crediting PRs did carry it. The entry does say what a
  `breaking`-blind reader would still want to know -- TCP:80/8080 traffic
  that is neither valid HTTP/1 nor preface-carrying now reaches
  `_guess_version`'s fall-through arm instead of the direct `httpv1` bind's
  unconditional `Raw`, which is where the 12 (of 252) fall-throughs the PR
  measured come from.

`util/changelog_md.py` regenerated `CHANGELOG.md`, first pass, no
line-spanning literal this round; `--check` exit 0.
`test_changelog_md.py` 47 passed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug docs Pull requests that change documentation only (docs: subject prefix) fix Pull requests that fix a defect (fix: subject prefix) test Pull requests that add or correct tests (test: subject prefix)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

http: _guess_version trial-parses instead of identifying, so it answers HTTP/2 for garbage text and for the preface by accident

1 participant