#
# Copyright (c) 2006-2025 Wade Alcorn - wade@bindshell.net
# Browser Exploitation Framework (BeEF) - https://beefproject.com
# See the file 'doc/COPYING' for copying permission
#
require 'openssl'

module BeEF
  module Extension
    module Proxy
      class Proxy
        HB = BeEF::Core::Models::HookedBrowser
        H = BeEF::Core::Models::Http
        @response = nil

        # Multi-threaded Tunneling Proxy: listens on host:port configured in extensions/proxy/config.yaml
        # and forwards requests to the hooked browser using the Requester component.
        def initialize
          @conf = BeEF::Core::Configuration.instance
          @proxy_server = TCPServer.new(@conf.get('beef.extension.proxy.address'), @conf.get('beef.extension.proxy.port'))

          # setup proxy for SSL/TLS
          ssl_context = OpenSSL::SSL::SSLContext.new
          # ssl_context.ssl_version = :TLSv1_2

          # load certificate
          begin
            cert_file = @conf.get('beef.extension.proxy.cert')
            cert = File.read(cert_file)
            ssl_context.cert = OpenSSL::X509::Certificate.new(cert)
          rescue StandardError
            print_error "[Proxy] Could not load SSL certificate '#{cert_file}'"
          end

          # load key
          begin
            key_file = @conf.get('beef.extension.proxy.key')
            key = File.read(key_file)
            ssl_context.key = OpenSSL::PKey::RSA.new(key)
          rescue StandardError
            print_error "[Proxy] Could not load SSL key '#{key_file}'"
          end

          ssl_server = OpenSSL::SSL::SSLServer.new(@proxy_server, ssl_context)
          ssl_server.start_immediately = false

          loop do
            ssl_socket = ssl_server.accept
            Thread.new ssl_socket, &method(:handle_request)
          end
        end

        def handle_request(socket)
          request_line = socket.readline

          # HTTP method # defaults to GET
          method = request_line[/^\w+/]

          # Handle SSL requests
          url_prefix = ''
          if method == 'CONNECT'
            # request_line is something like:
            # CONNECT example.com:443 HTTP/1.1
            host_port = request_line.split[1]
            proto = 'https'
            url_prefix = "#{proto}://#{host_port}"
            loop do
              line = socket.readline
              break if line.strip.empty?
            end
            socket.puts("HTTP/1.0 200 Connection established\r\n\r\n")
            socket.accept
            print_debug("[PROXY] Handled CONNECT to #{host_port}")
            request_line = socket.readline
          end

          method, _path, version = request_line.split

          # HTTP scheme/protocol # defaults to http
          proto = 'http' unless proto.eql?('https')

          # HTTP version # defaults to 1.0
          version = 'HTTP/1.0' if version !~ %r{\AHTTP/\d\.\d\z}

          # HTTP request path
          path = request_line[/^\w+\s+(\S+)/, 1]

          # url # proto://host:port + path
          url = url_prefix + path

          # We're overwriting the URI::Parser UNRESERVED regex to prevent BAD URI errors
          # when sending attack vectors (see tolerant_parser)
          # anti: somehow the config below was removed, have a look into this
          tolerant_parser = URI::Parser.new(UNRESERVED: BeEF::Core::Configuration.instance.get('beef.extension.requester.uri_unreserved_chars'))
          uri = tolerant_parser.parse(url.to_s)

          uri_path_and_qs = uri.query.nil? ? uri.path : "#{uri.path}?#{uri.query}"

          # extensions/requester/api/hook.rb parses raw_request to find port and path
          raw_request = "#{[method, uri_path_and_qs, version].join(' ')}\r\n"
          content_length = 0

          loop do
            line = socket.readline

            content_length = Regexp.last_match(1).to_i if line =~ /^Content-Length:\s+(\d+)\s*$/

            if line.strip.empty?
              # read data still in the socket, exactly <content_length> bytes
              raw_request += "\r\n#{socket.read(content_length)}" if content_length >= 0
              break
            else
              raw_request += line
            end
          end

          # Saves the new HTTP request to the db. It will be processed by the PreHookCallback of the requester component.
          # IDs are created and incremented automatically by DataMapper.
          http = H.new(
            request: raw_request,
            method: method,
            proto: proto,
            domain: uri.host,
            port: uri.port,
            path: uri_path_and_qs,
            request_date: Time.now,
            hooked_browser_id: get_tunneling_proxy,
            allow_cross_origin: 'true'
          )
          http.save
          print_debug(
            "[PROXY] --> Forwarding request ##{http.id}: " \
            "domain[#{http.domain}:#{http.port}], " \
            "method[#{http.method}], " \
            "path[#{http.path}], " \
            "cross origin[#{http.allow_cross_origin}]"
          )

          # Wait for the HTTP response to be stored in the db.
          # TODO: re-implement this with EventMachine or with the Observer pattern.
          sleep 0.5 while H.find(http.id).has_ran != 'complete'
          @response = H.find(http.id)
          print_debug "[PROXY] <-- Response for request ##{@response.id} to [#{@response.path}] on domain [#{@response.domain}:#{@response.port}] correctly processed"

          response_body = @response['response_data']
          response_status = @response['response_status_code']
          headers = @response['response_headers']

          # The following is needed to forward back some of the original HTTP response headers obtained via XHR calls.
          # Original XHR response headers are stored in extension_requester_http table (response_headers column),
          # but we are forwarding back only some of them (Server, X-.. - like X-Powered-By -, Content-Type, ... ).
          # Some of the original response headers need to be removed, like encoding and cache related: for example
          # about encoding, the original response headers says that the content-length is 1000 as the response is gzipped,
          # but the final content-length forwarded back by the proxy is clearly bigger. Date header follows the same way.
          response_headers = ''
          if response_status != -1 && response_status != 0
            ignore_headers = %w[
              Content-Encoding
              Keep-Alive
              Cache-Control
              Vary
              Pragma
              Connection
              Expires
              Accept-Ranges
              Transfer-Encoding
              Date
            ]
            headers.each_line do |line|
              # stripping the Encoding, Cache and other headers
              header_key = line.split(': ')[0]
              header_value = line.split(': ')[1]
              next if header_key.nil?
              next if ignore_headers.any? { |h| h.casecmp(header_key).zero? }

              # ignore headers with no value (@todo: why?)
              next if header_value.nil?

              unless header_key == 'Content-Length'
                response_headers += line
                next
              end

              # update Content-Length with the valid one
              response_headers += "Content-Length: #{response_body.size}\r\n"
            end
          end

          res = "#{version} #{response_status}\r\n#{response_headers}\r\n#{response_body}"
          socket.write(res)
          socket.close
        end

        def get_tunneling_proxy
          proxy_browser = HB.where(is_proxy: true).first
          return proxy_browser.session.to_s unless proxy_browser.nil?

          hooked_browser = HB.first
          unless hooked_browser.nil?
            print_debug "[Proxy] Proxy browser not set. Defaulting to first hooked browser [id: #{hooked_browser.session}]"
            return hooked_browser.session
          end

          print_error '[Proxy] No hooked browsers'
          nil
        end
      end
    end
  end
end
