diff --git a/google-apis-core/lib/google/apis/core/http_command.rb b/google-apis-core/lib/google/apis/core/http_command.rb index f07a323b9e5..17fbe87b362 100644 --- a/google-apis-core/lib/google/apis/core/http_command.rb +++ b/google-apis-core/lib/google/apis/core/http_command.rb @@ -31,7 +31,7 @@ class HttpCommand RETRIABLE_ERRORS = [Google::Apis::ServerError, Google::Apis::RateLimitError, Google::Apis::TransmissionError, - Google::Apis::RequestTimeOutError] + Google::Apis::RequestTimeOutError].freeze begin require 'opencensus' @@ -83,15 +83,15 @@ def initialize(method, url, body: nil) self.url = url self.url = Addressable::Template.new(url) if url.is_a?(String) self.method = method - self.header = Hash.new + self.header =({}) self.body = body self.query = {} self.params = {} @opencensus_span = nil if OPENCENSUS_AVAILABLE logger.warn 'OpenCensus support is now deprecated. ' + - 'Please refer https://github.com/googleapis/google-api-ruby-client#tracing for migrating to use OpenTelemetry.' - + 'Please refer https://github.com/googleapis/google-api-ruby-client#tracing for migrating to use OpenTelemetry.' + end end @@ -114,8 +114,8 @@ def execute(client, &block) release! end - def do_retry func, client - begin + def do_retry(func, client) + Retriable.retriable tries: options.retries + 1, max_elapsed_time: options.max_elapsed_time, base_interval: options.base_interval, @@ -130,19 +130,17 @@ def do_retry func, client on: [Google::Apis::AuthorizationError, Signet::AuthorizationError, Signet::RemoteServerError, Signet::UnexpectedStatusError], on_retry: proc { |*| refresh_authorization } do send(func, client).tap do |result| - if block_given? - yield result, nil - end + yield result, nil if block_given? end end end - rescue => e + rescue StandardError => e if block_given? yield nil, e else raise e end - end + end # Refresh the authorization authorization after a 401 error @@ -171,6 +169,7 @@ def prepare! query.update(options.query) if options.query normalize_unicode = options.normalize_unicode end + validate_path_parameters! if url.is_a?(Addressable::Template) self.url = url.expand(params, nil, normalize_unicode) if url.is_a?(Addressable::Template) url.query_values = normalize_query_values(query).merge(url.query_values || {}) @@ -182,17 +181,16 @@ def prepare! @form_encoded = false end - self.body = '' if self.body.nil? && [:post, :put, :patch].include?(method) + self.body = '' if self.body.nil? && %i[post put patch].include?(method) if defined?(::Google::Apis::Core::CompositeIO) && body.is_a?(::Google::Apis::Core::CompositeIO) - header["Content-Length"] ||= body.size.to_s + header['Content-Length'] ||= body.size.to_s end end # Release any resources used by this command # @private # @return [void] - def release! - end + def release!; end # Check the response and either decode body or raise error # @@ -298,7 +296,7 @@ def error(err, rethrow: false, &block) err = Google::Apis::TransmissionError.new(err) end block.call(nil, err) if block_given? - fail err if rethrow || block.nil? + raise err if rethrow || block.nil? end # Execute the command once. @@ -323,7 +321,7 @@ def execute_once(client) logger.debug { safe_single_line_representation @http_res } response = process_response(@http_res.status.to_i, @http_res.headers, @http_res.body) success(response) - rescue => e + rescue StandardError => e logger.debug { sprintf('Caught error %s', e) } error(e, rethrow: true) end @@ -343,28 +341,29 @@ def apply_request_options(req_header) end def allow_form_encoding? - [:post, :put].include?(method) && body.nil? + %i[post put].include?(method) && body.nil? end # Set the API version header for the service if not empty. # @return [void] - def set_api_version_header api_version + def set_api_version_header(api_version) self.header['X-Goog-Api-Version'] = api_version unless api_version.empty? end private UNSAFE_CLASS_NAMES = [ - "Google::Apis::CloudkmsV1::DecryptResponse", - "Google::Apis::SecretmanagerV1::SecretPayload", - "Google::Apis::SecretmanagerV1beta1::SecretPayload" - ] + 'Google::Apis::CloudkmsV1::DecryptResponse', + 'Google::Apis::SecretmanagerV1::SecretPayload', + 'Google::Apis::SecretmanagerV1beta1::SecretPayload' + ].freeze module RedactingPPMethods - def pp_object obj + def pp_object(obj) return super unless UNSAFE_CLASS_NAMES.include? obj.class.name + object_address_group obj do - text "(fields redacted)" + text '(fields redacted)' end end end @@ -377,16 +376,16 @@ class RedactingSingleLine < PP::SingleLine include RedactingPPMethods end - def safe_pretty_representation obj - out = +"" + def safe_pretty_representation(obj) + out = +'' printer = RedactingPP.new out, 79 printer.guard_inspect_key { printer.pp obj } printer.flush out << "\n" end - def safe_single_line_representation obj - out = +"" + def safe_single_line_representation(obj) + out = +'' printer = RedactingSingleLine.new out printer.guard_inspect_key { printer.pp obj } printer.flush @@ -400,18 +399,16 @@ def opencensus_begin_span @opencensus_span = OpenCensus::Trace.start_span url.path.to_s @opencensus_span.kind = OpenCensus::Trace::SpanBuilder::CLIENT - @opencensus_span.put_attribute "http.host", url.host.to_s - @opencensus_span.put_attribute "http.method", method.to_s.upcase - @opencensus_span.put_attribute "http.path", url.path.to_s + @opencensus_span.put_attribute 'http.host', url.host.to_s + @opencensus_span.put_attribute 'http.method', method.to_s.upcase + @opencensus_span.put_attribute 'http.path', url.path.to_s sent_size = if body.respond_to? :bytesize body.bytesize elsif body.nil? 0 end - if sent_size - @opencensus_span.put_message_event OpenCensus::Trace::SpanBuilder::SENT, 1, sent_size - end + @opencensus_span.put_message_event OpenCensus::Trace::SpanBuilder::SENT, 1, sent_size if sent_size formatter = OpenCensus::Trace.config.http_formatter if formatter.respond_to? :header_name @@ -436,7 +433,7 @@ def opencensus_end_span status = @http_res.status.to_i if status > 0 @opencensus_span.set_status map_http_status status - @opencensus_span.put_attribute "http.status_code", status + @opencensus_span.put_attribute 'http.status_code', status end end @@ -452,7 +449,7 @@ def form_encoded? @form_encoded end - def map_http_status http_status + def map_http_status(http_status) case http_status when 200..399 then 0 # OK when 400 then 3 # INVALID_ARGUMENT @@ -468,9 +465,8 @@ def map_http_status http_status end def normalize_query_values(input) - input.inject({}) do |h, (k, v)| + input.each_with_object({}) do |(k, v), h| h[k] = normalize_query_value(v) - h end end @@ -484,6 +480,58 @@ def normalize_query_value(v) v.to_s end end + + # Validate path parameters against template to prevent path traversal and injection exploits + # @raise [Google::Apis::Error] if validation fails + def validate_path_parameters! + template_pattern = url.pattern + + # Parse variables and operators + variables = [] + template_pattern.scan(/\{([\+#\.\/;\?&])?([^}]+)\}/) do |operator, var_list| + var_list.split(',').each do |var| + var_name = var.split(':').first.split('*').first + variables << { name: var_name, operator: operator, reserved: (operator == '+' || operator == '#') } + end + end + + variables.each do |v| + var_name = v[:name] + var_key = params.key?(var_name) ? var_name : var_name.to_sym + next unless params.key?(var_key) + value = params[var_key].to_s + + if value.include?('?') || value.include?('#') + raise Google::Apis::Error, "Parameter #{var_name} contains invalid characters (? or #)" + end + + if v[:reserved] + value_segments = value.split('/', -1) + net_depth = 0 + + value_segments.each do |seg| + if seg == '..' + if net_depth > 0 + net_depth -= 1 + else + raise Google::Apis::Error, "Path traversal escape detected in parameter #{var_name}: #{value}" + end + elsif seg == '.' || seg == '' + raise Google::Apis::Error, "Invalid path segment '#{seg}' in parameter #{var_name}" + else + net_depth += 1 + end + end + else + if value.include?('/') + raise Google::Apis::Error, "Simple parameter #{var_name} cannot contain slashes: #{value}" + end + if value == '.' || value == '..' + raise Google::Apis::Error, "Simple parameter #{var_name} cannot be '.' or '..': #{value}" + end + end + end + end end end end diff --git a/google-apis-core/spec/google/apis/core/http_command_spec.rb b/google-apis-core/spec/google/apis/core/http_command_spec.rb index 9813c3ddfb2..058439d2b32 100644 --- a/google-apis-core/spec/google/apis/core/http_command_spec.rb +++ b/google-apis-core/spec/google/apis/core/http_command_spec.rb @@ -34,8 +34,8 @@ class SecretPayload let(:command) do command = Google::Apis::Core::HttpCommand.new(:get, 'https://www.googleapis.com/zoo/animals') command.options.authorization = Signet::OAuth2::Client.new({ - :token_credential_uri => 'https://accounts.google.com/o/oauth2/token' - }) + token_credential_uri: 'https://accounts.google.com/o/oauth2/token' + }) command end @@ -43,7 +43,8 @@ class SecretPayload before(:example) do stub_request(:post, 'https://accounts.google.com/o/oauth2/token').to_return( { status: [500, 'Server error'] }, - { status: [200, ''], headers: { 'Content-Type' => 'application/json' }, body: '{"access_token": "foo"}' }) + { status: [200, ''], headers: { 'Content-Type' => 'application/json' }, body: '{"access_token": "foo"}' } + ) stub_request(:get, 'https://www.googleapis.com/zoo/animals').to_return(body: %(Hello world)) end @@ -59,7 +60,8 @@ class SecretPayload before(:example) do stub_request(:post, 'https://accounts.google.com/o/oauth2/token').to_return( { status: [500, 'Server error'] }, - { status: [401, 'Unauthorized'] }) + { status: [401, 'Unauthorized'] } + ) end it 'should raise error' do @@ -157,7 +159,8 @@ class SecretPayload base_interval: Google::Apis::RequestOptions.default.base_interval, max_interval: Google::Apis::RequestOptions.default.max_interval, multiplier: Google::Apis::RequestOptions.default.multiplier, - on: described_class::RETRIABLE_ERRORS).and_call_original + on: described_class::RETRIABLE_ERRORS + ).and_call_original allow(Retriable).to receive(:retriable).and_call_original command.execute(client) @@ -202,7 +205,6 @@ class SecretPayload it 'should raise error with HTTP status code' do expect(err.status_code).to eq 500 end - end context('with callbacks') do @@ -339,80 +341,82 @@ class SecretPayload end it 'should not swallow errors raised in block' do - expect { command.execute(client) { raise "Potatoes detected in tailpipe" } }.to raise_error("Potatoes detected in tailpipe") + expect { command.execute(client) { raise 'Potatoes detected in tailpipe' } }.to raise_error('Potatoes detected in tailpipe') end end - context('with opencensus integration') do - it 'should create an opencensus span for a successful call' do - stub_request(:get, 'https://www.googleapis.com/zoo/animals').to_return(status: [200, ''], body: "Hello world") - command = Google::Apis::Core::HttpCommand.new(:get, 'https://www.googleapis.com/zoo/animals') - OpenCensus::Trace.start_request_trace do |span_context| - command.execute(client) - spans = span_context.build_contained_spans - expect(spans.size).to eql 1 - span = spans.first - expect(span.name.value).to eql '/zoo/animals' - expect(span.status.code).to eql 0 - attrs = span.attributes - expect(attrs['http.host'].value).to eql 'www.googleapis.com' - expect(attrs['http.method'].value).to eql 'GET' - expect(attrs['http.path'].value).to eql '/zoo/animals' - expect(attrs['http.status_code']).to eql 200 - events = span.time_events - expect(events.size).to eql 2 - expect(events[0].type).to eql :SENT - expect(events[0].uncompressed_size).to eql 0 - expect(events[1].type).to eql :RECEIVED - expect(events[1].uncompressed_size).to eql 11 - end - end - - it 'should create an opencensus span for a call failure' do - stub_request(:get, 'https://www.googleapis.com/zoo/animals').to_return(status: [403, '']) - command = Google::Apis::Core::HttpCommand.new(:get, 'https://www.googleapis.com/zoo/animals') - OpenCensus::Trace.start_request_trace do |span_context| - expect { command.execute(client) }.to raise_error(Google::Apis::ClientError) - spans = span_context.build_contained_spans - expect(spans.size).to eql 1 - span = spans.first - expect(span.name.value).to eql '/zoo/animals' - expect(span.status.code).to eql 7 - attrs = span.attributes - expect(attrs['http.host'].value).to eql 'www.googleapis.com' - expect(attrs['http.method'].value).to eql 'GET' - expect(attrs['http.path'].value).to eql '/zoo/animals' - expect(attrs['http.status_code']).to eql 403 - end - end - - it 'should propagate trace context header' do - stub_request(:get, 'https://www.googleapis.com/zoo/animals').to_return(body: %(Hello world)) - command = Google::Apis::Core::HttpCommand.new(:get, 'https://www.googleapis.com/zoo/animals') - OpenCensus::Trace.start_request_trace do |span_context| - result = command.execute(client) - expect(a_request(:get, 'https://www.googleapis.com/zoo/animals') - .with { |req| !req.headers['Traceparent'].empty? }).to have_been_made + if Google::Apis::Core::HttpCommand::OPENCENSUS_AVAILABLE + context('with opencensus integration') do + it 'should create an opencensus span for a successful call' do + stub_request(:get, 'https://www.googleapis.com/zoo/animals').to_return(status: [200, ''], body: 'Hello world') + command = Google::Apis::Core::HttpCommand.new(:get, 'https://www.googleapis.com/zoo/animals') + OpenCensus::Trace.start_request_trace do |span_context| + command.execute(client) + spans = span_context.build_contained_spans + expect(spans.size).to eql 1 + span = spans.first + expect(span.name.value).to eql '/zoo/animals' + expect(span.status.code).to eql 0 + attrs = span.attributes + expect(attrs['http.host'].value).to eql 'www.googleapis.com' + expect(attrs['http.method'].value).to eql 'GET' + expect(attrs['http.path'].value).to eql '/zoo/animals' + expect(attrs['http.status_code']).to eql 200 + events = span.time_events + expect(events.size).to eql 2 + expect(events[0].type).to eql :SENT + expect(events[0].uncompressed_size).to eql 0 + expect(events[1].type).to eql :RECEIVED + expect(events[1].uncompressed_size).to eql 11 + end end - end - it 'should not create an opencensus span if disabled' do - stub_request(:get, 'https://www.googleapis.com/zoo/animals').to_return(status: [200, '']) - command = Google::Apis::Core::HttpCommand.new(:get, 'https://www.googleapis.com/zoo/animals') - command.options.use_opencensus = false - OpenCensus::Trace.start_request_trace do |span_context| - command.execute(client) - spans = span_context.build_contained_spans - expect(spans.size).to eql 0 + it 'should create an opencensus span for a call failure' do + stub_request(:get, 'https://www.googleapis.com/zoo/animals').to_return(status: [403, '']) + command = Google::Apis::Core::HttpCommand.new(:get, 'https://www.googleapis.com/zoo/animals') + OpenCensus::Trace.start_request_trace do |span_context| + expect { command.execute(client) }.to raise_error(Google::Apis::ClientError) + spans = span_context.build_contained_spans + expect(spans.size).to eql 1 + span = spans.first + expect(span.name.value).to eql '/zoo/animals' + expect(span.status.code).to eql 7 + attrs = span.attributes + expect(attrs['http.host'].value).to eql 'www.googleapis.com' + expect(attrs['http.method'].value).to eql 'GET' + expect(attrs['http.path'].value).to eql '/zoo/animals' + expect(attrs['http.status_code']).to eql 403 + end + end + + it 'should propagate trace context header' do + stub_request(:get, 'https://www.googleapis.com/zoo/animals').to_return(body: %(Hello world)) + command = Google::Apis::Core::HttpCommand.new(:get, 'https://www.googleapis.com/zoo/animals') + OpenCensus::Trace.start_request_trace do |_span_context| + result = command.execute(client) + expect(a_request(:get, 'https://www.googleapis.com/zoo/animals') + .with { |req| !req.headers['Traceparent'].empty? }).to have_been_made + end + end + + it 'should not create an opencensus span if disabled' do + stub_request(:get, 'https://www.googleapis.com/zoo/animals').to_return(status: [200, '']) + command = Google::Apis::Core::HttpCommand.new(:get, 'https://www.googleapis.com/zoo/animals') + command.options.use_opencensus = false + OpenCensus::Trace.start_request_trace do |span_context| + command.execute(client) + spans = span_context.build_contained_spans + expect(spans.size).to eql 0 + end end end - end if Google::Apis::Core::HttpCommand::OPENCENSUS_AVAILABLE + end it 'should send repeated query parameters' do stub_request(:get, 'https://www.googleapis.com/zoo/animals?a=1&a=2&a=3') .to_return(status: [200, '']) command = Google::Apis::Core::HttpCommand.new(:get, 'https://www.googleapis.com/zoo/animals') - command.query['a'] = [1,2,3] + command.query['a'] = [1, 2, 3] command.execute(client) end @@ -420,7 +424,7 @@ class SecretPayload stub_request(:get, 'https://www.googleapis.com/zoo/animals?a=1&a=2&a=3&foo=bar') .to_return(status: [200, '']) command = Google::Apis::Core::HttpCommand.new(:get, 'https://www.googleapis.com/zoo/animals?foo=bar') - command.query['a'] = [1,2,3] + command.query['a'] = [1, 2, 3] command.execute(client) end @@ -437,8 +441,8 @@ class SecretPayload stub_request(:get, 'https://www.googleapis.com/zoo/animals?a=2019-06-22T13:51:37-07:00&b=2019-06-22T13:51:37-07:00&b=2019-07-23T14:54:12-07:00') .to_return(status: [200, '']) command = Google::Apis::Core::HttpCommand.new(:get, 'https://www.googleapis.com/zoo/animals') - date1 = DateTime.new(2019, 6, 22, 13, 51, 37, "-0700") - date2 = DateTime.new(2019, 7, 23, 14, 54, 12, "-0700") + date1 = DateTime.new(2019, 6, 22, 13, 51, 37, '-0700') + date2 = DateTime.new(2019, 7, 23, 14, 54, 12, '-0700') command.query['a'] = date1 command.query['b'] = [date1, date2] command.execute(client) @@ -446,9 +450,9 @@ class SecretPayload it 'should form encode parameters when method is POST and no body present' do stub_request(:post, 'https://www.googleapis.com/zoo/animals?a=1&a=2&a=3&b=hello&c=true&d=0') - .to_return(status: [200, '']) + .to_return(status: [200, '']) command = Google::Apis::Core::HttpCommand.new(:post, 'https://www.googleapis.com/zoo/animals') - command.query['a'] = [1,2,3] + command.query['a'] = [1, 2, 3] command.query['b'] = 'hello' command.query['c'] = nil command.query['d'] = 0 @@ -460,7 +464,7 @@ class SecretPayload .to_return(status: [200, '']) command = Google::Apis::Core::HttpCommand.new(:get, 'https://www.googleapis.com/zoo/animals?foo=bar') command.options.query = { - 'a' => [1,2,3], + 'a' => [1, 2, 3], 'b' => false } command.execute(client) @@ -520,7 +524,7 @@ class SecretPayload command = Google::Apis::Core::HttpCommand.new(:get, 'https://www.googleapis.com/zoo/animals') command.options.retries = 0 expect { command.execute(client) }.to raise_error(Google::Apis::RequestTimeOutError) - end + end it 'should not normalize unicode values by default' do stub_request(:get, 'https://www.googleapis.com/Cafe%CC%81').to_return(status: [200, '']) @@ -543,83 +547,205 @@ class SecretPayload it 'should set X-Goog-Api-Version headers when requested' do command = Google::Apis::Core::HttpCommand.new(:get, 'https://www.googleapis.com/zoo/animals') - command.set_api_version_header "v1_20240502" + command.set_api_version_header 'v1_20240502' stub_request(:get, 'https://www.googleapis.com/zoo/animals').to_return(body: %(Api version)) result = command.execute(client) expect(a_request(:get, 'https://www.googleapis.com/zoo/animals') .with { |req| req.headers['X-Goog-Api-Version'] == 'v1_20240502' }).to have_been_made - expect(result).to eql "Api version" + expect(result).to eql 'Api version' end - describe "#safe_pretty_representation" do + describe '#safe_pretty_representation' do let(:command) do Google::Apis::Core::HttpCommand.new(:get, 'https://www.googleapis.com/zoo/animals') end - it "should show fields in a normal object" do + it 'should show fields in a normal object' do obj = Google::Apis::SecretmanagerV1::AccessSecretVersionResponse.new - obj.instance_variable_set(:@payload, "hello") + obj.instance_variable_set(:@payload, 'hello') str = command.send(:safe_pretty_representation, obj) - expect(str).to include("@payload") - expect(str).not_to include("(fields redacted)") + expect(str).to include('@payload') + expect(str).not_to include('(fields redacted)') expect(str).to include("\n") end - it "should not show fields in a restricted object" do + it 'should not show fields in a restricted object' do obj = Google::Apis::SecretmanagerV1::SecretPayload.new - obj.instance_variable_set(:@payload, "hello") + obj.instance_variable_set(:@payload, 'hello') str = command.send(:safe_pretty_representation, obj) - expect(str).not_to include("@payload") - expect(str).to include("(fields redacted)") + expect(str).not_to include('@payload') + expect(str).to include('(fields redacted)') expect(str).to include("\n") end - it "should not show fields in a nested restricted object" do + it 'should not show fields in a nested restricted object' do obj = Google::Apis::SecretmanagerV1::AccessSecretVersionResponse.new payload = Google::Apis::SecretmanagerV1::SecretPayload.new - payload.instance_variable_set(:@data, "whoops") + payload.instance_variable_set(:@data, 'whoops') obj.instance_variable_set(:@payload, payload) str = command.send(:safe_pretty_representation, obj) - expect(str).to include("@payload") - expect(str).not_to include("@data") - expect(str).to include("(fields redacted)") + expect(str).to include('@payload') + expect(str).not_to include('@data') + expect(str).to include('(fields redacted)') expect(str).to include("\n") end end - describe "#safe_single_line_representation" do + describe '#safe_single_line_representation' do let(:command) do Google::Apis::Core::HttpCommand.new(:get, 'https://www.googleapis.com/zoo/animals') end - it "should show fields in a normal object" do + it 'should show fields in a normal object' do obj = Google::Apis::SecretmanagerV1::AccessSecretVersionResponse.new - obj.instance_variable_set(:@payload, "hello") + obj.instance_variable_set(:@payload, 'hello') str = command.send(:safe_single_line_representation, obj) - expect(str).to include("@payload") - expect(str).not_to include("(fields redacted)") + expect(str).to include('@payload') + expect(str).not_to include('(fields redacted)') expect(str).not_to include("\n") end - it "should not show fields in a restricted object" do + it 'should not show fields in a restricted object' do obj = Google::Apis::SecretmanagerV1::SecretPayload.new - obj.instance_variable_set(:@payload, "hello") + obj.instance_variable_set(:@payload, 'hello') str = command.send(:safe_single_line_representation, obj) - expect(str).not_to include("@payload") - expect(str).to include("(fields redacted)") + expect(str).not_to include('@payload') + expect(str).to include('(fields redacted)') expect(str).not_to include("\n") end - it "should not show fields in a nested restricted object" do + it 'should not show fields in a nested restricted object' do obj = Google::Apis::SecretmanagerV1::AccessSecretVersionResponse.new payload = Google::Apis::SecretmanagerV1::SecretPayload.new - payload.instance_variable_set(:@data, "whoops") + payload.instance_variable_set(:@data, 'whoops') obj.instance_variable_set(:@payload, payload) str = command.send(:safe_single_line_representation, obj) - expect(str).to include("@payload") - expect(str).not_to include("@data") - expect(str).to include("(fields redacted)") + expect(str).to include('@payload') + expect(str).not_to include('@data') + expect(str).to include('(fields redacted)') expect(str).not_to include("\n") end end + + describe 'path parameter validation' do + context 'with simple path variables' do + # Source template specification: + # Pattern: v1/projects/{project}/locations/{location}/webhooks/{webhook} + # Variable constraints (RFC 6570 simple expansion): + # - Must not contain slashes (cannot span path segments) + # - Must not contain traversal segments ('.', '..') + # - Must not contain query/fragment injections ('?', '#') + let(:template) do + Addressable::Template.new( + 'https://www.googleapis.com/v1/projects/{project}/locations/{location}/webhooks/{webhook}' + ) + end + + it 'should allow safe parameter values' do + stub_request(:get, 'https://www.googleapis.com/v1/projects/sys-prod-123/locations/us-central1/webhooks/billing-webhook') + .to_return(status: [200, '']) + command = Google::Apis::Core::HttpCommand.new(:get, template) + command.params[:project] = 'sys-prod-123' + command.params[:location] = 'us-central1' + command.params[:webhook] = 'billing-webhook' + command.options.retries = 0 + expect { command.execute(client) }.not_to raise_error + end + + it 'should reject slashes in simple parameters' do + command = Google::Apis::Core::HttpCommand.new(:get, template) + command.params[:project] = 'sys-prod-123' + command.params[:location] = 'us-central1' + command.params[:webhook] = 'parent/child-webhook' + command.options.retries = 0 + expect { command.execute(client) }.to raise_error(Google::Apis::Error, /cannot contain slashes/) + end + + it 'should reject dot or double-dot in simple parameters' do + command = Google::Apis::Core::HttpCommand.new(:get, template) + command.params[:project] = 'sys-prod-123' + command.params[:location] = 'us-central1' + command.params[:webhook] = '..' + command.options.retries = 0 + expect { command.execute(client) }.to raise_error(Google::Apis::Error, /cannot be '\.' or '\.\.'/) + end + + it 'should reject query parameter injections' do + command = Google::Apis::Core::HttpCommand.new(:get, template) + command.params[:project] = 'sys-prod-123' + command.params[:location] = 'us-central1' + command.params[:webhook] = 'billing-webhook?key=val' + command.options.retries = 0 + expect { command.execute(client) }.to raise_error(Google::Apis::Error, /contains invalid characters/) + end + + it 'should reject fragment parameter injections' do + command = Google::Apis::Core::HttpCommand.new(:get, template) + command.params[:project] = 'sys-prod-123' + command.params[:location] = 'us-central1' + command.params[:webhook] = 'billing-webhook#fragment' + command.options.retries = 0 + expect { command.execute(client) }.to raise_error(Google::Apis::Error, /contains invalid characters/) + end + end + + context 'with reserved path variables' do + # Source template specification: + # Pattern: v1/{+parent}/indexes + # Variable constraints (RFC 6570 reserved expansion): + # - Allows slashes (multiple path segments) + # - Relative traversals within the segment boundary (e.g. 'a/b/../c') are allowed + # - Traversals escaping the left parameter boundary (e.g. '../escape') are blocked + # - Invalid structures like double-slashes '//' or single dots '.' are blocked + # - Query/fragment injections ('?', '#') are blocked + let(:template) { Addressable::Template.new('https://www.googleapis.com/v1/{+parent}/indexes') } + + it 'should allow safe double wildcard paths' do + stub_request(:get, 'https://www.googleapis.com/v1/projects/sys-prod-123/databases/default/documents/doc-1/indexes') + .to_return(status: [200, '']) + command = Google::Apis::Core::HttpCommand.new(:get, template) + command.params[:parent] = 'projects/sys-prod-123/databases/default/documents/doc-1' + command.options.retries = 0 + expect { command.execute(client) }.not_to raise_error + end + + it 'should allow safe relative traversals inside the wildcard' do + stub_request(:get, 'https://www.googleapis.com/v1/projects/sys-prod-123/databases/default/documents/doc-1/../../default/indexes') + .to_return(status: [200, '']) + command = Google::Apis::Core::HttpCommand.new(:get, template) + command.params[:parent] = 'projects/sys-prod-123/databases/default/documents/doc-1/../../default' + command.options.retries = 0 + expect { command.execute(client) }.not_to raise_error + end + + it 'should reject traversals escaping the left parameter boundary' do + command = Google::Apis::Core::HttpCommand.new(:get, template) + command.params[:parent] = + 'projects/sys-prod-123/databases/default/documents/doc-1/../../../../../../../escape-db' + command.options.retries = 0 + expect { command.execute(client) }.to raise_error(Google::Apis::Error, /Path traversal escape detected/) + end + + it 'should reject parameter values starting with parent directory traversals' do + command = Google::Apis::Core::HttpCommand.new(:get, template) + command.params[:parent] = '../escape-db' + command.options.retries = 0 + expect { command.execute(client) }.to raise_error(Google::Apis::Error, /Path traversal escape detected/) + end + + it 'should reject invalid segments like single dot' do + command = Google::Apis::Core::HttpCommand.new(:get, template) + command.params[:parent] = 'projects/sys-prod-123/./databases/default' + command.options.retries = 0 + expect { command.execute(client) }.to raise_error(Google::Apis::Error, /Invalid path segment/) + end + + it 'should reject invalid empty segments' do + command = Google::Apis::Core::HttpCommand.new(:get, template) + command.params[:parent] = 'projects/sys-prod-123//databases/default' + command.options.retries = 0 + expect { command.execute(client) }.to raise_error(Google::Apis::Error, /Invalid path segment/) + end + end + end end