diff --git a/aws-sdk-core/features/s3/presigned_request.feature b/aws-sdk-core/features/s3/presigned_request.feature new file mode 100644 index 00000000000..50fe795ee9e --- /dev/null +++ b/aws-sdk-core/features/s3/presigned_request.feature @@ -0,0 +1,53 @@ +# language: en +@s3 @presigned_request +Feature: Enhanced presigned request interface + + Background: + Given I create a bucket + + Scenario: Presigning a put object request + When I create a presigned request for "put_object" with: + | key | test | + | acl | public-read | + And I send an HTTP put request with uri headers and body "hello" + And I make an unauthenticated HTTPS GET request for key "test" + Then the response should be "hello" + + Scenario: Presigning a HTTPS get object request + When I put "signed" to the key "retrieve_me" + And I create a presigned request for "get_object" with: + | key | retrieve_me | + And I send an HTTP "get" request with uri and headers + Then the response should be "signed" + + Scenario: Presigning a HTTP get object request + When I put "signed" to the key "retrieve_me" + And I create a non-secure presigned request for "get_object" with: + | key | retrieve_me | + And I send an HTTP "get" request with uri and headers + Then the response should be "signed" + + @headers + Scenario: Presigning a put object request with custom headers + Given I have a header hash to sign: + | foo | bar | + When I create a presigned request for "put_object" with the hash and: + | key | test | + | acl | public-read | + And I send an HTTP "put" request with uri and headers + Then Signed headers should be: + | x-amz-acl | public-read | + | x-amz-foo | bar | + Then the response should have a 200 status code + + @headers + Scenario: Presigning a put object request with whitelist headers + Given I have a header hash to sign: + | cache-control | max-age=20000 | + When I create a presigned request for "put_object" with the hash and: + | key | test | + | cache-control | max-age=20000 | + And I send an HTTP "put" request with uri and headers + Then Signed headers should be: + | x-amz-cache-control | max-age=20000 | + Then the response should have a 200 status code diff --git a/aws-sdk-core/features/s3/step_definitions.rb b/aws-sdk-core/features/s3/step_definitions.rb index 2d9d8a572fb..4023610a376 100644 --- a/aws-sdk-core/features/s3/step_definitions.rb +++ b/aws-sdk-core/features/s3/step_definitions.rb @@ -280,3 +280,36 @@ def create_bucket(options = {}) Then(/^the location constraint should be "([^"]*)"$/) do |lc| expect(@response.location_constraint).to eq(lc) end + +When(/^I create a (non-secure )?presigned request for "([^"]*)" with( the hash and)?:$/) do |non_secure, method, hash, params| + params = symbolized_params(params) + params[:bucket] = @bucket_name + params[:secure] = false if non_secure + params[:headers] = @custom_headers if hash + params[:region] = @client.config.region + params[:credentials] = @client.config.credentials + presigned_request = Aws::S3::PresignedRequest.new(method.to_sym, params) + @uri = presigned_request.uri + @headers = presigned_request.headers +end + +When(/^I send an HTTP put request with uri headers and body "([^"]*)"$/) do |body| + http = Net::HTTP.new(@uri.host) + req = Net::HTTP::Put.new(@uri.request_uri, @headers) + req.body = body + @resp = http.request(req) +end + +When(/^I send an HTTP "([^"]*)" request with uri and headers$/) do |method| + http = Net::HTTP.new(@uri.host) + req = Net::HTTP.const_get(method.capitalize).new(@uri.request_uri, @headers) + @resp = http.request(req) +end + +Then(/^Signed headers should be:$/) do |table| + table.rows_hash.each {|k, v| expect(@headers[k]).to eq(v)} +end + +Given(/^I have a header hash to sign:$/) do |hash| + @custom_headers = hash.rows_hash.inject({}) {|h, (k, v)| h[k] = v; h} +end diff --git a/aws-sdk-core/lib/aws-sdk-core/s3.rb b/aws-sdk-core/lib/aws-sdk-core/s3.rb index bac2ac62f7f..c7485254f31 100644 --- a/aws-sdk-core/lib/aws-sdk-core/s3.rb +++ b/aws-sdk-core/lib/aws-sdk-core/s3.rb @@ -11,6 +11,7 @@ module Aws module S3 autoload :Presigner, 'aws-sdk-core/s3/presigner' + autoload :PresignedRequest, 'aws-sdk-core/s3/presigned_request' autoload :BucketRegionCache, 'aws-sdk-core/s3/bucket_region_cache' # A cache of discovered bucket regions. You can call `#bucket_added` diff --git a/aws-sdk-core/lib/aws-sdk-core/s3/presigned_request.rb b/aws-sdk-core/lib/aws-sdk-core/s3/presigned_request.rb new file mode 100644 index 00000000000..c8abfa67ccb --- /dev/null +++ b/aws-sdk-core/lib/aws-sdk-core/s3/presigned_request.rb @@ -0,0 +1,169 @@ +require 'aws-sigv4' + +module Aws + module S3 + + # Utility class for creating a presigned request with presigned URL and signed + # headers hash. + # + # Example Use: + # + # presigned_request = Aws::S3::PresignedRequest.new( + # :put_object, + # bucket: "bucket", + # key: "key", + # acl: "private", + # ... + # headers: { + # foo: "bar" + # } + # ) + # + # presigned_request.uri + # # => # {"x-amz-acl"=>"private", "x-amz-foo"=>'bar', ...} + # + class PresignedRequest + + # @api private + ONE_WEEK = 60 * 60 * 24 * 7 + + # TODO + # @param [Symbol] method Symbolized method name of the operation you + # want to presign. + # + # @option params [Integer] :expires_in (900) + # How long the presigned URL should be valid for. Defaults + # to 15 minutes (900 seconds). + # + # @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 options [Hash] :headers ({}) Customized headers to + # be signed and sent along with the request. + # + # @option options [Time] :time (Time.now) Time of the signature. + # You should only set this value for testing. + def initialize(method, params = {}) + bucket_and_key(params) + @credentials, @region = credentials_and_region(params) + client = Aws::S3::Client.new(region: @region, credentials: @credentials) + + @http_method = Aws::S3::Client.api.operation(method).http_method + @time = params.delete(:time) || Time.now + @expires = expires_in(params) + virtual_host = !!params.delete(:virtual_host) + secure = params.delete(:secure) + # build customize or whitelist headers when presents + headers = build_headers(params.delete(:headers) || {}) + + req = client.build_request(method, params) + endpoint_and_headers(req, secure, virtual_host) + @endpoint, req_headers = req.send_request.data + @headers = headers.merge(req_headers) + end + + # Returns the presigned URL for the S3 operation + # + # @return [HTTPS::URI, HTTP::URI] + def uri + v4_signer.presign_url( + http_method: @http_method, + url: @endpoint, + headers: @headers, + body_digest: 'UNSIGNED-PAYLOAD', + expires_in: @expires, + time: @time + ) + end + + # Returns signed headers + # + # @return [Hash] headers + attr_reader :headers +=begin + def headers + signature = v4_signer.sign_request( + http_method: @http_method, + url: @endpoint, + headers: @headers, + body: '' + ) + signature.headers.merge(@headers) + end +=end + + private + + def v4_signer + Aws::Sigv4::Signer.new( + service: 's3', + region: @region, + credentials_provider: @credentials, + uri_escape_path: false, + unsigned_headers: Aws::Signers::V4::BLACKLIST_HEADERS - @headers.keys + ) + end + + def expires_in(params) + if expires = params.delete(:expires_in) + if expires > ONE_WEEK + msg = "expires_in value of #{expires} exceeds one-week maximum" + raise ArgumentError, msg + end + expires + end + end + + def bucket_and_key(params) + bucket = params.fetch(:bucket) + key = params.fetch(:key) + raise ArgumentError, 'bucket must not be blank' if bucket == '' + raise ArgumentError, 'key must not be blank' if key == '' + end + + def credentials_and_region(params) + credentials = params.delete(:credentials) + region = params.delete(:region) + raise ArgumentError, ':credentials must be provided for a presigned request' if credentials.nil? + raise ArgumentError, ':region must be provided for a presigned request' if region.nil? + [credentials, region] + end + + def build_headers(hash) + hash.inject({}) {|h, (k, v)| h["x-amz-#{k.to_s.gsub(/_/, '-')}"] = v; h} + end + + def endpoint_and_headers(req, secure, virtual_host) + req.handlers.remove(Plugins::S3BucketDns::Handler) if virtual_host + req.handlers.remove(Plugins::S3RequestSigner::SigningHandler) + req.handlers.remove(Seahorse::Client::Plugins::ContentLength::Handler) + req.handle(step: :send) do |context| + headers = context.http_request.headers.inject({}) do |h, (k, v)| + h[k] = v unless k.match(/x-amz-/).nil? + h + end + endpoint = context.http_request.endpoint + if secure == false || virtual_host + endpoint.scheme = 'http' + endpoint.port = 80 + end + if virtual_host + endpoint.host = context.params[:bucket] + endpoint.path.sub!("/#{context.params[:bucket]}", '') + end + Seahorse::Client::Response.new(context: context, data: [endpoint, headers]) + end + end + + end + + end +end diff --git a/aws-sdk-core/spec/aws/s3/presigned_request_spec.rb b/aws-sdk-core/spec/aws/s3/presigned_request_spec.rb new file mode 100644 index 00000000000..5f5b7abe16c --- /dev/null +++ b/aws-sdk-core/spec/aws/s3/presigned_request_spec.rb @@ -0,0 +1,183 @@ +require 'spec_helper' + +module Aws + module S3 + describe PresignedRequest do + + before (:each) do + allow(Time).to receive(:now).and_return(now) + allow(now).to receive(:utc).and_return(utc) + allow(utc).to receive(:strftime).and_return(datetime) + end + + let(:region) {'us-east-1'} + let(:credentials) { + Credentials.new( + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") + } + + let(:now) { double('now') } + let(:utc) { double('utc-time') } + let(:datetime) { '20130524T000000Z' } + + describe '#initialize' do + + it 'raises error when missing :region or :credentials' do + expect { + req = PresignedRequest.new( + :put_object, + credentials: credentials, + bucket: 'bucket', + key: 'key' + ) + }.to raise_error(ArgumentError) + expect { + req = PresignedRequest.new( + :put_object, + region: region, + bucket: 'bucket', + key: 'key' + ) + }.to raise_error(ArgumentError) + end + + it 'raises error when missing valid :bucket or :key' do + expect { + req = PresignedRequest.new( + :put_object, + region: region, + credentials: credentials, + key: 'key' + ) + }.to raise_error(KeyError) + expect { + req = PresignedRequest.new( + :put_object, + region: region, + credentials: credentials, + bucket: 'bucket', + key: '' + ) + }.to raise_error(ArgumentError, /key must not be blank/) + end + + it 'raises when expires_in length is over 1 week' do + expect { + req = PresignedRequest.new( + :get_object, + region: region, + credentials: credentials, + bucket: 'bucket', + key: 'key', + expires_in: (7 * 86400) + 1 + ) + }.to raise_error(ArgumentError) + end + + end + + describe "#uri" do + + it 'can presign #get_object to spec' do + bucket = "examplebucket" + key = "test.txt" + expected_url = "https://examplebucket.s3.amazonaws.com/test.txt"\ + "?X-Amz-Algorithm=AWS4-HMAC-SHA256"\ + "&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2F"\ + "us-east-1%2Fs3%2Faws4_request"\ + "&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400"\ + "&X-Amz-SignedHeaders=host"\ + "&X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c26595"\ + "7d157751f604d404" + + req = PresignedRequest.new( + :get_object, + region: region, + credentials: credentials, + bucket: bucket, + key: key, + expires_in: 86400 + ) + expect(req.uri.to_s).to eq(expected_url) + end + + it 'can generate http (non-secure) urls' do + req = PresignedRequest.new( + :get_object, + region: region, + credentials: credentials, + bucket: 'bucket', + key: 'key', + secure: false + ) + expect(req.uri.to_s).to match(/^http:/) + end + + it 'can generate virtual host style urls' do + req = PresignedRequest.new( + :get_object, + region: region, + credentials: credentials, + bucket: 'virtual.hosted.com', + key: 'foo', + virtual_host: true + ) + expect(req.uri.to_s).to match(/^http:\/\/virtual.hosted.com\/foo/) + end + + it 'returns same url when called twice' do + req = PresignedRequest.new( + :get_object, + region: region, + credentials: credentials, + bucket: 'virtual.hosted.com', + key: 'foo', + virtual_host: true + ) + first = req.uri.to_s + second = req.uri.to_s + expect(first).to eq(second) + expect(second).to match(/^http:\/\/virtual.hosted.com\/foo/) + end + end + + describe "#headers" do + + it 'allows overwrite blacklist headers in Sigv4' do + req = PresignedRequest.new( + :put_object, + region: region, + credentials: credentials, + bucket: 'bucket', + key: 'key', + cache_control: 'max-age=20000', + headers: { + cache_control: 'max-age=20000', + } + ) + expect(req.headers['x-amz-cache-control']).to eq('max-age=20000') + expect(req.uri.to_s).to match(/.*X-Amz-SignedHeaders=host%3Bx-amz-cache-control.*/) + end + + it 'allows customize header signing' do + req = PresignedRequest.new( + :put_object, + region: region, + credentials: credentials, + bucket: 'bucket', + key: 'key', + headers: { + 'foo' => 'bar' + } + ) + expect(req.headers['x-amz-foo']).to eq('bar') + expect(req.uri.to_s).to match(/.*X-Amz-SignedHeaders=host%3Bx-amz-foo.*/) + + end + + end + + end + end +end diff --git a/aws-sdk-resources/lib/aws-sdk-resources/services/s3/object.rb b/aws-sdk-resources/lib/aws-sdk-resources/services/s3/object.rb index f023c335ace..da414ac8371 100644 --- a/aws-sdk-resources/lib/aws-sdk-resources/services/s3/object.rb +++ b/aws-sdk-resources/lib/aws-sdk-resources/services/s3/object.rb @@ -192,6 +192,51 @@ def presigned_url(http_method, params = {}) )) end + # Returns Aws::S3::PresignedRequest object that provides both presigned + # url and signed headers hash. + # + # r = s3.bucket('bucket-name').object('obj-key').presigned_request( + # :put, + # acl: 'public-read' + # ) + # # => # # {"x-amz-acl"=>"public-read"} + # + # @param [Symbol] http_method + # The HTTP method to generate a presigned URL for. Valid values + # are `:get`, `:put`, `:head`, and `:delete`. + # + # @option params [Integer] :expires_in (900) + # How long the presigned URL should be valid for. Defaults + # to 15 minutes (900 seconds). + # + # @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 options [Hash] :headers ({}) Customized headers to + # be signed and sent along with the request. + # + # @option options [Time] :time (Time.now) Time of the signature. + # You should only set this value for testing. + # + def presigned_request(http_method, params = {}) + PresignedRequest.new("#{http_method.downcase}_object", params.merge( + client: client, + bucket: bucket_name, + key: key + )) + end + # Returns the public (un-signed) URL for this object. # # s3.bucket('bucket-name').object('obj-key').public_url diff --git a/aws-sdk-resources/lib/aws-sdk-resources/services/s3/object_summary.rb b/aws-sdk-resources/lib/aws-sdk-resources/services/s3/object_summary.rb index 5785758d892..034dc9e7d93 100644 --- a/aws-sdk-resources/lib/aws-sdk-resources/services/s3/object_summary.rb +++ b/aws-sdk-resources/lib/aws-sdk-resources/services/s3/object_summary.rb @@ -44,6 +44,14 @@ def presigned_url(http_method, params = {}) object.presigned_url(http_method, params) end + # @param (see Object#presigned_request) + # @options (see Object#presigned_request) + # @return (see Object#presigned_request) + # @see Object#presigned_request + def presigned_request(http_method, params = {}) + object.presigned_request(http_method, params) + end + # @param (see Object#public_url) # @options (see Object#public_url) # @return (see Object#public_url)