fix(http): check the buffer's length, not just the declared one, in HTTP/2's frame guard - #802
Conversation
|
Cross-review verdict: NEEDS CHANGES (haiku; author was sonnet). The 1. The narrowed The MRO is the damning part: at head the escape is not a Why the original sweep measured zero: its nine byte patterns land on frame types 2. #799's headline harm survives, unchanged. Verified on both trees: Also true at buflens 10, 12, 16, 24 — overstated by 16,777,206 octets. The guard compares each quantity against the constant Required: fix the root cause — a negative field length should raise Mitigation worth noting: |
…TTP/2's frame guard - httpv2.HTTP.read() tested only the 24-bit declared length off the wire (schema.length < 9), never how many octets the buffer actually held, so a frame backed by far fewer real octets than it declared still parsed and reported the declared, attacker-controlled length. Sweeping the declared length against a 4-octet buffer showed non-monotone accept/reject (9, 15, 65535, 16777215 parsed; 10, 100 did not) -- an artifact of _read_http_settings's own unrelated (declared - 9) % 6 check, not evidence the buffer held what was declared. - HTTP.unpack now rejects a non-empty buffer under nine octets before the schema layer runs (preserving StreamEOFError for a genuinely exhausted, implicit-length stream), and read's guard now also requires the declared length not to exceed the available buffer. - A buffer that clears nine octets can still carry a frame type whose own fixed-width fields exceed what is left after the header (GOAWAY at 9-16 octets, PUSH_PROMISE at 9-12, padded DATA/HEADERS/PUSH_PROMISE) and still crash the schema layer with a bare struct.error; that root cause is in generic Schema.unpack/FieldBase.length machinery shared by ten schema modules, out of scope here and filed as #805. _guess_version's last arm therefore keeps suppressing struct.error alongside ProtocolError. Build/test: 75 tests green in tests/protocols/application/test_http_unit.py and test_httpv2_payload_length_unit.py, 100% line+branch on both changed files standalone; tests/corekit/ (218) and test_construction_keyword_check_unit.py (22) unaffected; protochain over 23 sample captures / 1604 frames byte- identical to main; mypy and pylint report no new findings.
958e3ed to
3d85e56
Compare
|
Revised to Blocker 1 was resolved by dropping the narrowing rather than by widening the guard, which is the right call: arm 2 goes back to A consequence worth noting: the Blocker 2 closed by adding Tightening it broke two pre-existing #787 tests, and the reason is the interesting part: both had been passing only because of the bug #799 fixes. The defeated structural test was deleted rather than patched, since its premise (both arms suppress only Flipping |
|
Cross-review verdict at
It also re-ran the 18,144-case sub-9 grid despite the Both rewritten #787 tests fail against true base, so they assert something real rather than merely passing. The first keeps the load-bearing The deleted structural test lost nothing. Its replacement uses real wire bytes on the un-mocked guess path — the exact gap the old one had — and is load-bearing: run against the old-head library it errors with
Coverage caveat gone: Residual, stated not hidden: 1136
Flipping to |
…k included `main` moved from 73f09ae to 4530424 while this PR sat open, and the 1.5.0 section cited none of the 25 commits in between. Ten new bullets cover thirteen of them, appended in merge order, with the file's own `**a breaking change**` lead sentence on the three that are breaking: - #754 -- AppType split into per-transport registries; the 1,004 portless and 704 transportless rows stop being members. Breaking. - #764 -- an out-of-range port in `AppType.get` is refused, not minted, so `TCP.make(srcport=99999)` raises; per-transport `_missing_` spans. Breaking. - #778 -- `@final` enforced at runtime on `Info`/`Schema`: bare `@final` raises `InfoError`/`SchemaError` at first construction, deriving from a finalised class raises, and `SchemaError` is a `ValueError` where a caller may have been catching `TypeError`. Breaking. - #772 (with #790's docstring reword), #766, #759, #787, #794 (with #791's citation repoint), #792/#798 and #802 -- the remaining seven. Also re-ran the citation sweep against `origin/main` rather than the checkout. One stale line number fixed: the `httpv2.py` `header.length != 9` guard the `#692` entry calls out moved from `:562` to `:650` under #789 and #802. The preamble's "reaching #726" becomes #805, the new maximum reference. Verified unmoved on 4530424: `protocol.py:1411`, `schema/internet/ipv4.py:336`, `traceflow.py` 146/149/162/424, the four `:type:` fields in `engine.rst`, `reassembly.rst` and `traceflow.rst`, and `EXPECTED_FAILURES` at 43 entries. Carries the previous round's #651/#646 corrections unchanged. Two literals were reflowed so no ``literal`` wraps a line, which the generator's residual guard refuses. `changelog_md.py --check` exit 0; `test_changelog_md.py` 47 passed, 37 subtests.
… error (#800) (#814) `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.
`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.
`main` moved from 73f09ae to 3cbdf89 while this PR sat open. This commit (relative to its parent, 4233555) now names all 20 bullets it carries, not just the 8 this session added on top of the 10 already there -- a first draft of this message named only its own 8 and left the other 10 silent, which a cross-review caught. Four are breaking: - #754 -- AppType split into per-transport registries; the 1,004 portless and 704 transportless rows stop being members. - #764 -- an out-of-range port in `AppType.get` is refused, not minted. - #778 -- `@final` enforced at runtime on `Info`/`Schema`. - #575 -- four `.get()`-backed enum fields fall through to `_unregistered_member` instead of minting; 14 of 23 sample captures change output. - #772 (with #790's docstring reword), #766, #759, #787, #794 (with #791's citation repoint), #792/#798, #802, #782 (a further #745-hazard instance), #704, #723, #739, #743/#746 (cross-dependent, one bullet each), #805 (closes #802's own filed-as-out-of-scope), #796, #800 -- the other 16, non-breaking. Also restores a measurement an earlier round in this same diff dropped while updating an adjacent one: the #692 entry's "mypy is unmoved at 112 errors" silently lost its "and pylint ... 364 messages" half when `EXPECTED_FAILURES` was corrected 44 to 43 elsewhere in the same sentence. Restored to the last value earlier rounds signed off on rather than re-measured, since this branch's own `pcapkit/` tree predates several since-merged PRs and a fresh run would not be measuring the same thing the original round measured. Unmoved, and not silently dropped this time: `93 of the 95 sites` corrected to `94 of the 95` and `EXPECTED_FAILURES` 44 to 43 in the several other places that already carried the fix. On which PRs get a bullet: there is no clean "user-facing only" rule -- #766 and #791 are pure CI/test/lint-comment entries that are in, while #773 and #763 are the same kind of thing and are out. The real pattern across this file's 46 commits is closer to "each round's author judged it worth a reader's time," which is inconsistent by construction. This round leaves that inconsistency as found rather than trying to retrofit a rule, but did add #782 on reconsideration -- its own PR body names it as sharing #766's hazard, and pre-existing precedent already treats that hazard's instances as bullet-worthy. `changelog_md.py` regenerated `CHANGELOG.md`; `--check` exit 0. `test_changelog_md.py` 47 passed.
`main` moved from 73f09ae to 3cbdf89 while this PR sat open. This commit (relative to its parent, 4233555) now names all 20 bullets it carries, not just the 8 this session added on top of the 10 already there -- a first draft of this message named only its own 8 and left the other 10 silent, which a cross-review caught. Six are breaking, matching the crediting PRs' own `breaking` label in each case: - #754 -- AppType split into per-transport registries; the 1,004 portless and 704 transportless rows stop being members. - #764 -- an out-of-range port in `AppType.get` is refused, not minted. - #778 -- `@final` enforced at runtime on `Info`/`Schema`. - #575 -- four `.get()`-backed enum fields fall through to `_unregistered_member` instead of minting; 14 of 23 sample captures change output. - #759 -- `AppType._dispatch` on a multi-transport `proto` now raises `ProtocolError` instead of silently resolving to whichever transport owns the lowest set bit. - #805 -- `FieldBase.length` on a negative resolved length now raises `ProtocolError` instead of letting a bare `struct.error` escape. A second cross-review caught both: their crediting PRs (#783, #811) both carry GitHub's own `breaking` label, and neither bullet said so. - #772 (with #790's docstring reword), #766, #787, #794 (with #791's citation repoint), #792/#798, #802, #779 (via #782, a further #745-hazard instance), #704, #723, #739, #743/#746 (cross-dependent, one bullet each), #796, #800 -- the other 14, non-breaking. Also restores a measurement an earlier round in this same diff dropped while updating an adjacent one: the #692 entry's "mypy is unmoved at 112 errors" silently lost its "and pylint ... 364 messages" half when `EXPECTED_FAILURES` was corrected 44 to 43 elsewhere in the same sentence. Restored to the last value earlier rounds signed off on rather than re-measured, since this branch's own `pcapkit/` tree predates several since-merged PRs and a fresh run would not be measuring the same thing the original round measured. Unmoved, and not silently dropped this time: `93 of the 95 sites` corrected to `94 of the 95` and `EXPECTED_FAILURES` 44 to 43 in the several other places that already carried the fix. On which PRs get a bullet: there is no clean "user-facing only" rule -- #766 and #791 are pure CI/test/lint-comment entries that are in, while #773 and #763 are the same kind of thing and are out. The real pattern across this file's 46 commits is closer to "each round's author judged it worth a reader's time," which is inconsistent by construction. This round leaves that inconsistency as found rather than trying to retrofit a rule, but did add #779 (via #782) on reconsideration -- its own PR body names it as sharing #766's hazard, and pre-existing precedent already treats that hazard's instances as bullet-worthy. `changelog_md.py` regenerated `CHANGELOG.md`; `--check` exit 0. `test_changelog_md.py` 47 passed.
`main` moved from 73f09ae to 3cbdf89 while this PR sat open. This commit (relative to its parent, 4233555) now names all 20 bullets it carries, not just the 8 this session added on top of the 10 already there -- a first draft of this message named only its own 8 and left the other 10 silent, which a cross-review caught. Six are breaking, matching the crediting PRs' own `breaking` label in each case: - #754 -- AppType split into per-transport registries; the 1,004 portless and 704 transportless rows stop being members. - #764 -- an out-of-range port in `AppType.get` is refused, not minted. - #778 -- `@final` enforced at runtime on `Info`/`Schema`. - #575 -- four `.get()`-backed enum fields fall through to `_unregistered_member` instead of minting; 14 of 23 sample captures change output. - #759 -- `AppType._dispatch` on a multi-transport `proto` now raises `ProtocolError` instead of silently resolving to whichever transport owns the lowest set bit. - #805 -- `FieldBase.length` on a negative resolved length now raises `ProtocolError` instead of letting a bare `struct.error` escape. A second cross-review caught both: their crediting PRs (#783, #811) both carry GitHub's own `breaking` label, and neither bullet said so. - #772 (with #790's docstring reword), #766, #787, #794 (with #791's citation repoint), #792/#798, #802, #779 (via #782, a further #745-hazard instance), #704, #723, #739, #743/#746 (cross-dependent, one bullet each), #796, #800 -- the other 14, non-breaking. Also restores a measurement an earlier round in this same diff dropped while updating an adjacent one: the #692 entry's "mypy is unmoved at 112 errors" silently lost its "and pylint ... 364 messages" half when `EXPECTED_FAILURES` was corrected 44 to 43 elsewhere in the same sentence. Restored to the last value earlier rounds signed off on rather than re-measured, since this branch's own `pcapkit/` tree predates several since-merged PRs and a fresh run would not be measuring the same thing the original round measured. Unmoved, and not silently dropped this time: `93 of the 95 sites` corrected to `94 of the 95` and `EXPECTED_FAILURES` 44 to 43 in the several other places that already carried the fix. On which PRs get a bullet: there is no clean "user-facing only" rule -- #766 and #791 are pure CI/test/lint-comment entries that are in, while #773 and #763 are the same kind of thing and are out. The real pattern across this file's 46 commits is closer to "each round's author judged it worth a reader's time," which is inconsistent by construction. This round leaves that inconsistency as found rather than trying to retrofit a rule, but did add #779 (via #782) on reconsideration -- its own PR body names it as sharing #766's hazard, and pre-existing precedent already treats that hazard's instances as bullet-worthy. `changelog_md.py` regenerated `CHANGELOG.md`; `--check` exit 0. `test_changelog_md.py` 47 passed.
`main` moved from 73f09ae to 3cbdf89 while this PR sat open. This commit (relative to its parent, 4233555) now names all 20 bullets it carries, not just the 8 this session added on top of the 10 already there -- a first draft of this message named only its own 8 and left the other 10 silent, which a cross-review caught. Six are breaking, matching the crediting PRs' own `breaking` label in each case: - #754 -- AppType split into per-transport registries; the 1,004 portless and 704 transportless rows stop being members. - #764 -- an out-of-range port in `AppType.get` is refused, not minted. - #778 -- `@final` enforced at runtime on `Info`/`Schema`. - #575 -- four `.get()`-backed enum fields fall through to `_unregistered_member` instead of minting; 14 of 23 sample captures change output. - #759 -- `AppType._dispatch` on a multi-transport `proto` now raises `ProtocolError` instead of silently resolving to whichever transport owns the lowest set bit. - #805 -- `FieldBase.length` on a negative resolved length now raises `ProtocolError` instead of letting a bare `struct.error` escape. A second cross-review caught both: their crediting PRs (#783, #811) both carry GitHub's own `breaking` label, and neither bullet said so. - #772 (with #790's docstring reword), #766, #787, #794 (with #791's citation repoint), #792/#798, #802, #779 (via #782, a further #745-hazard instance), #704, #723, #739, #743/#746 (cross-dependent, one bullet each), #796, #800 -- the other 14, non-breaking. Also restores a measurement an earlier round in this same diff dropped while updating an adjacent one: the #692 entry's "mypy is unmoved at 112 errors" silently lost its "and pylint ... 364 messages" half when `EXPECTED_FAILURES` was corrected 44 to 43 elsewhere in the same sentence. Restored to the last value earlier rounds signed off on rather than re-measured, since this branch's own `pcapkit/` tree predates several since-merged PRs and a fresh run would not be measuring the same thing the original round measured. Unmoved, and not silently dropped this time: `93 of the 95 sites` corrected to `94 of the 95` and `EXPECTED_FAILURES` 44 to 43 in the several other places that already carried the fix. On which PRs get a bullet: there is no clean "user-facing only" rule -- #766 and #791 are pure CI/test/lint-comment entries that are in, while #773 and #763 are the same kind of thing and are out. The real pattern across this file's 46 commits is closer to "each round's author judged it worth a reader's time," which is inconsistent by construction. This round leaves that inconsistency as found rather than trying to retrofit a rule, but did add #779 (via #782) on reconsideration -- its own PR body names it as sharing #766's hazard, and pre-existing precedent already treats that hazard's instances as bullet-worthy. `changelog_md.py` regenerated `CHANGELOG.md`; `--check` exit 0. `test_changelog_md.py` 47 passed.
Please follow the guide below
make pylint,make mypy,make isort)make testpasses, and a test case covers the changedocs/source/changelog/and regeneratedCHANGELOG.md— N/A — changelog centralised in docs(changelog): shared 1.5.0 changelog — long-lived, merges last (#610, #616, #617, #618, #620) #657What is the purpose of your pull request?
fix— corrects a defectfeat— adds a featureperf— changes performance, not behaviourrefactor— changes neither behaviour nor performancetest— tests onlydocs— documentation onlyci— workflows or build toolingchore— anything elseDescription of your pull request and other information
Closes #799.
httpv2.py's guard tested only the declared 24-bit length off the wire, neverthe buffer.
HTTP.unpacknow rejects a non-empty buffer under nine octetsbefore the schema layer runs (an implicit-length, genuinely exhausted stream
still raises
StreamEOFError, unchanged), andread's guard now alsorequires
schema.length <= length— a nine-octet buffer declaring 16777215no longer parses and reports that length as fact.
A buffer that clears nine octets can still carry a frame whose own
fixed-width fields exceed what's left (
GOAWAYat 9-16 octets,PUSH_PROMISEat 9-12, padded
DATA/HEADERS/PUSH_PROMISE), crashing the schema layerwith a bare
struct.error. An earlier revision of this PR narrowed_guess_version'sstruct.errorsuppression on the strength of a sweep thatnever hit this class; a cross-review measured it (1136 escapes) and it's
reverted — the root cause is generic
Schema.unpack/FieldBase.lengthmachinery shared by ten schema modules, filed as #805 rather than widened
here.
Two pre-#799 tests relied on this exact leniency to label garbage/the HTTP/2
preface as
version='2'— updated to reflect that #799 correctly refusesboth now.