lib/selenium/webdriver/devtools/network_interceptor.rb
# frozen_string_literal: true # Licensed to the Software Freedom Conservancy (SFC) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The SFC licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. module Selenium module WebDriver class DevTools # # Wraps the network request/response interception, providing # thread-safety guarantees and handling special cases such as browser # canceling requests midst interception. # # You should not be using this class directly, use Driver#intercept instead. # @api private # class NetworkInterceptor # CDP fails to get body on certain responses (301) and raises: # "Can only get response body on requests captured after headers received." CANNOT_GET_BODY_ON_REDIRECT_ERROR_CODE = '-32000' # CDP fails to operate with intercepted requests. # Typical reason is browser cancelling intercepted requests/responses. INVALID_INTERCEPTION_ID_ERROR_CODE = '-32602' def initialize(devtools) @devtools = devtools @lock = Mutex.new end def intercept(&block) devtools.network.on(:loading_failed) { |params| track_cancelled_request(params) } devtools.fetch.on(:request_paused) { |params| request_paused(params, &block) } devtools.network.set_cache_disabled(cache_disabled: true) devtools.network.enable devtools.fetch.enable(patterns: [{requestStage: 'Request'}, {requestStage: 'Response'}]) end private attr_accessor :devtools, :lock # We should be thread-safe to use the hash without synchronization # because its keys are interception job identifiers and they should be # unique within a devtools session. def pending_response_requests @pending_response_requests ||= {} end # Ensure usage of cancelled_requests is thread-safe via synchronization! def cancelled_requests @cancelled_requests ||= [] end def track_cancelled_request(data) return unless data['canceled'] lock.synchronize { cancelled_requests << data['requestId'] } end def request_paused(data, &block) id = data['requestId'] network_id = data['networkId'] with_cancellable_request(network_id) do if response?(data) block = pending_response_requests.delete(id) intercept_response(id, data, &block) else intercept_request(id, data, &block) end end end # The presence of any of these fields indicate we're at the response stage. # @see https://chromedevtools.github.io/devtools-protocol/tot/Fetch/#event-requestPaused def response?(params) params.key?('responseStatusCode') || params.key?('responseErrorReason') end def intercept_request(id, params, &block) original = DevTools::Request.from(id, params) mutable = DevTools::Request.from(id, params) block.call(mutable) do |&continue| pending_response_requests[id] = continue if original == mutable continue_request(original.id) else mutate_request(mutable) end end end def intercept_response(id, params) return continue_response(id) unless block_given? body = fetch_response_body(id) original = DevTools::Response.from(id, body, params) mutable = DevTools::Response.from(id, body, params) yield mutable if original == mutable continue_response(id) else mutate_response(mutable) end end def continue_request(id) devtools.fetch.continue_request(request_id: id) end alias continue_response continue_request def mutate_request(request) devtools.fetch.continue_request( request_id: request.id, url: request.url, method: request.method, post_data: (Base64.strict_encode64(request.post_data) if request.post_data), headers: request.headers.map do |k, v| {name: k, value: v} end ) end def mutate_response(response) devtools.fetch.fulfill_request( request_id: response.id, body: (Base64.strict_encode64(response.body) if response.body), response_code: response.code, response_headers: response.headers.map do |k, v| {name: k, value: v} end ) end def fetch_response_body(id) devtools.fetch.get_response_body(request_id: id).dig('result', 'body') rescue Error::WebDriverError => e raise unless e.message.start_with?(CANNOT_GET_BODY_ON_REDIRECT_ERROR_CODE) end def with_cancellable_request(network_id) yield rescue Error::WebDriverError => e raise if e.message.start_with?(INVALID_INTERCEPTION_ID_ERROR_CODE) && !cancelled?(network_id) end def cancelled?(network_id) lock.synchronize { !!cancelled_requests.delete(network_id) } end end # NetworkInterceptor end # DevTools end # WebDriver end # Selenium