Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce rack.protocol header for handling version-agnostic protocol upgrades. #1954

Merged
merged 1 commit into from
Jun 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. For info on

- `rack.input` is now optional. ([#1997](https://github.com/rack/rack/pull/1997), [@ioquatix])
- `PATH_INFO` is now validated according to the HTTP/1.1 specification. ([#2117](https://github.com/rack/rack/pull/2117), [@ioquatix])
- `rack.protocol` is an optional environment key and response header for handling connection upgrades.

### Changed

Expand Down
18 changes: 16 additions & 2 deletions SPEC.rdoc
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ Rack-specific variables:
<tt>rack.hijack</tt>:: See below, if present, an object responding
to +call+ that is used to perform a full
hijack.
<tt>rack.protocol</tt>:: An optional +Array+ of +String+, containing
the protocols advertised by the client in
the +upgrade+ header (HTTP/1) or the
+:protocol+ pseudo-header (HTTP/2).
Additional environment specifications have approved to
standardized middleware APIs. None of these are required to
be implemented by the server.
Expand Down Expand Up @@ -263,16 +267,26 @@ Header values must be either a String instance,
or an Array of String instances,
such that each String instance must not contain characters below 037.

=== The content-type
==== The +content-type+ Header

There must not be a <tt>content-type</tt> header key when the +Status+ is 1xx,
204, or 304.

=== The content-length
==== The +content-length+ Header

There must not be a <tt>content-length</tt> header key when the
+Status+ is 1xx, 204, or 304.

==== The +rack.protocol+ Header

If the +rack.protocol+ header is present, it must be a +String+, and
must be one of the values from the +rack.protocol+ array from the
environment.

Setting this value informs the server that it should perform a
connection upgrade. In HTTP/1, this is done using the +upgrade+
header. In HTTP/2, this is done by accepting the request.

=== The Body

The Body is typically an +Array+ of +String+ instances, an enumerable
Expand Down
46 changes: 40 additions & 6 deletions lib/rack/lint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,9 @@ def response
end

## and the *body*.
check_content_type(@status, @headers)
check_content_length(@status, @headers)
check_content_type_header(@status, @headers)
check_content_length_header(@status, @headers)
check_rack_protocol_header(@status, @headers)
@head_request = @env[REQUEST_METHOD] == HEAD

@lint = (@env['rack.lint'] ||= []) << self
Expand Down Expand Up @@ -182,6 +183,16 @@ def check_environment(env)
## to +call+ that is used to perform a full
## hijack.

## <tt>rack.protocol</tt>:: An optional +Array+ of +String+, containing
## the protocols advertised by the client in
## the +upgrade+ header (HTTP/1) or the
## +:protocol+ pseudo-header (HTTP/2).
if protocols = @env['rack.protocol']
unless protocols.is_a?(Array) && protocols.all?{|protocol| protocol.is_a?(String)}
raise LintError, "rack.protocol must be an Array of Strings"
end
end

## Additional environment specifications have approved to
## standardized middleware APIs. None of these are required to
## be implemented by the server.
Expand Down Expand Up @@ -723,9 +734,9 @@ def check_header_value(key, value)
end

##
## === The content-type
## ==== The +content-type+ Header
##
def check_content_type(status, headers)
def check_content_type_header(status, headers)
headers.each { |key, value|
## There must not be a <tt>content-type</tt> header key when the +Status+ is 1xx,
## 204, or 304.
Expand All @@ -739,9 +750,9 @@ def check_content_type(status, headers)
end

##
## === The content-length
## ==== The +content-length+ Header
##
def check_content_length(status, headers)
def check_content_length_header(status, headers)
headers.each { |key, value|
if key == 'content-length'
## There must not be a <tt>content-length</tt> header key when the
Expand All @@ -766,6 +777,29 @@ def verify_content_length(size)
end
end

##
## ==== The +rack.protocol+ Header
##
def check_rack_protocol_header(status, headers)
## If the +rack.protocol+ header is present, it must be a +String+, and
## must be one of the values from the +rack.protocol+ array from the
## environment.
protocol = headers['rack.protocol']

if protocol
request_protocols = @env['rack.protocol']

if request_protocols.nil?
raise LintError, "rack.protocol header is #{protocol.inspect}, but rack.protocol was not set in request!"
elsif !request_protocols.include?(protocol)
raise LintError, "rack.protocol header is #{protocol.inspect}, but should be one of #{request_protocols.inspect} from the request!"
end
end
end
##
## Setting this value informs the server that it should perform a
## connection upgrade. In HTTP/1, this is done using the +upgrade+
## header. In HTTP/2, this is done by accepting the request.
##
## === The Body
##
Expand Down
34 changes: 34 additions & 0 deletions test/spec_lint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -948,4 +948,38 @@ def call(env, status, headers, error)
[200, {}, ["foo"]]
}).call(env({ "rack.response_finished" => [-> (env) {}, lambda { |env| }, callable_object], "content-length" => "3" })).first.must_equal 200
end

it "notices when the response protocol is specified in the response but not in the request" do
app = Rack::Lint.new(lambda{|env|
[101, {'rack.protocol' => 'websocket'}, ["foo"]]
})

lambda do
app.call(env())
end
.must_raise(Rack::Lint::LintError)
.message.must_match(/rack.protocol header is "websocket", but rack.protocol was not set in request/)
end

it "notices when the response protocol is specified in the response but not in the request" do
app = Rack::Lint.new(lambda{|env|
[101, {'rack.protocol' => 'websocket'}, ["foo"]]
})

lambda do
app.call(env('rack.protocol' => ['smtp']))
end
.must_raise(Rack::Lint::LintError)
.message.must_match(/rack.protocol header is "websocket", but should be one of \["smtp"\] from the request!/)
end

it "pass valid rack.protocol" do
app = Rack::Lint.new(lambda{|env|
[101, {'rack.protocol' => 'websocket'}, ["foo"]]
})

response = app.call(env({'rack.protocol' => ['websocket']}))

response.first.must_equal 101
end
end