lib/aws-sdk-s3/presigner.rb



module Aws
  module S3

    # Allows you to create presigned URLs for S3 operations.
    #
    # Example Use:
    #
    #      signer = Aws::S3::Presigner.new
    #      url = signer.presigned_url(:get_object, bucket: "bucket", key: "key")
    #
    class Presigner

      # @api private
      ONE_WEEK = 60 * 60 * 24 * 7

      # @api private
      FIFTEEN_MINUTES = 60 * 15

      BLACKLISTED_HEADERS = [
        'accept',
        'cache-control',
        'content-length', # due to a ELB bug
        'expect',
        'from',
        'if-match',
        'if-none-match',
        'if-modified-since',
        'if-unmodified-since',
        'if-range',
        'max-forwards',
        'pragma',
        'proxy-authorization',
        'referer',
        'te',
        'user-agent'
      ].freeze

      # @option options [Client] :client Optionally provide an existing
      #   S3 client
      def initialize(options = {})
        @client = options[:client] || Aws::S3::Client.new
      end

      # @param [Symbol] method Symbolized method name of the operation you want
      #   to presign.
      #
      # @option params [Integer] :expires_in (900) The number of seconds
      #   before the presigned URL expires. Defaults to 15 minutes. As signature
      #   version 4 has a maximum expiry time of one week for presigned URLs,
      #   attempts to set this value to greater than one week (604800) will
      #   raise an exception.
      #
      # @option params [Time] :time (Time.now) The starting time for when the
      #   presigned url becomes active.
      #
      # @option params [Boolean] :secure (true) When `false`, a HTTP URL
      #   is returned instead of the default HTTPS URL.
      #
      # @option params [Boolean] :virtual_host (false) When `true`, the
      #   bucket name will be used as the hostname. This will cause
      #   the returned URL to be 'http' and not 'https'.
      #
      # @option params [Boolean] :use_accelerate_endpoint (false) When `true`,
      #   Presigner will attempt to use accelerated endpoint.
      #
      # @option params [Array<String>] :whitelist_headers ([]) Additional
      #   headers to be included for the signed request. Certain headers beyond
      #   the authorization header could, in theory, be changed for various
      #   reasons (including but not limited to proxies) while in transit and
      #   after signing. This would lead to signature errors being returned,
      #   despite no actual problems with signing. (see BLACKLISTED_HEADERS)
      #
      # @raise [ArgumentError] Raises an ArgumentError if `:expires_in`
      #   exceeds one week.
      #
      def presigned_url(method, params = {})
        if params[:key].nil? or params[:key] == ''
          raise ArgumentError, ":key must not be blank"
        end
        virtual_host = !!params.delete(:virtual_host)
        time = params.delete(:time)
        whitelisted_headers = params.delete(:whitelist_headers) || []
        unsigned_headers = BLACKLISTED_HEADERS - whitelisted_headers
        scheme = http_scheme(params, virtual_host)

        req = @client.build_request(method, params)
        use_bucket_as_hostname(req) if virtual_host

        sign_but_dont_send(req, expires_in(params), scheme, time, unsigned_headers)
        req.send_request.data
      end

      private

      def http_scheme(params, virtual_host)
        if params.delete(:secure) == false || virtual_host
          'http'
        else
          @client.config.endpoint.scheme
        end
      end

      def expires_in(params)
        if (expires_in = params.delete(:expires_in))
          if expires_in > ONE_WEEK
            msg = "expires_in value of #{expires_in} exceeds one-week maximum"
            raise ArgumentError, msg
          end
          expires_in
        else
          FIFTEEN_MINUTES
        end
      end

      def use_bucket_as_hostname(req)
        req.handlers.remove(Plugins::BucketDns::Handler)
        req.handle do |context|
          uri = context.http_request.endpoint
          uri.host = context.params[:bucket]
          uri.path.sub!("/#{context.params[:bucket]}", '')
          uri.scheme = 'http'
          uri.port = 80
          @handler.call(context)
        end
      end

      # @param [Seahorse::Client::Request] req
      def sign_but_dont_send(req, expires_in, scheme, time, unsigned_headers)
        http_req = req.context.http_request

        req.handlers.remove(Aws::S3::Plugins::S3Signer::LegacyHandler)
        req.handlers.remove(Aws::S3::Plugins::S3Signer::V4Handler)
        req.handlers.remove(Seahorse::Client::Plugins::ContentLength::Handler)

        signer = build_signer(req.context.config, unsigned_headers)

        req.context[:presigned_url] = true
        req.handle(step: :send) do |context|

          if scheme != http_req.endpoint.scheme
            endpoint = http_req.endpoint.dup
            endpoint.scheme = scheme
            endpoint.port = (scheme == 'http' ? 80 : 443)
            http_req.endpoint = URI.parse(endpoint.to_s)
          end

          # hoist x-amz-* headers to the querystring
          query = http_req.endpoint.query ? http_req.endpoint.query.split('&') : []
          http_req.headers.keys.each do |key|
            if key.match(/^x-amz/i)
              value = Aws::Sigv4::Signer.uri_escape(http_req.headers.delete(key))
              key = Aws::Sigv4::Signer.uri_escape(key)
              query << "#{key}=#{value}"
            end
          end
          http_req.endpoint.query = query.join('&') unless query.empty?

          url = signer.presign_url(
            http_method: http_req.http_method,
            url: http_req.endpoint,
            headers: http_req.headers,
            body_digest: 'UNSIGNED-PAYLOAD',
            expires_in: expires_in,
            time: time
          ).to_s

          Seahorse::Client::Response.new(context: context, data: url)
        end
      end

      def build_signer(cfg, unsigned_headers)
        Aws::Sigv4::Signer.new(
          service: 's3',
          region: cfg.region,
          credentials_provider: cfg.credentials,
          unsigned_headers: unsigned_headers,
          uri_escape_path: false
        )
      end
    end
  end
end