From 0a4675584cf369f93d3e46cc32c7b71930a3eecb Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Mon, 27 Jul 2026 20:03:30 +0000 Subject: [PATCH] feat(core): implement path validation and traversal checks in HttpCommand Implement dynamic runtime template parsing and validation inside HttpCommand to protect Discovery REST clients from path traversal and parameter injection exploits. Specifically, this change: - Rejects query (?) and fragment (#) characters in path parameter values. - Rejects slashes (/) and dots (. or ..) in simple path variables (standard wildcards). - Validates path traversals in reserved path variables (+ or #, double wildcards) using a segment-boundary traversal validation algorithm. In addition to the validation logic, this commit includes: - Ruby style modernization: cleaned up and auto-corrected string quote style to favor single quotes where appropriate. - Formatted long stub requests to follow style guidelines. Note: If the canonical specification is updated to favor the simpler alternative, this implementation can be easily simplified to "Fail always if dots are found in a double-wildcard value" by replacing the segment-traversal logic with a simple check for any "." or ".." substring. --- .../lib/google/apis/core/http_command.rb | 126 +++++-- .../google/apis/core/http_command_spec.rb | 336 ++++++++++++------ 2 files changed, 318 insertions(+), 144 deletions(-) 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