Skip to content

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

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

[HIGH] Bound peer-created stream state#201
OskarEichler wants to merge 3 commits into
igrigorik:mainfrom
OskarEichler:codex/security-bound-peer-stream-state

Conversation

@OskarEichler

@OskarEichler OskarEichler commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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
Comment on lines +321 to +328
priority = @idle_stream_priorities.delete(stream_id) || {}
if frame[:flags].anybits?(PRIORITY)
priority = {
weight: frame[:weight],
dependency: frame[:dependency],
exclusive: frame[:exclusive]
}
end

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.

Suggested change
priority = @idle_stream_priorities.delete(stream_id) || {}
if frame[:flags].anybits?(PRIORITY)
priority = {
weight: frame[:weight],
dependency: frame[:dependency],
exclusive: frame[:exclusive]
}
end
if frame[:flags].anybits?(PRIORITY)
priority = {
weight: frame[:weight],
dependency: frame[:dependency],
exclusive: frame[:exclusive]
}
else
priority = @idle_stream_priorities.delete(stream_id)
end

Comment thread lib/http/2/connection.rb
Comment on lines +331 to +333
weight: priority.fetch(:weight, DEFAULT_WEIGHT),
dependency: priority.fetch(:dependency, 0),
exclusive: priority.fetch(:exclusive, false)

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.

Suggested change
weight: priority.fetch(:weight, DEFAULT_WEIGHT),
dependency: priority.fetch(:dependency, 0),
exclusive: priority.fetch(:exclusive, false)
weight: priority&[:weight] || DEFAULT_WEIGHT,
dependency: priority&[:dependency] || 0,
exclusive: priority&[:exclusive] || false

Comment thread lib/http/2/connection.rb
_verify_pseudo_headers(frame, REQUEST_MANDATORY_HEADERS)
verify_stream_order(pid)

promised_stream_count = @streams.each_value.count(&:parent)

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.

instead of counting on the fly and in order to avoid a O(n), bookkeep this in an ivar instead.

Comment thread lib/http/2/connection.rb
end

def validate_push_promise(stream_id)
connection_error(:protocol_error, msg: "clients cannot send PUSH_PROMISE") if @local_role == :server

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 clause is easier to read with if @remote_role == :client

Comment thread lib/http/2/connection.rb
def validate_push_promise(stream_id)
connection_error(:protocol_error, msg: "clients cannot send PUSH_PROMISE") if @local_role == :server
if @local_settings[:settings_enable_push].zero?
connection_error(:protocol_error, msg: "received PUSH_PROMISE while push is disabled")

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.

Suggested change
connection_error(:protocol_error, msg: "received PUSH_PROMISE while push is disabled")
connection_error(:protocol_error, msg: "push promises are disabled")

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