From 73681e6ea3bcb386e700cee96a8a1b64f443fa98 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Mon, 27 Jul 2026 20:03:30 +0000 Subject: [PATCH 1/8] 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 From c84404dd2566df5ecb0916be666b8e140a0dc13b Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Mon, 3 Aug 2026 17:50:04 +0000 Subject: [PATCH 2/8] fix(core): simplify path validation to fail always on dots in double-wildcard --- .../lib/google/apis/core/http_command.rb | 16 ++++------------ .../spec/google/apis/core/http_command_spec.rb | 12 +++++------- 2 files changed, 9 insertions(+), 19 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 17fbe87b362..bee616ed11c 100644 --- a/google-apis-core/lib/google/apis/core/http_command.rb +++ b/google-apis-core/lib/google/apis/core/http_command.rb @@ -507,20 +507,12 @@ def validate_path_parameters! 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 + if %w[. ..].include?(seg) + raise Google::Apis::Error, + "Path traversal segment #{seg.inspect} is not allowed in parameter #{var_name}: #{value}" end + raise Google::Apis::Error, "Invalid path segment '' in parameter #{var_name}" if seg == '' end else if value.include?('/') 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 058439d2b32..6e9d87150f8 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 @@ -709,13 +709,11 @@ class SecretPayload 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, '']) + it 'should reject relative traversals inside the wildcard' do 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 + expect { command.execute(client) }.to raise_error(Google::Apis::Error, /is not allowed/) end it 'should reject traversals escaping the left parameter boundary' do @@ -723,21 +721,21 @@ class SecretPayload 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/) + expect { command.execute(client) }.to raise_error(Google::Apis::Error, /is not allowed/) 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/) + expect { command.execute(client) }.to raise_error(Google::Apis::Error, /is not allowed/) 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/) + expect { command.execute(client) }.to raise_error(Google::Apis::Error, /is not allowed/) end it 'should reject invalid empty segments' do From 38e5c18e81ee0b08a49e5497a3fd5ddc800cbec7 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 22:46:53 +0000 Subject: [PATCH 3/8] docs: improve validate_path_parameters! documentation in HttpCommand --- .../lib/google/apis/core/http_command.rb | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 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 bee616ed11c..691e0b37377 100644 --- a/google-apis-core/lib/google/apis/core/http_command.rb +++ b/google-apis-core/lib/google/apis/core/http_command.rb @@ -481,8 +481,20 @@ def normalize_query_value(v) end end - # Validate path parameters against template to prevent path traversal and injection exploits - # @raise [Google::Apis::Error] if validation fails + # Validates user-supplied path parameter values against the URL template specification + # to prevent directory traversal and parameter injection exploits. + # + # Validation Mechanism: + # 1. Identifies query/fragment injections: Rejects values containing '?' or '#' characters. + # 2. For simple variables (standard single-wildcard behavior): + # - Rejects if the value contains '/' (cannot span multiple path segments). + # - Rejects if the value is exactly '.' or '..'. + # 3. For reserved variables (reserved expansion like '+' or '#', double-wildcard behavior): + # - Splits the value by slash ('/') using a `-1` limit to preserve empty trailing segments. + # - Rejects if any segment is a directory traversal segment ('.' or '..'). + # - Rejects empty segments ('', meaning duplicate slashes '//' or trailing slashes). + # + # @raise [Google::Apis::Error] If any validation check fails. def validate_path_parameters! template_pattern = url.pattern From ec1d995ccebad6b460ad99762f2a981097696643 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 23:37:41 +0000 Subject: [PATCH 4/8] docs: add @raise tags to execute and prepare! methods in HttpCommand --- google-apis-core/lib/google/apis/core/http_command.rb | 2 ++ 1 file changed, 2 insertions(+) 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 691e0b37377..9d9ca48c541 100644 --- a/google-apis-core/lib/google/apis/core/http_command.rb +++ b/google-apis-core/lib/google/apis/core/http_command.rb @@ -104,6 +104,7 @@ def initialize(method, url, body: nil) # @raise [Google::Apis::ServerError] An error occurred on the server and the request can be retried # @raise [Google::Apis::ClientError] The request is invalid and should not be retried without modification # @raise [Google::Apis::AuthorizationError] Authorization is required + # @raise [Google::Apis::Error] If path parameter validation fails. def execute(client, &block) prepare! opencensus_begin_span @@ -162,6 +163,7 @@ def authorization_refreshable? # # @private # @return [void] + # @raise [Google::Apis::Error] If path parameter validation fails. def prepare! normalize_unicode = true if options From 08bbbead13057614d07a6916241f695aecb97125 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 23:41:53 +0000 Subject: [PATCH 5/8] refactor(core): extract template scan regex to TEMPLATE_VAR_PATTERN constant --- google-apis-core/lib/google/apis/core/http_command.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 9d9ca48c541..8fc3f7b3e03 100644 --- a/google-apis-core/lib/google/apis/core/http_command.rb +++ b/google-apis-core/lib/google/apis/core/http_command.rb @@ -360,6 +360,12 @@ def set_api_version_header(api_version) 'Google::Apis::SecretmanagerV1beta1::SecretPayload' ].freeze + # Pattern to scan for RFC 6570 URI template expressions enclosed in curly braces {...}. + # Captures: + # 1. An optional expansion operator prefix (one of: +, #, ., /, ;, ?, &). + # 2. A comma-separated list of variable definitions containing their names and optional modifiers. + TEMPLATE_VAR_PATTERN = /\{([\+#\.\/;\?&])?([^}]+)\}/.freeze + module RedactingPPMethods def pp_object(obj) return super unless UNSAFE_CLASS_NAMES.include? obj.class.name @@ -502,7 +508,7 @@ def validate_path_parameters! # Parse variables and operators variables = [] - template_pattern.scan(/\{([\+#\.\/;\?&])?([^}]+)\}/) do |operator, var_list| + template_pattern.scan(TEMPLATE_VAR_PATTERN) 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 == '#') } From f201209b52dbedb5009d520008c94cd2a106cfe5 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 23:45:19 +0000 Subject: [PATCH 6/8] refactor(core): unify dot check construct in HttpCommand --- google-apis-core/lib/google/apis/core/http_command.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8fc3f7b3e03..b1e8a6cd8e2 100644 --- a/google-apis-core/lib/google/apis/core/http_command.rb +++ b/google-apis-core/lib/google/apis/core/http_command.rb @@ -528,7 +528,7 @@ def validate_path_parameters! if v[:reserved] value_segments = value.split('/', -1) value_segments.each do |seg| - if %w[. ..].include?(seg) + if seg == '.' || seg == '..' raise Google::Apis::Error, "Path traversal segment #{seg.inspect} is not allowed in parameter #{var_name}: #{value}" end From e51a367806c0890e3588005e4ca82e250e664947 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 23:46:43 +0000 Subject: [PATCH 7/8] refactor(core): make dot validation error messages consistent in HttpCommand --- google-apis-core/lib/google/apis/core/http_command.rb | 3 ++- google-apis-core/spec/google/apis/core/http_command_spec.rb | 2 +- 2 files changed, 3 insertions(+), 2 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 b1e8a6cd8e2..48f90a77ecb 100644 --- a/google-apis-core/lib/google/apis/core/http_command.rb +++ b/google-apis-core/lib/google/apis/core/http_command.rb @@ -539,7 +539,8 @@ def validate_path_parameters! 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}" + raise Google::Apis::Error, + "Path traversal segment #{value.inspect} is not allowed in parameter #{var_name}: #{value}" 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 6e9d87150f8..fa9d34d5fa5 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 @@ -667,7 +667,7 @@ class SecretPayload 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 '\.\.'/) + expect { command.execute(client) }.to raise_error(Google::Apis::Error, /is not allowed/) end it 'should reject query parameter injections' do From 649445e2bb7d351cbe3f9c6258664d69f2434c04 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 23:51:29 +0000 Subject: [PATCH 8/8] docs(core): refine TEMPLATE_VAR_PATTERN comments in HttpCommand --- .../lib/google/apis/core/http_command.rb | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 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 48f90a77ecb..49893c5ad9b 100644 --- a/google-apis-core/lib/google/apis/core/http_command.rb +++ b/google-apis-core/lib/google/apis/core/http_command.rb @@ -361,9 +361,17 @@ def set_api_version_header(api_version) ].freeze # Pattern to scan for RFC 6570 URI template expressions enclosed in curly braces {...}. - # Captures: - # 1. An optional expansion operator prefix (one of: +, #, ., /, ;, ?, &). - # 2. A comma-separated list of variable definitions containing their names and optional modifiers. + # + # Regex Mechanics: + # - Matches literal '{' and '}' characters. + # - Capture Group 1: Captures a single prefix operator character if present (one of: +, #, ., /, ;, ?, &). + # - Capture Group 2: Captures all characters up to the closing brace ('[^}]+'). + # + # Expected Capture Format: + # Capture Group 2 is expected to contain a raw string block representing one or more variable + # definitions (e.g. "var1" or "var1,var2"). This string block may contain commas, variable names, + # prefix length constraints (e.g. "var1:5"), or explode modifiers (e.g. "var1*"). The commas are + # split at the code level during traversal. TEMPLATE_VAR_PATTERN = /\{([\+#\.\/;\?&])?([^}]+)\}/.freeze module RedactingPPMethods