diff --git a/gapic-common/lib/gapic/rest/grpc_transcoder.rb b/gapic-common/lib/gapic/rest/grpc_transcoder.rb index e5ef0ab..32d5d1d 100644 --- a/gapic-common/lib/gapic/rest/grpc_transcoder.rb +++ b/gapic-common/lib/gapic/rest/grpc_transcoder.rb @@ -111,12 +111,14 @@ 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 @@ -124,6 +126,46 @@ def bind_uri_values! http_binding, request_hash 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 + 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 .." + 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. diff --git a/gapic-common/test/gapic/rest/grpc_transcoder_test.rb b/gapic-common/test/gapic/rest/grpc_transcoder_test.rb index b97f089..0721891 100644 --- a/gapic-common/test/gapic/rest/grpc_transcoder_test.rb +++ b/gapic-common/test/gapic/rest/grpc_transcoder_test.rb @@ -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