Skip to content
Open
42 changes: 42 additions & 0 deletions gapic-common/lib/gapic/rest/grpc_transcoder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,19 +111,61 @@ def transcode request
# @return [Hash{String, String}]
# Name to value hash of the variables for the uri template expansion.
# The values are percent-escaped with slashes potentially preserved.
# @raise [Gapic::Common::Error] If any parameter value fails path traversal or injection validation.
def bind_uri_values! http_binding, request_hash
http_binding.field_bindings.to_h do |field_binding|
field_path_camel = field_binding.field_path.split(".").map { |part| camel_name_for part }.join(".")
field_value = extract_scalar_value! request_hash, field_path_camel, field_binding.regex

if field_value
validate_field_binding! field_binding, field_value
field_value = field_value.split("/").map { |segment| percent_escape segment }.join("/")
end

[field_binding.field_path, field_value]
end
end

# Validates a user-supplied parameter value bound to a standard (*) or path (**) URI template variable
# to prevent directory traversal and parameter injection exploits.
#
# @param field_binding [HttpBinding::FieldBinding] The field binding template metadata.
# @param field_value [String] The parameter value to validate.
# @raise [Gapic::Common::Error] If validation fails.
def validate_field_binding! field_binding, field_value
validate_path_binding! field_binding, field_value

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: we could just call these methods independently on L121 and L122 instead of wrapping validate_path_binding!.

end

# Validates standard (*) and path (**) parameters by ensuring that no segment in the parameter
# value is a directory traversal segment (. or ..).
#
# Validation Mechanism:
# 1. URL-decodes the parameter value to ensure all encoded dot (`%2e` / `%2E`)
# and slash (`%2f` / `%2F`) segments are expanded.
# 2. Splits the decoded parameter value by slash (`/`) using `-1` limit to preserve all segments.
# 3. Checks each segment. If any segment matches `.` or `..`, it immediately raises
# a `Gapic::Common::Error`, aborting the request.
# 4. Empty segments (e.g. duplicate slashes `//` or trailing slashes `/`) are allowed
# by this linter and passed to the server, which handles normalization or returns 400.
#
# @param field_binding [HttpBinding::FieldBinding] The field binding template metadata.
# @param field_value [String] The parameter value to validate.
# @raise [Gapic::Common::Error] If validation fails.
def validate_path_binding! field_binding, field_value
unescaped_value = CGI.unescape field_value
segments = unescaped_value.split("/", -1)
segments.each do |segment|
next unless segment == "." || segment == ".."
if field_binding.preserve_slashes
raise ::Gapic::Common::Error,
"Value for #{field_binding.field_path} must not contain segments that are exactly . or .."

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

NIT: separate these by . and .. cases

else
raise ::Gapic::Common::Error,
"Invalid value #{segment} for #{field_binding.field_path}"
end
end
end

# Percent-escapes a string.
# @param str [String] String to escape.
# @return [String] Escaped string.
Expand Down
130 changes: 130 additions & 0 deletions gapic-common/test/gapic/rest/grpc_transcoder_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,136 @@ def test_last_one_wins
assert_transcoding_matches transcoder, test_cases
end

def test_transcode_validation_parameter_injection
# 1. Parameter Injection (Rejection check)
# Proto: post: "/v3/{name=projects/*/locations/*/agents/*/sessions/*}:detectIntent"
# Template: v3/{name}:detectIntent (representing Dialogflow session method)
transcoder_inj = Gapic::Rest::GrpcTranscoder.new.with_bindings(
uri_method: :post,
uri_template: "/v3/{name}:detectIntent",
matches: [["name", %r{^projects/[^/]+/locations/[^/]+/agents/[^/]+/sessions/[^/]+$}, false]]
)

# Valid payload should pass
transcoder_inj.transcode example_request(name: "projects/p/locations/l/agents/a/sessions/s1")

# Payload with query injection should succeed and escape the ? character
_uri_method, uri, _query_params, _body =
transcoder_inj.transcode example_request(name: "projects/p/locations/l/agents/a/sessions/s1?key=val")
assert_equal "/v3/projects/p/locations/l/agents/a/sessions/s1%3Fkey%3Dval:detectIntent", uri

# Payload with fragment injection should succeed and escape the # character
_uri_method, uri, _query_params, _body =
transcoder_inj.transcode example_request(name: "projects/p/locations/l/agents/a/sessions/s1#frag")
assert_equal "/v3/projects/p/locations/l/agents/a/sessions/s1%23frag:detectIntent", uri
end

def test_transcode_validation_standard_wildcard
# 2. Standard Single-Wildcard Matchers (*)
# Proto: delete: "/v3/projects/{name}/webhooks/{sub_request.name}"
# Template: v3/projects/{name}/webhooks/{sub_request.name}
transcoder_std = Gapic::Rest::GrpcTranscoder.new.with_bindings(
uri_method: :delete,
uri_template: "/v3/projects/{name}/webhooks/{sub_request.name}",
matches: [
["name", %r{^[^/]+$}, false],
["sub_request.name", %r{^[^/]+$}, false]
]
)

# Valid payload should pass
transcoder_std.transcode example_request(name: "p1", sub_name: "w1")

# Traversal segment '..' in standard parameter should fail
err = assert_raises ::Gapic::Common::Error do
transcoder_std.transcode example_request(name: "p1", sub_name: "..")
end
assert_equal "Invalid value .. for sub_request.name", err.message

# Traversal segment '.' in standard parameter should fail
err = assert_raises ::Gapic::Common::Error do
transcoder_std.transcode example_request(name: "p1", sub_name: ".")
end
assert_equal "Invalid value . for sub_request.name", err.message

# URL-encoded traversal segment '%2e%2e' in standard parameter should fail
err = assert_raises ::Gapic::Common::Error do
transcoder_std.transcode example_request(name: "p1", sub_name: "%2e%2e")
end
assert_equal "Invalid value .. for sub_request.name", err.message

# URL-encoded traversal segment '%2e' in standard parameter should fail
err = assert_raises ::Gapic::Common::Error do
transcoder_std.transcode example_request(name: "p1", sub_name: "%2e")
end
assert_equal "Invalid value . for sub_request.name", err.message

# Slashes in standard parameter should fail matching (regex rejects slashes)
err = assert_raises ::Gapic::Common::Error do
transcoder_std.transcode example_request(name: "p1", sub_name: "w1/w2")
end
assert err.message.include?("does not match any transcoding template")
end

def test_transcode_validation_path_wildcard
# 3. Path/Double-Wildcard Matchers (**)
# Proto: post: "/v1/{name=projects/*/databases/*/documents/*/**}/{sub_request.name}"
# Template: v1/{name}/{sub_request.name}
transcoder_wild = Gapic::Rest::GrpcTranscoder.new.with_bindings(
uri_method: :post,
uri_template: "/v1/{name}/{sub_request.name}",
matches: [
["name", %r{^projects/[^/]+/databases/[^/]+/documents/[^/]+(?:/(?<__wildcard__>.*))?$}, true],
["sub_request.name", %r{^[^/]+$}, false]
]
)

# Valid path should pass
transcoder_wild.transcode example_request(name: "projects/p/databases/d/documents/doc/a/b/c", sub_name: "col")

# Segment '..' anywhere in parameter should fail
err = assert_raises ::Gapic::Common::Error do
transcoder_wild.transcode example_request(name: "projects/p/databases/d/documents/doc/../../../../doc2", sub_name: "col")
end
assert_equal "Value for name must not contain segments that are exactly . or ..", err.message

# Segment '.' anywhere in parameter should fail
err = assert_raises ::Gapic::Common::Error do
transcoder_wild.transcode example_request(name: "projects/p/databases/d/documents/doc/./a", sub_name: "col")
end
assert_equal "Value for name must not contain segments that are exactly . or ..", err.message

# Prefix traversal in the parameter should fail
err = assert_raises ::Gapic::Common::Error do
transcoder_wild.transcode example_request(name: "projects/p/databases/../documents/doc/a/b", sub_name: "col")
end
assert_equal "Value for name must not contain segments that are exactly . or ..", err.message

# URL-encoded segment '%2e%2e' in path parameter should fail
err = assert_raises ::Gapic::Common::Error do
transcoder_wild.transcode example_request(name: "projects/p/databases/d/documents/doc/%2e%2e/doc2", sub_name: "col")
end
assert_equal "Value for name must not contain segments that are exactly . or ..", err.message

# URL-encoded segment '%2e' in path parameter should fail
err = assert_raises ::Gapic::Common::Error do
transcoder_wild.transcode example_request(name: "projects/p/databases/d/documents/doc/%2e/a", sub_name: "col")
end
assert_equal "Value for name must not contain segments that are exactly . or ..", err.message

# URL-encoded traversal slashes '..%2f..%2f' in path parameter should fail
err = assert_raises ::Gapic::Common::Error do
transcoder_wild.transcode example_request(name: "projects/p/databases/d/documents/doc/..%2f..%2fescape-db", sub_name: "col")
end
assert_equal "Value for name must not contain segments that are exactly . or ..", err.message

# Mixed URL-encoded dots and slashes '%2e%2e%2f%2e%2e%2f' in path parameter should fail
err = assert_raises ::Gapic::Common::Error do
transcoder_wild.transcode example_request(name: "projects/p/databases/d/documents/doc/%2e%2e%2f%2e%2e%2fescape-db", sub_name: "col")
end
assert_equal "Value for name must not contain segments that are exactly . or ..", err.message
end

private

def assert_transcoding_matches transcoder, test_cases
Expand Down
Loading