a0b614f10a
* Disable incorrect check for hidden services in Socket Hidden services can only be accessed with an HTTP proxy, in which case the host seen by the Socket class will be the proxy, not the target host. Hidden services are already filtered in `Request#initialize`. * Use our Socket class to connect to HTTP proxies Avoid the timeout logic being bypassed * Add support for IP addresses in Request::Socket * Refactor a bit, no need to keep the DNS resolver around
253 lines
6.9 KiB
Ruby
253 lines
6.9 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'ipaddr'
|
|
require 'socket'
|
|
require 'resolv'
|
|
|
|
# Monkey-patch the HTTP.rb timeout class to avoid using a timeout block
|
|
# around the Socket#open method, since we use our own timeout blocks inside
|
|
# that method
|
|
class HTTP::Timeout::PerOperation
|
|
def connect(socket_class, host, port, nodelay = false)
|
|
@socket = socket_class.open(host, port)
|
|
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
|
|
end
|
|
end
|
|
|
|
class Request
|
|
REQUEST_TARGET = '(request-target)'
|
|
|
|
# We enforce a 5s timeout on DNS resolving, 5s timeout on socket opening
|
|
# and 5s timeout on the TLS handshake, meaning the worst case should take
|
|
# about 15s in total
|
|
TIMEOUT = { connect: 5, read: 10, write: 10 }.freeze
|
|
|
|
include RoutingHelper
|
|
|
|
def initialize(verb, url, **options)
|
|
raise ArgumentError if url.blank?
|
|
|
|
@verb = verb
|
|
@url = Addressable::URI.parse(url).normalize
|
|
@http_client = options.delete(:http_client)
|
|
@options = options.merge(socket_class: use_proxy? ? ProxySocket : Socket)
|
|
@options = @options.merge(Rails.configuration.x.http_client_proxy) if use_proxy?
|
|
@headers = {}
|
|
|
|
raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if block_hidden_service?
|
|
|
|
set_common_headers!
|
|
set_digest! if options.key?(:body)
|
|
end
|
|
|
|
def on_behalf_of(account, key_id_format = :acct, sign_with: nil)
|
|
raise ArgumentError unless account.local?
|
|
|
|
@account = account
|
|
@keypair = sign_with.present? ? OpenSSL::PKey::RSA.new(sign_with) : @account.keypair
|
|
@key_id_format = key_id_format
|
|
|
|
self
|
|
end
|
|
|
|
def add_headers(new_headers)
|
|
@headers.merge!(new_headers)
|
|
self
|
|
end
|
|
|
|
def perform
|
|
begin
|
|
response = http_client.public_send(@verb, @url.to_s, @options.merge(headers: headers))
|
|
rescue => e
|
|
raise e.class, "#{e.message} on #{@url}", e.backtrace[0]
|
|
end
|
|
|
|
begin
|
|
response = response.extend(ClientLimit)
|
|
|
|
# If we are using a persistent connection, we have to
|
|
# read every response to be able to move forward at all.
|
|
# However, simply calling #to_s or #flush may not be safe,
|
|
# as the response body, if malicious, could be too big
|
|
# for our memory. So we use the #body_with_limit method
|
|
response.body_with_limit if http_client.persistent?
|
|
|
|
yield response if block_given?
|
|
ensure
|
|
http_client.close unless http_client.persistent?
|
|
end
|
|
end
|
|
|
|
def headers
|
|
(@account ? @headers.merge('Signature' => signature) : @headers).without(REQUEST_TARGET)
|
|
end
|
|
|
|
class << self
|
|
def valid_url?(url)
|
|
begin
|
|
parsed_url = Addressable::URI.parse(url)
|
|
rescue Addressable::URI::InvalidURIError
|
|
return false
|
|
end
|
|
|
|
%w(http https).include?(parsed_url.scheme) && parsed_url.host.present?
|
|
end
|
|
|
|
def http_client
|
|
HTTP.use(:auto_inflate).timeout(:per_operation, TIMEOUT.dup).follow(max_hops: 2)
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def set_common_headers!
|
|
@headers[REQUEST_TARGET] = "#{@verb} #{@url.path}"
|
|
@headers['User-Agent'] = Mastodon::Version.user_agent
|
|
@headers['Host'] = @url.host
|
|
@headers['Date'] = Time.now.utc.httpdate
|
|
@headers['Accept-Encoding'] = 'gzip' if @verb != :head
|
|
end
|
|
|
|
def set_digest!
|
|
@headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}"
|
|
end
|
|
|
|
def signature
|
|
algorithm = 'rsa-sha256'
|
|
signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest::SHA256.new, signed_string))
|
|
|
|
"keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\""
|
|
end
|
|
|
|
def signed_string
|
|
signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n")
|
|
end
|
|
|
|
def signed_headers
|
|
@headers.without('User-Agent', 'Accept-Encoding')
|
|
end
|
|
|
|
def key_id
|
|
case @key_id_format
|
|
when :acct
|
|
@account.to_webfinger_s
|
|
when :uri
|
|
[ActivityPub::TagManager.instance.uri_for(@account), '#main-key'].join
|
|
end
|
|
end
|
|
|
|
def http_client
|
|
@http_client ||= Request.http_client
|
|
end
|
|
|
|
def use_proxy?
|
|
Rails.configuration.x.http_client_proxy.present?
|
|
end
|
|
|
|
def block_hidden_service?
|
|
!Rails.configuration.x.access_to_hidden_service && /\.(onion|i2p)$/.match(@url.host)
|
|
end
|
|
|
|
module ClientLimit
|
|
def body_with_limit(limit = 1.megabyte)
|
|
raise Mastodon::LengthValidationError if content_length.present? && content_length > limit
|
|
|
|
if charset.nil?
|
|
encoding = Encoding::BINARY
|
|
else
|
|
begin
|
|
encoding = Encoding.find(charset)
|
|
rescue ArgumentError
|
|
encoding = Encoding::BINARY
|
|
end
|
|
end
|
|
|
|
contents = String.new(encoding: encoding)
|
|
|
|
while (chunk = readpartial)
|
|
contents << chunk
|
|
chunk.clear
|
|
|
|
raise Mastodon::LengthValidationError if contents.bytesize > limit
|
|
end
|
|
|
|
contents
|
|
end
|
|
end
|
|
|
|
class Socket < TCPSocket
|
|
class << self
|
|
def open(host, *args)
|
|
outer_e = nil
|
|
port = args.first
|
|
|
|
addresses = []
|
|
begin
|
|
addresses = [IPAddr.new(host)]
|
|
rescue IPAddr::InvalidAddressError
|
|
Resolv::DNS.open do |dns|
|
|
dns.timeouts = 5
|
|
addresses = dns.getaddresses(host).take(2)
|
|
end
|
|
end
|
|
|
|
addresses.each do |address|
|
|
begin
|
|
check_private_address(address)
|
|
|
|
sock = ::Socket.new(address.is_a?(Resolv::IPv6) ? ::Socket::AF_INET6 : ::Socket::AF_INET, ::Socket::SOCK_STREAM, 0)
|
|
sockaddr = ::Socket.pack_sockaddr_in(port, address.to_s)
|
|
|
|
sock.setsockopt(::Socket::IPPROTO_TCP, ::Socket::TCP_NODELAY, 1)
|
|
|
|
begin
|
|
sock.connect_nonblock(sockaddr)
|
|
rescue IO::WaitWritable
|
|
if IO.select(nil, [sock], nil, Request::TIMEOUT[:connect])
|
|
begin
|
|
sock.connect_nonblock(sockaddr)
|
|
rescue Errno::EISCONN
|
|
# Yippee!
|
|
rescue
|
|
sock.close
|
|
raise
|
|
end
|
|
else
|
|
sock.close
|
|
raise HTTP::TimeoutError, "Connect timed out after #{Request::TIMEOUT[:connect]} seconds"
|
|
end
|
|
end
|
|
|
|
return sock
|
|
rescue => e
|
|
outer_e = e
|
|
end
|
|
end
|
|
|
|
if outer_e
|
|
raise outer_e
|
|
else
|
|
raise SocketError, "No address for #{host}"
|
|
end
|
|
end
|
|
|
|
alias new open
|
|
|
|
def check_private_address(address)
|
|
raise Mastodon::HostValidationError if PrivateAddressCheck.private_address?(IPAddr.new(address.to_s))
|
|
end
|
|
end
|
|
end
|
|
|
|
class ProxySocket < Socket
|
|
class << self
|
|
def check_private_address(_address)
|
|
# Accept connections to private addresses as HTTP proxies will usually
|
|
# be on local addresses
|
|
nil
|
|
end
|
|
end
|
|
end
|
|
|
|
private_constant :ClientLimit, :Socket, :ProxySocket
|
|
end
|