# frozen_string_literal: true
module Aws
module S3
class Presigner
# @api private
ONE_WEEK = 60 * 60 * 24 * 7
# @api private
FIFTEEN_MINUTES = 60 * 15
# @api private
BLACKLISTED_HEADERS = [
'accept',
'amz-sdk-request',
'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
# Create presigned URLs for S3 operations.
#
# @example
# signer = Aws::S3::Presigner.new
# url = signer.presigned_url(:get_object, bucket: "bucket", key: "key")
#
# @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.
#
# @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.
#
# @return [String] a presigned url
def presigned_url(method, params = {})
url, _headers = _presigned_request(method, params)
url
end
# Allows you to create presigned URL requests for S3 operations. This
# method returns a tuple containing the URL and the signed X-amz-* headers
# to be used with the presigned url.
#
# @example
# signer = Aws::S3::Presigner.new
# url, headers = signer.presigned_request(
# :get_object, bucket: "bucket", key: "key"
# )
#
# @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.
#
# @return [String, Hash] A tuple with a presigned URL and headers that
# should be included with the request.
def presigned_request(method, params = {})
_presigned_request(method, params, false)
end
private
def _presigned_request(method, params, hoist = true)
virtual_host = params.delete(:virtual_host)
time = params.delete(:time)
unsigned_headers = unsigned_headers(params)
scheme = http_scheme(params)
expires_in = expires_in(params)
req = @client.build_request(method, params)
use_bucket_as_hostname(req) if virtual_host
handle_presigned_url_context(req)
x_amz_headers = sign_but_dont_send(
req, expires_in, scheme, time, unsigned_headers, hoist
)
[req.send_request.data, x_amz_headers]
end
def unsigned_headers(params)
whitelist_headers = params.delete(:whitelist_headers) || []
BLACKLISTED_HEADERS - whitelist_headers
end
def http_scheme(params)
if params.delete(:secure) == false
'http'
else
@client.config.endpoint.scheme
end
end
def expires_in(params)
if (expires_in = params.delete(:expires_in))
if expires_in > ONE_WEEK
raise ArgumentError,
"expires_in value of #{expires_in} exceeds one-week maximum."
elsif expires_in <= 0
raise ArgumentError,
"expires_in value of #{expires_in} cannot be 0 or less."
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]}", '')
@handler.call(context)
end
end
# Used for excluding presigned_urls from API request count.
#
# Store context information as early as possible, to allow
# handlers to perform decisions based on this flag if need.
def handle_presigned_url_context(req)
req.handle(step: :initialize, priority: 98) do |context|
context[:presigned_url] = true
@handler.call(context)
end
end
# @param [Seahorse::Client::Request] req
def sign_but_dont_send(
req, expires_in, scheme, time, unsigned_headers, hoist = true
)
x_amz_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)
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
query = http_req.endpoint.query ? http_req.endpoint.query.split('&') : []
http_req.headers.each do |key, value|
next unless key =~ /^x-amz/i
if hoist
value = Aws::Sigv4::Signer.uri_escape(value)
key = Aws::Sigv4::Signer.uri_escape(key)
# hoist x-amz-* headers to the querystring
http_req.headers.delete(key)
query << "#{key}=#{value}"
else
x_amz_headers[key] = value
end
end
http_req.endpoint.query = query.join('&') unless query.empty?
# If it's an ARN, get the resolved region and service
if (arn = context.metadata[:s3_arn])
region = arn[:resolved_region]
service = arn[:arn].service
end
signer = Aws::Sigv4::Signer.new(
service: service || 's3',
region: region || context.config.region,
credentials_provider: context.config.credentials,
unsigned_headers: unsigned_headers,
uri_escape_path: false
)
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
# Return the headers
x_amz_headers
end
end
end
end