lib/rack/lint.rb in rack-3.0.10 vs lib/rack/lint.rb in rack-3.1.0

- old
+ new

@@ -1,17 +1,23 @@ # frozen_string_literal: true require 'forwardable' +require 'uri' require_relative 'constants' require_relative 'utils' module Rack # Rack::Lint validates your application and the requests and # responses according to the Rack spec. class Lint + REQUEST_PATH_ORIGIN_FORM = /\A\/[^#]*\z/ + REQUEST_PATH_ABSOLUTE_FORM = /\A#{URI::DEFAULT_PARSER.make_regexp}\z/ + REQUEST_PATH_AUTHORITY_FORM = /\A(.*?)(:\d*)\z/ + REQUEST_PATH_ASTERISK_FORM = '*' + def initialize(app) @app = app end # :stopdoc: @@ -54,13 +60,10 @@ def response ## It takes exactly one argument, the *environment* raise LintError, "No env given" unless @env check_environment(@env) - @env[RACK_INPUT] = InputWrapper.new(@env[RACK_INPUT]) - @env[RACK_ERRORS] = ErrorWrapper.new(@env[RACK_ERRORS]) - ## and returns a non-frozen Array of exactly three values: @response = @app.call(@env) raise LintError, "response is not an Array, but #{@response.class}" unless @response.kind_of? Array raise LintError, "response is frozen" if @response.frozen? raise LintError, "response array has #{@response.size} elements instead of 3" unless @response.size == 3 @@ -76,12 +79,13 @@ if hijack_proc @headers[RACK_HIJACK] = hijack_proc 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 if (@env['rack.lint.body_iteration'] ||= 0) > 0 @@ -177,10 +181,20 @@ ## <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). + 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. ## <tt>rack.session</tt>:: A hash-like interface for storing @@ -263,15 +277,13 @@ ## environment, too. The keys must contain at least one dot, ## and should be prefixed uniquely. The prefix <tt>rack.</tt> ## is reserved for use with the Rack core distribution and other ## accepted specifications and must not be used otherwise. ## - - %w[REQUEST_METHOD SERVER_NAME QUERY_STRING SERVER_PROTOCOL - rack.input rack.errors].each { |header| + %w[REQUEST_METHOD SERVER_NAME QUERY_STRING SERVER_PROTOCOL rack.errors].each do |header| raise LintError, "env missing required key #{header}" unless env.include? header - } + end ## The <tt>SERVER_PORT</tt> must be an Integer if set. server_port = env["SERVER_PORT"] unless server_port.nil? || (Integer(server_port) rescue false) raise LintError, "env[SERVER_PORT] is not an Integer" @@ -291,15 +303,10 @@ server_protocol = env['SERVER_PROTOCOL'] unless %r{HTTP/\d(\.\d)?}.match?(server_protocol) raise LintError, "env[SERVER_PROTOCOL] does not match HTTP/\\d(\\.\\d)?" end - ## If the <tt>HTTP_VERSION</tt> is present, it must equal the <tt>SERVER_PROTOCOL</tt>. - if env['HTTP_VERSION'] && env['HTTP_VERSION'] != server_protocol - raise LintError, "env[HTTP_VERSION] does not equal env[SERVER_PROTOCOL]" - end - ## The environment must not contain the keys ## <tt>HTTP_CONTENT_TYPE</tt> or <tt>HTTP_CONTENT_LENGTH</tt> ## (use the versions without <tt>HTTP_</tt>). %w[HTTP_CONTENT_TYPE HTTP_CONTENT_LENGTH].each { |header| if env.include? header @@ -326,30 +333,61 @@ ## * <tt>rack.url_scheme</tt> must either be +http+ or +https+. unless %w[http https].include?(env[RACK_URL_SCHEME]) raise LintError, "rack.url_scheme unknown: #{env[RACK_URL_SCHEME].inspect}" end - ## * There must be a valid input stream in <tt>rack.input</tt>. - check_input env[RACK_INPUT] + ## * There may be a valid input stream in <tt>rack.input</tt>. + if rack_input = env[RACK_INPUT] + check_input_stream(rack_input) + @env[RACK_INPUT] = InputWrapper.new(rack_input) + end + ## * There must be a valid error stream in <tt>rack.errors</tt>. - check_error env[RACK_ERRORS] + rack_errors = env[RACK_ERRORS] + check_error_stream(rack_errors) + @env[RACK_ERRORS] = ErrorWrapper.new(rack_errors) + ## * There may be a valid hijack callback in <tt>rack.hijack</tt> check_hijack env + ## * There may be a valid early hints callback in <tt>rack.early_hints</tt> + check_early_hints env ## * The <tt>REQUEST_METHOD</tt> must be a valid token. unless env[REQUEST_METHOD] =~ /\A[0-9A-Za-z!\#$%&'*+.^_`|~-]+\z/ raise LintError, "REQUEST_METHOD unknown: #{env[REQUEST_METHOD].dump}" end ## * The <tt>SCRIPT_NAME</tt>, if non-empty, must start with <tt>/</tt> if env.include?(SCRIPT_NAME) && env[SCRIPT_NAME] != "" && env[SCRIPT_NAME] !~ /\A\// raise LintError, "SCRIPT_NAME must start with /" end - ## * The <tt>PATH_INFO</tt>, if non-empty, must start with <tt>/</tt> - if env.include?(PATH_INFO) && env[PATH_INFO] != "" && env[PATH_INFO] !~ /\A\// - raise LintError, "PATH_INFO must start with /" + + ## * The <tt>PATH_INFO</tt>, if provided, must be a valid request target. + if env.include?(PATH_INFO) + case env[PATH_INFO] + when REQUEST_PATH_ASTERISK_FORM + ## * Only <tt>OPTIONS</tt> requests may have <tt>PATH_INFO</tt> set to <tt>*</tt> (asterisk-form). + unless env[REQUEST_METHOD] == OPTIONS + raise LintError, "Only OPTIONS requests may have PATH_INFO set to '*' (asterisk-form)" + end + when REQUEST_PATH_AUTHORITY_FORM + ## * Only <tt>CONNECT</tt> requests may have <tt>PATH_INFO</tt> set to an authority (authority-form). Note that in HTTP/2+, the authority-form is not a valid request target. + unless env[REQUEST_METHOD] == CONNECT + raise LintError, "Only CONNECT requests may have PATH_INFO set to an authority (authority-form)" + end + when REQUEST_PATH_ABSOLUTE_FORM + ## * <tt>CONNECT</tt> and <tt>OPTIONS</tt> requests must not have <tt>PATH_INFO</tt> set to a URI (absolute-form). + if env[REQUEST_METHOD] == CONNECT || env[REQUEST_METHOD] == OPTIONS + raise LintError, "CONNECT and OPTIONS requests must not have PATH_INFO set to a URI (absolute-form)" + end + when REQUEST_PATH_ORIGIN_FORM + ## * Otherwise, <tt>PATH_INFO</tt> must start with a <tt>/</tt> and must not include a fragment part starting with '#' (origin-form). + else + raise LintError, "PATH_INFO must start with a '/' and must not include a fragment part starting with '#' (origin-form)" + end end + ## * The <tt>CONTENT_LENGTH</tt>, if given, must consist of digits only. if env.include?("CONTENT_LENGTH") && env["CONTENT_LENGTH"] !~ /\A\d+\z/ raise LintError, "Invalid CONTENT_LENGTH: #{env["CONTENT_LENGTH"]}" end @@ -382,13 +420,13 @@ ## ## === The Input Stream ## ## The input stream is an IO-like object which contains the raw HTTP ## POST data. - def check_input(input) + def check_input_stream(input) ## When applicable, its external encoding must be "ASCII-8BIT" and it - ## must be opened in binary mode, for Ruby 1.9 compatibility. + ## must be opened in binary mode. if input.respond_to?(:external_encoding) && input.external_encoding != Encoding::ASCII_8BIT raise LintError, "rack.input #{input} does not have ASCII-8BIT as its external encoding" end if input.respond_to?(:binmode?) && !input.binmode? raise LintError, "rack.input #{input} is not opened in binary mode" @@ -416,11 +454,11 @@ raise LintError, "rack.input#gets didn't return a String" end v end - ## * +read+ behaves like IO#read. + ## * +read+ behaves like <tt>IO#read</tt>. ## Its signature is <tt>read([length, [buffer]])</tt>. ## ## If given, +length+ must be a non-negative Integer (>= 0) or +nil+, ## and +buffer+ must be a String and may not be nil. ## @@ -476,21 +514,21 @@ end yield line } end - ## * +close+ can be called on the input stream to indicate that the - ## any remaining input is not needed. + ## * +close+ can be called on the input stream to indicate that + ## any remaining input is not needed. def close(*args) @input.close(*args) end end ## ## === The Error Stream ## - def check_error(error) + def check_error_stream(error) ## The error stream must respond to +puts+, +write+ and +flush+. [:puts, :write, :flush].each { |method| unless error.respond_to? method raise LintError, "rack.error #{error} does not respond to ##{method}" end @@ -607,10 +645,34 @@ end nil end + ## + ## === Early Hints + ## + ## The application or any middleware may call the <tt>rack.early_hints</tt> + ## with an object which would be valid as the headers of a Rack response. + def check_early_hints(env) + if env[RACK_EARLY_HINTS] + ## + ## If <tt>rack.early_hints</tt> is present, it must respond to #call. + unless env[RACK_EARLY_HINTS].respond_to?(:call) + raise LintError, "rack.early_hints must respond to call" + end + + original_callback = env[RACK_EARLY_HINTS] + env[RACK_EARLY_HINTS] = lambda do |headers| + ## If <tt>rack.early_hints</tt> is called, it must be called with + ## valid Rack response headers. + check_headers(headers) + original_callback.call(headers) + end + end + end + + ## ## == The Response ## ## === The Status ## def check_status(status) @@ -670,13 +732,13 @@ raise LintError, "invalid header value #{key}: #{value.inspect}" end 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. if key == "content-type" if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i @@ -686,13 +748,13 @@ end } 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 ## +Status+ is 1xx, 204, or 304. if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i @@ -713,11 +775,34 @@ raise LintError, "content-length header was #{@content_length}, but should be #{size}" end 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 ## ## The Body is typically an +Array+ of +String+ instances, an enumerable ## that yields +String+ instances, a +Proc+ instance, or a File-like ## object. @@ -780,23 +865,20 @@ raise LintError, "Enumerable Body must respond to each" unless @body.respond_to?(:each) ## It must only be called once. raise LintError, "Response body must only be invoked once (#{@invoked})" unless @invoked.nil? - ## It must not be called after being closed. + ## It must not be called after being closed, raise LintError, "Response body is already closed" if @closed @invoked = :each @body.each do |chunk| ## and must only yield String values. unless chunk.kind_of? String raise LintError, "Body yielded non-string value #{chunk.inspect}" end - ## - ## The Body itself should not be an instance of String, as this will - ## break in Ruby 1.9. ## ## Middleware must not call +each+ directly on the Body. ## Instead, middleware can return a new Body that calls +each+ on the ## original Body, yielding at least once per iteration. if @lint[0] == self