Skip to content

[HIGH] Bound peer-created stream state - #201

Closed
OskarEichler wants to merge 5 commits into
igrigorik:mainfrom
OskarEichler:codex/security-bound-peer-stream-state
Closed

OskarEichler wants to merge 5 commits into
igrigorik:mainfrom
OskarEichler:codex/security-bound-peer-stream-state

Conversation

@OskarEichler

@OskarEichler OskarEichler commented Sep 4, 2026 •

Copy link
Copy Markdown

Summary

  • Keep untracked PRIORITY state out of the connection's live stream registry and bound compatibility events to the advertised local stream limit.
  • Validate PUSH_PROMISE direction, enablement, and promised stream identifiers before retaining state.
  • Refuse promises beyond the configured concurrent-stream bound.

Security impact

A peer could send a PRIORITY frame for an idle stream, causing a Stream object to be retained without counting against the concurrent-stream limit. Repeating that with distinct IDs grew the connection registry linearly: 1,000 small frames retained 1,000 streams while active_stream_count stayed at zero.

The retained idle object also changed later HEADERS handling. A request missing :authority was rejected on a fresh stream but accepted when a PRIORITY frame pre-created that stream, because new-stream pseudo-header validation was skipped.

Servers also accepted client-sent PUSH_PROMISE frames even though clients cannot push. A focused reproduction retained 1,001 streams from one active request after 1,000 promises. Clients did not enforce SETTINGS_ENABLE_PUSH=0 or bound reserved promises.

After this change, the priority reproduction retains no live stream objects, limits compatibility IDs to 100, and rejects the missing-authority request. Servers reject client push, disabled clients reject push, and the 101st concurrently retained promise is refused when the configured limit is 100.

Verification

  • rbenv exec bundle exec rake: 448 examples, 0 failures; 41 RuboCop files, no offenses
  • Focused PRIORITY validation and 1,000-frame retention reproduction
  • Focused server/client PUSH_PROMISE direction, disablement, identifier, and 101-promise bound reproduction
  • rbenv exec bundle exec rake build
  • Targeted Ruby syntax and git diff --check

Added focused regression coverage for repeated idle PRIORITY updates and the priority retained when that stream later opens.

Limitations

The repository's HPACK fixture task cannot currently run because its fixtures are absent (tracked separately in issue #200). Live sockets, alternate Ruby engines, and a long-running production server were not exercised.

Breaking changes

Illegal client PUSH_PROMISE frames and pushes received while disabled now close the connection with PROTOCOL_ERROR. Excess valid promises are refused. Untracked PRIORITY frames no longer enter the live stream registry; the existing stream callback remains available for the first bounded set of distinct idle priority IDs.

Comment thread lib/http/2/connection.rb Outdated
Comment thread lib/http/2/connection.rb Outdated
Comment thread lib/http/2/connection.rb Outdated
Comment thread lib/http/2/connection.rb Outdated
Comment thread lib/http/2/connection.rb Outdated
@OskarEichler

Copy link
Copy Markdown
Author

Addressed in 1a011bf: promised streams now use a counter updated on activation/close instead of an O(n) scan, with a lifecycle regression; simplified priority defaults and the remote-client/push-disabled checks. One deliberate difference from the suggestion: cached idle priority is deleted even when HEADERS carries its own PRIORITY, otherwise the old entry stays retained after activation. HEADERS priority still takes precedence. Updated the RBS counter declaration. Verification: 450 examples, zero failures; 41 RuboCop files clean; git diff --check passes.

@OskarEichler

Copy link
Copy Markdown
Author

CI follow-up: all nine Ruby runtime jobs pass on the pushed revision. The coverage job fails when its reporting action tries to create a GitHub check run and receives 403 Resource not accessible by integration; the log does not report a coverage-threshold failure. This appears to require maintainer-side workflow permissions rather than a runtime code change.

Comment thread lib/http/2/connection.rb Outdated
)
}
@idle_stream_priorities[stream_id] = priority
stream = Stream.new(connection: self, id: stream_id, **priority)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feels strange. why create a Stream object directly and bypass activate_stream? It feels stream creation should either be bypassed, or the frame should be kept around for eventual handling once the stream is open and activated for good.

@HoneyryderChuck

Copy link
Copy Markdown
Collaborator

@OskarEichler fair enough, it's a known issue, I've been waiting for @igrigorik to either make me repository admin, or he himself update the repo permissions so CI can perform that operation.

@OskarEichler

Copy link
Copy Markdown
Author

Addressed the Stream.new concern: idle PRIORITY frames now only update the bounded metadata cache. Stream creation and the :stream event occur on actual activation through activate_stream. Updated the existing idle-priority expectation; the existing repeated-priority/opening regression still passes. Verified all 450 available non-fixture examples, targeted RuboCop, syntax, and git diff --check.

@HoneyryderChuck

HoneyryderChuck commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

@OskarEichler your changes broke one of the conformance tests.

@HoneyryderChuck

Copy link
Copy Markdown
Collaborator

cherry-pick to main.

@HoneyryderChuck

Copy link
Copy Markdown
Collaborator

@OskarEichler After looking at this again, I'm not so convinced that the following claims are true, nor were they fixed (and the fact that the added test in server_spec.rb passes without these changes doesn't help):

A peer could send a PRIORITY frame for an idle stream, causing a Stream object to be retained without counting against the concurrent-stream limit. Repeating that with distinct IDs grew the connection registry linearly: 1,000 small frames retained 1,000 streams while active_stream_count stayed at zero.

Yes, a stream object is retained; however, the PRIORITY parameters are also retained. A stream is created, period. Sure, the parameters are lighter in comparison, but it's not like a memory leak is happening. I'd like a better explanation for why your LLM rated this as a HIGH risk.

I also don't think that this patch accurately counts against the concurrent-stream limit. FWIW this is used when: activating a stream (and it's matched against active stream count, which only accounts for at-least open streams, i.e. discards the idle streams that PRIORITY frames create); when receiving a PUSH_PROMISE, and it's matched against @promised_stream_count, not idle priority streams nor active stream counts; when receiving PRIORITY streams, and it's matched against idle streams only to know whether it should discard the incoming stream. I'd expect bookkeeping to take all 3 states into account.

The retained idle object also changed later HEADERS handling. A request missing :authority was rejected on a fresh stream but accepted when a PRIORITY frame pre-created that stream, because new-stream pseudo-header validation was skipped.

I don't think this is accurate. The headers are validated when they arrive (i.e. when the HEADERS frame is processed). pseudo-header validation happens when stream objects transition to "open".

LMK if you have thoughts on this. I'd like to cut a release soon, and if we don't get to a consensus here, I'm tempted to revert the mentioned changes until we do.

@HoneyryderChuck

Copy link
Copy Markdown
Collaborator

this has been partially reverted in 8633de6

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants