From 75929fa7fb393a76dbe2384551cf7c9e80f55753 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Mon, 27 Jul 2026 16:03:18 +0000 Subject: [PATCH 1/8] fix: validate path parameters and prevent traversal/injection in REST transcoder Implement path parameter validation in GrpcTranscoder#bind_uri_values! to protect GAPIC 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 standard single-wildcard (*) variables. - Validates path traversals in double-wildcard (**) variables using the segment-traversal validation algorithm. - Extracts the wildcard segment using the __wildcard__ named capture group, with on-the-fly regex patching fallback for legacy stubs. - Validates prefix segments to prevent prefix buffering bypasses. 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/gapic/rest/grpc_transcoder.rb | 146 ++++++++++++++ .../test/gapic/rest/grpc_transcoder_test.rb | 179 ++++++++++++++++++ 2 files changed, 325 insertions(+) diff --git a/gapic-common/lib/gapic/rest/grpc_transcoder.rb b/gapic-common/lib/gapic/rest/grpc_transcoder.rb index e5ef0ab..a923546 100644 --- a/gapic-common/lib/gapic/rest/grpc_transcoder.rb +++ b/gapic-common/lib/gapic/rest/grpc_transcoder.rb @@ -117,6 +117,7 @@ def bind_uri_values! http_binding, request_hash 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 +125,151 @@ def bind_uri_values! http_binding, request_hash end end + # Validates a single path or standard field binding value against 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 + if field_value.include?("?") || field_value.include?("#") + raise ::Gapic::Common::Error, + "Invalid value #{field_value.inspect} containing '?' or '#' " \ + "for field #{field_binding.field_path.inspect}" + end + + if field_binding.preserve_slashes + validate_path_binding! field_binding, field_value + else + validate_standard_binding! field_binding, field_value + end + end + + # Validates standard parameters (*) by ensuring no path segment is a directory traversal (. or ..). + # + # @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_standard_binding! field_binding, field_value + segments = field_value.split "/" + segments.each do |segment| + next unless segment == "." || segment == ".." + raise ::Gapic::Common::Error, + "Invalid value #{field_value.inspect} containing traversal segment #{segment.inspect} " \ + "for field #{field_binding.field_path.inspect}" + end + end + + # Validates path parameters (**) by isolating the wildcard segment and verifying it is a safe traversal, + # while validating that all static/standard prefix segments are traversal-free. + # + # @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 + wildcard_value = extract_wildcard_value field_binding, field_value + + if wildcard_value + validate_path_traversal! wildcard_value, field_binding.field_path + + # Extract and validate prefix segments to block prefix traversal injection + prefix_length = field_value.length - wildcard_value.length + prefix_length -= 1 if prefix_length.positive? && field_value[prefix_length - 1] == "/" + prefix = field_value[0, prefix_length] + validate_prefix_segments! prefix, field_binding.field_path + else + validate_prefix_segments! field_value, field_binding.field_path + end + end + + # Extracts the double wildcard (**) segment value using named capture metadata or legacy fallbacks. + # + # @param field_binding [HttpBinding::FieldBinding] The field binding template metadata. + # @param field_value [String] The parameter value to extract from. + # @return [String, Nil] The extracted wildcard segment value, or nil if not found. + def extract_wildcard_value field_binding, field_value + match_data = field_binding.regex.match field_value + return nil unless match_data + + if match_data.names.include? "__wildcard__" + match_data[:__wildcard__] + else + extract_wildcard_fallback field_binding, field_value + end + end + + # Fallback wildcard extraction logic for legacy precompiled client stubs. + # Matches the legacy pattern's optional suffix by dynamically patching the matcher regex. + # + # @param field_binding [HttpBinding::FieldBinding] The field binding template metadata. + # @param field_value [String] The parameter value to extract from. + # @return [String, Nil] The extracted wildcard segment value, or nil if not found. + def extract_wildcard_fallback field_binding, field_value + capturing_regex_str = field_binding.regex.source.sub "(?:/.*)?$", "(?:/(.*))?$" + capturing_regex = Regexp.new capturing_regex_str, field_binding.regex.options + fallback_match = capturing_regex.match field_value + wildcard_value = fallback_match[1] if fallback_match + + if wildcard_value.nil? && unprefixed_wildcard?(field_binding.regex.source) + wildcard_value = field_value + end + + wildcard_value + end + + # Performs segment-counting verification on a double-wildcard path segment value + # to ensure it does not escape the parameter boundary. + # + # @param path [String] The wildcard path value to validate. + # @param field_path [String] The name of the parameter field for exception context. + # @raise [Gapic::Common::Error] If the traversal escapes the boundary or resolves to empty. + def validate_path_traversal! path, field_path + segments = path.split "/" + normalized = [] + has_traversal = false + segments.each do |segment| + if segment == ".." + has_traversal = true + if normalized.empty? + raise ::Gapic::Common::Error, + "Path traversal escaped parameter boundary for field #{field_path.inspect} in value #{path.inspect}" + end + normalized.pop + elsif segment == "." + has_traversal = true + elsif segment != "" + normalized.push segment + end + end + + return unless normalized.empty? && has_traversal + raise ::Gapic::Common::Error, + "Path traversal resolved to empty path for field #{field_path.inspect} " \ + "in value #{path.inspect}" + end + + # Validates that no prefix segment contains path traversal indicators (. or ..). + # + # @param prefix [String] The prefix path string to check. + # @param field_path [String] The name of the parameter field for exception context. + # @raise [Gapic::Common::Error] If any segment is . or .. + def validate_prefix_segments! prefix, field_path + segments = prefix.split "/" + segments.each do |segment| + next unless segment == "." || segment == ".." + raise ::Gapic::Common::Error, + "Path traversal segment #{segment.inspect} in prefix #{prefix.inspect} " \ + "is not allowed for field #{field_path.inspect}" + end + end + + # Checks if the regex matches an unprefixed wildcard pattern (such as `{name=**}`). + # + # @param regex_source [String] The source regex pattern to check. + # @return [Boolean] True if the regex represents an unprefixed wildcard template. + def unprefixed_wildcard? regex_source + !/\A\^?(?:\(\?<[a-zA-Z_0-9.]+>\))?\.\*\)?\$?\z/.match(regex_source).nil? + 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..7f1e3bb 100644 --- a/gapic-common/test/gapic/rest/grpc_transcoder_test.rb +++ b/gapic-common/test/gapic/rest/grpc_transcoder_test.rb @@ -299,6 +299,185 @@ 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 fail + err = assert_raises ::Gapic::Common::Error do + transcoder_inj.transcode example_request(name: "projects/p/locations/l/agents/a/sessions/s1?key=val") + end + assert err.message.include?("containing '?' or '#'") + + # Payload with fragment injection should fail + err = assert_raises ::Gapic::Common::Error do + transcoder_inj.transcode example_request(name: "projects/p/locations/l/agents/a/sessions/s1#frag") + end + assert err.message.include?("containing '?' or '#'") + 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 err.message.include?("containing traversal segment") + + # 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 err.message.include?("containing traversal segment") + + # 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 (**) - New Style (named capture) + # 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 local traversal should pass + transcoder_wild.transcode example_request(name: "projects/p/databases/d/documents/doc/a/b/../c", sub_name: "col") + + # Invalid traversal escaping boundary 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 err.message.include?("escaped parameter boundary") + + # Invalid traversal resolving to empty path 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 err.message.include?("resolved to empty path") + + # Prefix traversal injection (traversal in prefix standard segment) 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 err.message.include?("in prefix") + end + + def test_transcode_validation_multi_segment_standard + # 4. Multi-Segment Standard Templates (No ** segment, preserve_slashes: true) + # Proto: get: "/v1/{name=projects/*/locations/*}" + # Template: v1/{name} + transcoder_multi_std = Gapic::Rest::GrpcTranscoder.new.with_bindings( + uri_method: :get, + uri_template: "/v1/{name}", + matches: [["name", %r{^projects/[^/]+/locations/[^/]+$}, true]] + ) + + # Traversal in standard multi-segment parameter should fail + err = assert_raises ::Gapic::Common::Error do + transcoder_multi_std.transcode example_request(name: "projects/p/locations/..") + end + assert err.message.include?("in prefix") + end + + def test_transcode_validation_legacy_fallbacks + # 5. Legacy Fallback Matcher Testing + # Type 1: Legacy prefix + suffix wildcard matching (no named capture group) + # Proto: post: "/v3/{name=projects/*/locations/*/agents/*/sessions/**}:detectIntent" + # Template: v3/{name}:detectIntent + transcoder_legacy1 = Gapic::Rest::GrpcTranscoder.new.with_bindings( + uri_method: :post, + uri_template: "/v3/{name}:detectIntent", + matches: [["name", %r{^projects/[^/]+/locations/[^/]+/agents/[^/]+/sessions/[^/]+(?:/.*)?$}, true]] + ) + + # Valid local traversal passes + transcoder_legacy1.transcode example_request(name: "projects/p/locations/l/agents/a/sessions/s1/a/b/../c") + + # Traversal segment '..' in legacy prefix segment should fail with prefix rejection + err = assert_raises ::Gapic::Common::Error do + transcoder_legacy1.transcode example_request(name: "projects/p/locations/l/agents/a/sessions/..") + end + assert err.message.include?("in prefix") + + # Traversal segment '..' in legacy wildcard suffix should fail with escaping boundary + err = assert_raises ::Gapic::Common::Error do + transcoder_legacy1.transcode example_request(name: "projects/p/locations/l/agents/a/sessions/s1/..") + end + assert err.message.include?("escaped parameter boundary") + + # Prefix traversal in legacy template should fail + err = assert_raises ::Gapic::Common::Error do + transcoder_legacy1.transcode example_request(name: "projects/p/locations/../agents/a/sessions/s1") + end + assert err.message.include?("in prefix") + + # Type 2: Legacy nested wildcard matching + # Proto: post: "/v1/{name=projects/*/databases/*/documents/*/**}" + # Template: v1/{name} + transcoder_legacy2 = Gapic::Rest::GrpcTranscoder.new.with_bindings( + uri_method: :post, + uri_template: "/v1/{name}", + matches: [["name", %r{^projects/[^/]+/databases/[^/]+/documents/[^/]+(?:/.*)?$}, true]] + ) + + # Traversal escaping nested boundary should fail + err = assert_raises ::Gapic::Common::Error do + transcoder_legacy2.transcode example_request(name: "projects/p/databases/d/documents/doc/../../../../doc2") + end + assert err.message.include?("escaped parameter boundary") + + # Type 3: Legacy wildcard with no prefix ({foo=**} -> ^.*$) + # Proto: get: "/v1/{name=**}" + # Template: v1/{name} + transcoder_legacy3 = Gapic::Rest::GrpcTranscoder.new.with_bindings( + uri_method: :get, + uri_template: "/v1/{name}", + matches: [["name", %r{^.*$}, true]] + ) + + # Valid path passes + transcoder_legacy3.transcode example_request(name: "a/b/c") + + # Path traversal in unprefixed legacy wildcard should fail + err = assert_raises ::Gapic::Common::Error do + transcoder_legacy3.transcode example_request(name: "a/b/../../../escape") + end + assert err.message.include?("escaped parameter boundary") + end + private def assert_transcoding_matches transcoder, test_cases From e52d7fb2bc276617c08d2815962e0025f090e589 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Mon, 3 Aug 2026 17:14:50 +0000 Subject: [PATCH 2/8] fix: simplify rest transcoder double-wildcard validation to fail always on dots --- .../lib/gapic/rest/grpc_transcoder.rb | 101 +---------------- .../test/gapic/rest/grpc_transcoder_test.rb | 103 ++---------------- 2 files changed, 13 insertions(+), 191 deletions(-) diff --git a/gapic-common/lib/gapic/rest/grpc_transcoder.rb b/gapic-common/lib/gapic/rest/grpc_transcoder.rb index a923546..4162554 100644 --- a/gapic-common/lib/gapic/rest/grpc_transcoder.rb +++ b/gapic-common/lib/gapic/rest/grpc_transcoder.rb @@ -166,110 +166,15 @@ def validate_standard_binding! field_binding, field_value # @param field_value [String] The parameter value to validate. # @raise [Gapic::Common::Error] If validation fails. def validate_path_binding! field_binding, field_value - wildcard_value = extract_wildcard_value field_binding, field_value - - if wildcard_value - validate_path_traversal! wildcard_value, field_binding.field_path - - # Extract and validate prefix segments to block prefix traversal injection - prefix_length = field_value.length - wildcard_value.length - prefix_length -= 1 if prefix_length.positive? && field_value[prefix_length - 1] == "/" - prefix = field_value[0, prefix_length] - validate_prefix_segments! prefix, field_binding.field_path - else - validate_prefix_segments! field_value, field_binding.field_path - end - end - - # Extracts the double wildcard (**) segment value using named capture metadata or legacy fallbacks. - # - # @param field_binding [HttpBinding::FieldBinding] The field binding template metadata. - # @param field_value [String] The parameter value to extract from. - # @return [String, Nil] The extracted wildcard segment value, or nil if not found. - def extract_wildcard_value field_binding, field_value - match_data = field_binding.regex.match field_value - return nil unless match_data - - if match_data.names.include? "__wildcard__" - match_data[:__wildcard__] - else - extract_wildcard_fallback field_binding, field_value - end - end - - # Fallback wildcard extraction logic for legacy precompiled client stubs. - # Matches the legacy pattern's optional suffix by dynamically patching the matcher regex. - # - # @param field_binding [HttpBinding::FieldBinding] The field binding template metadata. - # @param field_value [String] The parameter value to extract from. - # @return [String, Nil] The extracted wildcard segment value, or nil if not found. - def extract_wildcard_fallback field_binding, field_value - capturing_regex_str = field_binding.regex.source.sub "(?:/.*)?$", "(?:/(.*))?$" - capturing_regex = Regexp.new capturing_regex_str, field_binding.regex.options - fallback_match = capturing_regex.match field_value - wildcard_value = fallback_match[1] if fallback_match - - if wildcard_value.nil? && unprefixed_wildcard?(field_binding.regex.source) - wildcard_value = field_value - end - - wildcard_value - end - - # Performs segment-counting verification on a double-wildcard path segment value - # to ensure it does not escape the parameter boundary. - # - # @param path [String] The wildcard path value to validate. - # @param field_path [String] The name of the parameter field for exception context. - # @raise [Gapic::Common::Error] If the traversal escapes the boundary or resolves to empty. - def validate_path_traversal! path, field_path - segments = path.split "/" - normalized = [] - has_traversal = false - segments.each do |segment| - if segment == ".." - has_traversal = true - if normalized.empty? - raise ::Gapic::Common::Error, - "Path traversal escaped parameter boundary for field #{field_path.inspect} in value #{path.inspect}" - end - normalized.pop - elsif segment == "." - has_traversal = true - elsif segment != "" - normalized.push segment - end - end - - return unless normalized.empty? && has_traversal - raise ::Gapic::Common::Error, - "Path traversal resolved to empty path for field #{field_path.inspect} " \ - "in value #{path.inspect}" - end - - # Validates that no prefix segment contains path traversal indicators (. or ..). - # - # @param prefix [String] The prefix path string to check. - # @param field_path [String] The name of the parameter field for exception context. - # @raise [Gapic::Common::Error] If any segment is . or .. - def validate_prefix_segments! prefix, field_path - segments = prefix.split "/" + segments = field_value.split("/", -1) segments.each do |segment| next unless segment == "." || segment == ".." raise ::Gapic::Common::Error, - "Path traversal segment #{segment.inspect} in prefix #{prefix.inspect} " \ - "is not allowed for field #{field_path.inspect}" + "Path traversal segment #{segment.inspect} is not allowed " \ + "for field #{field_binding.field_path.inspect} in value #{field_value.inspect}" end end - # Checks if the regex matches an unprefixed wildcard pattern (such as `{name=**}`). - # - # @param regex_source [String] The source regex pattern to check. - # @return [Boolean] True if the regex represents an unprefixed wildcard template. - def unprefixed_wildcard? regex_source - !/\A\^?(?:\(\?<[a-zA-Z_0-9.]+>\))?\.\*\)?\$?\z/.match(regex_source).nil? - 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 7f1e3bb..a424964 100644 --- a/gapic-common/test/gapic/rest/grpc_transcoder_test.rb +++ b/gapic-common/test/gapic/rest/grpc_transcoder_test.rb @@ -361,7 +361,7 @@ def test_transcode_validation_standard_wildcard end def test_transcode_validation_path_wildcard - # 3. Path/Double-Wildcard Matchers (**) - New Style (named capture) + # 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( @@ -373,109 +373,26 @@ def test_transcode_validation_path_wildcard ] ) - # Valid local traversal should pass - transcoder_wild.transcode example_request(name: "projects/p/databases/d/documents/doc/a/b/../c", sub_name: "col") + # Valid path should pass + transcoder_wild.transcode example_request(name: "projects/p/databases/d/documents/doc/a/b/c", sub_name: "col") - # Invalid traversal escaping boundary should fail + # 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 err.message.include?("escaped parameter boundary") + assert err.message.include?("is not allowed") - # Invalid traversal resolving to empty path should fail + # 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") + transcoder_wild.transcode example_request(name: "projects/p/databases/d/documents/doc/./a", sub_name: "col") end - assert err.message.include?("resolved to empty path") + assert err.message.include?("is not allowed") - # Prefix traversal injection (traversal in prefix standard segment) should fail + # 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 err.message.include?("in prefix") - end - - def test_transcode_validation_multi_segment_standard - # 4. Multi-Segment Standard Templates (No ** segment, preserve_slashes: true) - # Proto: get: "/v1/{name=projects/*/locations/*}" - # Template: v1/{name} - transcoder_multi_std = Gapic::Rest::GrpcTranscoder.new.with_bindings( - uri_method: :get, - uri_template: "/v1/{name}", - matches: [["name", %r{^projects/[^/]+/locations/[^/]+$}, true]] - ) - - # Traversal in standard multi-segment parameter should fail - err = assert_raises ::Gapic::Common::Error do - transcoder_multi_std.transcode example_request(name: "projects/p/locations/..") - end - assert err.message.include?("in prefix") - end - - def test_transcode_validation_legacy_fallbacks - # 5. Legacy Fallback Matcher Testing - # Type 1: Legacy prefix + suffix wildcard matching (no named capture group) - # Proto: post: "/v3/{name=projects/*/locations/*/agents/*/sessions/**}:detectIntent" - # Template: v3/{name}:detectIntent - transcoder_legacy1 = Gapic::Rest::GrpcTranscoder.new.with_bindings( - uri_method: :post, - uri_template: "/v3/{name}:detectIntent", - matches: [["name", %r{^projects/[^/]+/locations/[^/]+/agents/[^/]+/sessions/[^/]+(?:/.*)?$}, true]] - ) - - # Valid local traversal passes - transcoder_legacy1.transcode example_request(name: "projects/p/locations/l/agents/a/sessions/s1/a/b/../c") - - # Traversal segment '..' in legacy prefix segment should fail with prefix rejection - err = assert_raises ::Gapic::Common::Error do - transcoder_legacy1.transcode example_request(name: "projects/p/locations/l/agents/a/sessions/..") - end - assert err.message.include?("in prefix") - - # Traversal segment '..' in legacy wildcard suffix should fail with escaping boundary - err = assert_raises ::Gapic::Common::Error do - transcoder_legacy1.transcode example_request(name: "projects/p/locations/l/agents/a/sessions/s1/..") - end - assert err.message.include?("escaped parameter boundary") - - # Prefix traversal in legacy template should fail - err = assert_raises ::Gapic::Common::Error do - transcoder_legacy1.transcode example_request(name: "projects/p/locations/../agents/a/sessions/s1") - end - assert err.message.include?("in prefix") - - # Type 2: Legacy nested wildcard matching - # Proto: post: "/v1/{name=projects/*/databases/*/documents/*/**}" - # Template: v1/{name} - transcoder_legacy2 = Gapic::Rest::GrpcTranscoder.new.with_bindings( - uri_method: :post, - uri_template: "/v1/{name}", - matches: [["name", %r{^projects/[^/]+/databases/[^/]+/documents/[^/]+(?:/.*)?$}, true]] - ) - - # Traversal escaping nested boundary should fail - err = assert_raises ::Gapic::Common::Error do - transcoder_legacy2.transcode example_request(name: "projects/p/databases/d/documents/doc/../../../../doc2") - end - assert err.message.include?("escaped parameter boundary") - - # Type 3: Legacy wildcard with no prefix ({foo=**} -> ^.*$) - # Proto: get: "/v1/{name=**}" - # Template: v1/{name} - transcoder_legacy3 = Gapic::Rest::GrpcTranscoder.new.with_bindings( - uri_method: :get, - uri_template: "/v1/{name}", - matches: [["name", %r{^.*$}, true]] - ) - - # Valid path passes - transcoder_legacy3.transcode example_request(name: "a/b/c") - - # Path traversal in unprefixed legacy wildcard should fail - err = assert_raises ::Gapic::Common::Error do - transcoder_legacy3.transcode example_request(name: "a/b/../../../escape") - end - assert err.message.include?("escaped parameter boundary") + assert err.message.include?("is not allowed") end private From 25e80c603fc0cc65b179fa146ae10f4152e67945 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Mon, 3 Aug 2026 17:25:12 +0000 Subject: [PATCH 3/8] docs: clarify validation rules and empty segment handling in validate_path_binding! --- gapic-common/lib/gapic/rest/grpc_transcoder.rb | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/gapic-common/lib/gapic/rest/grpc_transcoder.rb b/gapic-common/lib/gapic/rest/grpc_transcoder.rb index 4162554..e5b8cdf 100644 --- a/gapic-common/lib/gapic/rest/grpc_transcoder.rb +++ b/gapic-common/lib/gapic/rest/grpc_transcoder.rb @@ -159,8 +159,15 @@ def validate_standard_binding! field_binding, field_value end end - # Validates path parameters (**) by isolating the wildcard segment and verifying it is a safe traversal, - # while validating that all static/standard prefix segments are traversal-free. + # Validates path parameters (**) by ensuring that no segment in the parameter value is + # a directory traversal segment (. or ..). + # + # Validation Mechanism: + # 1. Splits the full 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 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. From 865c1ccc423437f87b4e557433ceefa52be86b3a Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Mon, 3 Aug 2026 17:47:22 +0000 Subject: [PATCH 4/8] docs: use standard (*) and path (**) indicators in validate_field_binding! comment --- gapic-common/lib/gapic/rest/grpc_transcoder.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gapic-common/lib/gapic/rest/grpc_transcoder.rb b/gapic-common/lib/gapic/rest/grpc_transcoder.rb index e5b8cdf..302a15c 100644 --- a/gapic-common/lib/gapic/rest/grpc_transcoder.rb +++ b/gapic-common/lib/gapic/rest/grpc_transcoder.rb @@ -125,7 +125,8 @@ def bind_uri_values! http_binding, request_hash end end - # Validates a single path or standard field binding value against traversal and parameter injection exploits. + # 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. From a8270fd501e55c55b09f77e1caeeb2dfa20b65c6 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Mon, 3 Aug 2026 17:58:36 +0000 Subject: [PATCH 5/8] refactor: unify standard and path parameter validation methods in REST transcoder --- .../lib/gapic/rest/grpc_transcoder.rb | 25 +++---------------- .../test/gapic/rest/grpc_transcoder_test.rb | 4 +-- 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/gapic-common/lib/gapic/rest/grpc_transcoder.rb b/gapic-common/lib/gapic/rest/grpc_transcoder.rb index 302a15c..f38c535 100644 --- a/gapic-common/lib/gapic/rest/grpc_transcoder.rb +++ b/gapic-common/lib/gapic/rest/grpc_transcoder.rb @@ -138,30 +138,11 @@ def validate_field_binding! field_binding, field_value "for field #{field_binding.field_path.inspect}" end - if field_binding.preserve_slashes - validate_path_binding! field_binding, field_value - else - validate_standard_binding! field_binding, field_value - end - end - - # Validates standard parameters (*) by ensuring no path segment is a directory traversal (. or ..). - # - # @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_standard_binding! field_binding, field_value - segments = field_value.split "/" - segments.each do |segment| - next unless segment == "." || segment == ".." - raise ::Gapic::Common::Error, - "Invalid value #{field_value.inspect} containing traversal segment #{segment.inspect} " \ - "for field #{field_binding.field_path.inspect}" - end + validate_path_binding! field_binding, field_value end - # Validates path parameters (**) by ensuring that no segment in the parameter value is - # a directory traversal segment (. or ..). + # 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 full parameter value by slash (`/`) using `-1` limit to preserve all segments. diff --git a/gapic-common/test/gapic/rest/grpc_transcoder_test.rb b/gapic-common/test/gapic/rest/grpc_transcoder_test.rb index a424964..22da6be 100644 --- a/gapic-common/test/gapic/rest/grpc_transcoder_test.rb +++ b/gapic-common/test/gapic/rest/grpc_transcoder_test.rb @@ -345,13 +345,13 @@ def test_transcode_validation_standard_wildcard err = assert_raises ::Gapic::Common::Error do transcoder_std.transcode example_request(name: "p1", sub_name: "..") end - assert err.message.include?("containing traversal segment") + assert err.message.include?("is not allowed") # 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 err.message.include?("containing traversal segment") + assert err.message.include?("is not allowed") # Slashes in standard parameter should fail matching (regex rejects slashes) err = assert_raises ::Gapic::Common::Error do From 844b429dbb9acd3e54a190784743eaf655363376 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 4 Aug 2026 23:23:13 +0000 Subject: [PATCH 6/8] docs: document that bind_uri_values! raises Gapic::Common::Error --- gapic-common/lib/gapic/rest/grpc_transcoder.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/gapic-common/lib/gapic/rest/grpc_transcoder.rb b/gapic-common/lib/gapic/rest/grpc_transcoder.rb index f38c535..2e1eceb 100644 --- a/gapic-common/lib/gapic/rest/grpc_transcoder.rb +++ b/gapic-common/lib/gapic/rest/grpc_transcoder.rb @@ -111,6 +111,7 @@ 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(".") From d0210db5e4e31a94b002c5b5c617b9e1e080dbac Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Thu, 27 Aug 2026 21:48:07 +0000 Subject: [PATCH 7/8] fix(rest): unescape path parameter values and format validation messages --- .../lib/gapic/rest/grpc_transcoder.rb | 21 ++++++--- .../test/gapic/rest/grpc_transcoder_test.rb | 46 +++++++++++++++++-- 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/gapic-common/lib/gapic/rest/grpc_transcoder.rb b/gapic-common/lib/gapic/rest/grpc_transcoder.rb index 2e1eceb..8a7bc7c 100644 --- a/gapic-common/lib/gapic/rest/grpc_transcoder.rb +++ b/gapic-common/lib/gapic/rest/grpc_transcoder.rb @@ -146,22 +146,29 @@ def validate_field_binding! field_binding, field_value # value is a directory traversal segment (. or ..). # # Validation Mechanism: - # 1. Splits the full parameter value by slash (`/`) using `-1` limit to preserve all segments. - # 2. Checks each segment. If any segment matches `.` or `..`, it immediately raises + # 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. - # 3. Empty segments (e.g. duplicate slashes `//` or trailing slashes `/`) are allowed + # 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 - segments = field_value.split("/", -1) + unescaped_value = CGI.unescape field_value + segments = unescaped_value.split("/", -1) segments.each do |segment| next unless segment == "." || segment == ".." - raise ::Gapic::Common::Error, - "Path traversal segment #{segment.inspect} is not allowed " \ - "for field #{field_binding.field_path.inspect} in value #{field_value.inspect}" + 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 diff --git a/gapic-common/test/gapic/rest/grpc_transcoder_test.rb b/gapic-common/test/gapic/rest/grpc_transcoder_test.rb index 22da6be..8dfc33c 100644 --- a/gapic-common/test/gapic/rest/grpc_transcoder_test.rb +++ b/gapic-common/test/gapic/rest/grpc_transcoder_test.rb @@ -345,13 +345,25 @@ def test_transcode_validation_standard_wildcard err = assert_raises ::Gapic::Common::Error do transcoder_std.transcode example_request(name: "p1", sub_name: "..") end - assert err.message.include?("is not allowed") + 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 err.message.include?("is not allowed") + 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 @@ -380,19 +392,43 @@ def test_transcode_validation_path_wildcard 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 err.message.include?("is not allowed") + 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 err.message.include?("is not allowed") + 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 err.message.include?("is not allowed") + 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 From 37844cabb645ae66fa45073a2bcd8f610144bf6c Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Fri, 28 Aug 2026 21:40:48 +0000 Subject: [PATCH 8/8] fix(rest): remove ? and # checks and rely on escaping --- gapic-common/lib/gapic/rest/grpc_transcoder.rb | 6 ------ .../test/gapic/rest/grpc_transcoder_test.rb | 14 ++++++-------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/gapic-common/lib/gapic/rest/grpc_transcoder.rb b/gapic-common/lib/gapic/rest/grpc_transcoder.rb index 8a7bc7c..32d5d1d 100644 --- a/gapic-common/lib/gapic/rest/grpc_transcoder.rb +++ b/gapic-common/lib/gapic/rest/grpc_transcoder.rb @@ -133,12 +133,6 @@ def bind_uri_values! http_binding, request_hash # @param field_value [String] The parameter value to validate. # @raise [Gapic::Common::Error] If validation fails. def validate_field_binding! field_binding, field_value - if field_value.include?("?") || field_value.include?("#") - raise ::Gapic::Common::Error, - "Invalid value #{field_value.inspect} containing '?' or '#' " \ - "for field #{field_binding.field_path.inspect}" - end - validate_path_binding! field_binding, field_value end diff --git a/gapic-common/test/gapic/rest/grpc_transcoder_test.rb b/gapic-common/test/gapic/rest/grpc_transcoder_test.rb index 8dfc33c..0721891 100644 --- a/gapic-common/test/gapic/rest/grpc_transcoder_test.rb +++ b/gapic-common/test/gapic/rest/grpc_transcoder_test.rb @@ -312,17 +312,15 @@ def test_transcode_validation_parameter_injection # Valid payload should pass transcoder_inj.transcode example_request(name: "projects/p/locations/l/agents/a/sessions/s1") - # Payload with query injection should fail - err = assert_raises ::Gapic::Common::Error do + # 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") - end - assert err.message.include?("containing '?' or '#'") + assert_equal "/v3/projects/p/locations/l/agents/a/sessions/s1%3Fkey%3Dval:detectIntent", uri - # Payload with fragment injection should fail - err = assert_raises ::Gapic::Common::Error do + # 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") - end - assert err.message.include?("containing '?' or '#'") + assert_equal "/v3/projects/p/locations/l/agents/a/sessions/s1%23frag:detectIntent", uri end def test_transcode_validation_standard_wildcard