require_relative "../blocked_app" require_relative "../errors" module Immunio # Rack middleware tracking HTTP requests and responses and triggers the proper hooks. class HTTPTracker def initialize(app) @app = app end def call(env) request = Request.new(env) request.time "plugin", "#{Module.nesting[0]}::#{__method__}" do Immunio.logger.debug { "Creating new request in HTTPTracker" } Immunio.new_request(request) Immunio.run_hook! "http_tracker", "http_request_start", meta_from_env(env) env['rack.input'] = InputWrapper.new(env['rack.input']) status, headers, body = Request.pause "plugin", "#{Module.nesting[0]}::#{__method__}" do @app.call(env) end # Run hook for the session only if it was loaded session = env["rack.session"] if session_was_loaded?(session) Immunio.run_hook! "http_tracker", "framework_session", session_id: extract_session_id(session) end # Immunio expects response headers as a list of tuples. list_headers = headers_to_list(headers) result = Immunio.run_hook! "http_tracker", "http_response_start", status: status, headers: list_headers # If new headers are specified, convert them back to the Ruby hash format. if result["headers"] != nil # The new_headers completely replace the originals. headers = list_to_headers(result["headers"].to_a) end # Send the response [status, headers, body] end rescue RequestBlocked Request.time "plugin", "#{Module.nesting[0]}::#{__method__}[RequestBlocked]" do status, headers, body = Immunio.blocked_app.call(env) # Do not allow blocking the request here Immunio.run_hook "http_tracker", "http_response_start", status: status, headers: headers [status, headers, body] end rescue OverrideResponse => override status, headers, body = Immunio.override_response.call(env, override) Immunio.run_hook "http_tracker", "http_response_start", status: status, headers: headers [status, headers, body] end private def headers_to_list(headers) list_headers = [] headers.each do |name, value| # Ruby treats the `Set-Cookie` header specially. If there are multiple # Set-Cookie headers to send, they are joined into a single field, # separated by line-feeds. if name == "Set-Cookie" value.split("\n").each do |cookie_val| list_headers.push(["Set-Cookie", cookie_val]) end else list_headers.push([name, value]) end end list_headers end def list_to_headers(list) new_headers = {} list.each do |name, value| # If this header is already in `new_headers`, append to the # existing value with a linefeed separator. if new_headers.has_key?(name) new_headers[name] += ("\n" + value) else new_headers[name] = value end end new_headers end def meta_from_env(env) request = Rack::Request.new(env) # Extract request headers from `env`. headers = env.select { |k| k.starts_with? "HTTP_" }. each_with_object({}) { |(k, v), h| h.store k[5..-1].downcase.tr('_', '-'), v } # Determine scheme (http://www.rubydoc.info/github/rack/rack/master/file/SPEC) # There are also some HTTP headers from proxies that may affect the # scheme seen by the end user. We process those in the hooks. scheme = env["rack.url_scheme"] if env["HTTPS"] == "on" # Some servers will set the HTTPS var explicity. If set, use it scheme = "https" end # Determine the route name in controller#action format: route_name = nil if defined?(Rails.application) && Rails.application.present? begin path = request.env['PATH_INFO'] method = request.env['REQUEST_METHOD'].downcase.to_sym url = Rails.application.routes.recognize_path(path, method: method) route_name = "#{url[:controller]}##{url[:action]}" rescue StandardError route_name = nil end end { protocol: env["SERVER_PROTOCOL"], scheme: scheme, uri: env["REQUEST_URI"], server_name: env["SERVER_NAME"], # SERVER_ADDR is non-standard, but rack uses it as a fallback, so # include it here as well so we can access it from Lua. server_addr: env["SERVER_ADDR"], server_port: env["SERVER_PORT"], route_name: route_name, querystring: request.query_string, method: request.request_method, path: request.path_info, socket_ip: request.ip, socket_port: request.port, headers: headers } end def session_was_loaded?(session) session && (session.respond_to?(:loaded?) ? session.loaded? : true) end def extract_session_id(session) session_id = if session.respond_to?(:id) session.id else session[:id] || session[:session_id] end Digest::SHA1.hexdigest(session_id) if session_id end end class InputWrapper < SimpleDelegator def initialize(input) super input @input = input end def gets(*) v = super Request.time "plugin", "#{Module.nesting[0]}::#{__method__}" do report_chunk v end v end def read(*) v = super Request.time "plugin", "#{Module.nesting[0]}::#{__method__}" do report_chunk v end v end def each Request.time "plugin", "#{Module.nesting[0]}::#{__method__}" do @input.each do |chunk| report_chunk chunk Request.pause "plugin", "#{Module.nesting[0]}::#{__method__}" do yield chunk end end end end private def report_chunk(chunk) Immunio.run_hook! "http_tracker", "http_request_body_chunk", chunk: chunk end end end