Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 115 additions & 39 deletions google-apis-core/lib/google/apis/core/http_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
require 'google/apis/options'
require 'google/apis/errors'
require 'retriable'
require 'cgi'
require 'google/apis/core/faraday_integration'
require 'google/apis/core/logging'
require 'pp'
Expand All @@ -31,7 +32,7 @@ class HttpCommand
RETRIABLE_ERRORS = [Google::Apis::ServerError,
Google::Apis::RateLimitError,
Google::Apis::TransmissionError,
Google::Apis::RequestTimeOutError]
Google::Apis::RequestTimeOutError].freeze

begin
require 'opencensus'
Expand Down Expand Up @@ -83,15 +84,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 =({})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious whether this was a Rubocop fix or was there a technical detail about instantiating a Hash one way vs the other?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rubocop!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we fix the missing space? FWIW, I thinkself.header = {} is sufficient.

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

Expand All @@ -104,6 +105,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
Expand All @@ -114,8 +116,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,
Expand All @@ -130,19 +132,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
Expand All @@ -164,13 +164,15 @@ def authorization_refreshable?
#
# @private
# @return [void]
# @raise [Google::Apis::Error] If path parameter validation fails.
def prepare!
normalize_unicode = true
if options
header.update(options.header) if options.header
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 || {})

Expand All @@ -182,17 +184,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
#
Expand Down Expand Up @@ -298,7 +299,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.
Expand All @@ -323,7 +324,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
Expand All @@ -343,28 +344,43 @@ 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

# Pattern to scan for RFC 6570 URI template expressions enclosed in curly braces {...}.
#
# 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
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
Expand All @@ -377,16 +393,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
Expand All @@ -400,18 +416,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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super nit: I actually like the original pattern (more explicit/readable) FWIW


formatter = OpenCensus::Trace.config.http_formatter
if formatter.respond_to? :header_name
Expand All @@ -436,7 +450,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

Expand All @@ -452,7 +466,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
Expand All @@ -468,9 +482,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

Expand All @@ -484,6 +497,69 @@ def normalize_query_value(v)
v.to_s
end
end

# 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. URL-decodes the parameter value to ensure all encoded dot (`%2e` / `%2E`) and slash
# (`%2f` / `%2F`) segments are expanded before segment checks.
# 3. For simple variables (standard single-wildcard behavior):
# - Rejects if the value contains '/' (cannot span multiple path segments).
# - Rejects if the value is exactly '.' or '..'.
# 4. For reserved variables (reserved expansion like '+' or '#', double-wildcard behavior):
# - Splits the decoded value by slash ('/') using a `-1` limit to preserve empty 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

# Parse variables and operators
variables = []
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 == '#') }
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

unescaped_value = CGI.unescape value

if v[:reserved]
value_segments = unescaped_value.split('/', -1)
value_segments.each do |seg|
if seg == '.' || seg == '..'
raise Google::Apis::Error,
"Value for #{var_name} must not contain segments that are exactly . or .."
end
raise Google::Apis::Error, "Invalid path segment '' in parameter #{var_name}" if seg == ''

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invalid path segment '' in parameter #{var_name} doesn't include #{value} and the literal '' can be a bit cryptic.
WDYT about making it clearer:

raise Google::Apis::Error, "Empty path segment (e.g. '//' or trailing '/') is not allowed in parameter #{var_name}: #{value}" if seg == ''

end
else
if unescaped_value.include?('/')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kinda an analog question to the .NET discovery lib work; this check isn't on the .NET side, do we need to skirt parity here?

raise Google::Apis::Error, "Simple parameter #{var_name} cannot contain slashes: #{value}"
end

if unescaped_value == '.' || unescaped_value == '..'
raise Google::Apis::Error,
"Invalid value #{unescaped_value} for #{var_name}"
end
end
end
end
end
end
end
Expand Down
Loading
Loading