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
39 changes: 39 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,58 @@ 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 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. Splits the parameter value by slash (`/`) using `-1` limit to preserve all segments.
# 2. Checks each segment. If any segment matches `.` or `..`, it immediately raises
# a `Gapic::Common::Error`, aborting the request.
# 3. Empty segments (e.g. duplicate slashes `//` or trailing slashes `/`) are allowed
# by this validator 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
segments = field_value.split("/", -1)
segments.each do |segment|
next unless [".", ".."].include? segment
if field_binding.preserve_slashes
raise ::Gapic::Common::Error,
"Value for #{field_binding.field_path} must not contain segments that are exactly . or .."
Comment thread
quartzmo marked this conversation as resolved.
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
124 changes: 124 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,130 @@ 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 be percent-escaped
_uri_method, uri, _query_params, _body =
transcoder_std.transcode example_request(name: "p1", sub_name: "%2e%2e")
assert_equal "/v3/projects/p1/webhooks/%252e%252e", uri

# URL-encoded traversal segment '%2e' in standard parameter should be percent-escaped
_uri_method, uri, _query_params, _body =
transcoder_std.transcode example_request(name: "p1", sub_name: "%2e")
assert_equal "/v3/projects/p1/webhooks/%252e", uri

# 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 be percent-escaped
_uri_method, uri, _query_params, _body =
transcoder_wild.transcode example_request(name: "projects/p/databases/d/documents/doc/%2e%2e/doc2", sub_name: "col")
assert_equal "/v1/projects/p/databases/d/documents/doc/%252e%252e/doc2/col", uri

# URL-encoded segment '%2e' in path parameter should be percent-escaped
_uri_method, uri, _query_params, _body =
transcoder_wild.transcode example_request(name: "projects/p/databases/d/documents/doc/%2e/a", sub_name: "col")
assert_equal "/v1/projects/p/databases/d/documents/doc/%252e/a/col", uri

# URL-encoded traversal slashes '..%2f..%2f' in path parameter should be percent-escaped
_uri_method, uri, _query_params, _body =
transcoder_wild.transcode example_request(name: "projects/p/databases/d/documents/doc/..%2f..%2fescape-db", sub_name: "col")
assert_equal "/v1/projects/p/databases/d/documents/doc/..%252f..%252fescape-db/col", uri

# Mixed URL-encoded dots and slashes '%2e%2e%2f%2e%2e%2f' in path parameter should be percent-escaped
_uri_method, uri, _query_params, _body =
transcoder_wild.transcode example_request(name: "projects/p/databases/d/documents/doc/%2e%2e%2f%2e%2e%2fescape-db", sub_name: "col")
assert_equal "/v1/projects/p/databases/d/documents/doc/%252e%252e%252f%252e%252e%252fescape-db/col", uri
end

private

def assert_transcoding_matches transcoder, test_cases
Expand Down
Loading