THREESCALE-15550: Upgrade uri gem - #4356
Conversation
|
I'm ok with keeping RFC2396, but iassume many people hit these changes. What is the rationale not to switch to the new parser everywhere with whatever migration path is common for these many users? Like the URL pattern, what is upstream project recommending as a migration to these? |
Thanks for the review @akostadinov. I investigated for full RFC3986 migration but it's not viable for us RFC3986_PARSER doesn't provide equivalents for the APIs we use. Its make_regexp, unescape, escape, extract all delegate to RFC2396_PARSER and its deprecation warnings saying "Use URI::RFC2396_PARSER explicitly". For .pattern and PATTERN::* constants, there are no RFC3986 equivalents at all. Other projects hit the same issue and took the same approach, CarrierWave (#2760) and Capybara (#2781). |
|
To me this PR as a quick compatibility patch up and not a proper migration. What I believe the issue is about is understanding the implications of using one parsing method or another and use a future proof method. This is a test script to demonstrate what presently the different approaches result into: #!/usr/bin/env ruby
# frozen_string_literal: true
# Run with: ruby uri_parser_repro.rb (NOT bundle exec)
require 'rubygems'
gem 'uri', '0.13.0'
require 'uri'
TEST_URLS = [
'http://my_proxy.internal:8080',
'https://foo_bar.example.com',
'http://exa_mple',
'http://example.com',
'http://bad host'
].freeze
puts "RUBY=#{RUBY_VERSION}"
puts "URI_GEM=#{Gem.loaded_specs.fetch('uri').version}"
puts "DEFAULT_PARSER_CLASS=#{URI::DEFAULT_PARSER.class}"
puts "Available PARSER constants: #{URI.constants.grep(/PARSER/).sort.join(', ')}"
default_regexp = URI::DEFAULT_PARSER.make_regexp(%w[http https])
TEST_URLS.each do |url|
puts
puts "URL=#{url}"
begin
parsed = URI.parse(url)
puts "URI.parse: ok host=#{parsed.host.inspect}"
rescue => e
puts "URI.parse: #{e.class}: #{e.message}"
end
begin
parsed = URI::DEFAULT_PARSER.parse(url)
puts "DEFAULT_PARSER.parse: ok host=#{parsed.host.inspect}"
rescue => e
puts "DEFAULT_PARSER.parse: #{e.class}: #{e.message}"
end
puts "DEFAULT_PARSER.make_regexp: #{!!(url =~ default_regexp)}"The result is: I'm not fully against a 100% compatible quick patch but current PR seems to change the behavior at places to be more strict and reject underscores (where I'll ask Bob to draft an alternative approach where our validation is more specific on what we care about. And we can decide whether to try keeping 100% old behavior, keep it vaguely or be more precise with our validation. |
Review: URI gem upgrade needs proper migration, not just compatibility patchesThis PR upgrades Core Issues
Recommended ApproachReplace regex-based validators with parse-based semantic validation plus explicit hostname policy. Example validator: class HttpUrlValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank? && options[:allow_blank]
uri = Addressable::URI.parse(value)
unless %w[http https].include?(uri.scheme)
record.errors.add(attribute, 'must be http or https')
return
end
unless valid_hostname?(uri.host)
record.errors.add(attribute, 'has invalid hostname')
end
rescue Addressable::URI::InvalidURIError
record.errors.add(attribute, 'is not a valid URL')
end
private
def valid_hostname?(host)
return false if host.blank?
return true if host =~ /\A\d+\.\d+\.\d+\.\d+\z/ # IPv4
return true if host =~ /\A\[.*\]\z/ # IPv6
return true if host == 'localhost'
# DNS hostname validation (RFC 1123)
labels = host.split('.')
return false if labels.empty?
labels.all? do |label|
label.length <= 63 &&
label =~ /\A[a-z0-9]([a-z0-9-]*[a-z0-9])?\z/i
end
end
endFile-by-File Migration Plan
Why This Matters
ReproductionSee |
|
As an answer to @akostadinov-bot comment,
To reiterate, I am interested in getting the question better understood and that we make a decision for the long-term. Not merely coming up with some implementation that tolerates |
jlledom
left a comment
There was a problem hiding this comment.
I agree with @akostadinov. I think the point of this issue is to migrate the whole application to RFC3986, othwerwise we are making the tech debt deeper. For instance, in thie PR we are explicitly calling the old parser when needed, but the latest uri gem is using RFC3986 as the default parser internally, this could cause inconsistencies in how the same data is treated. This mix of RFCs will be a source of problems for sure.
The best way to solve this is to ensure everything is properly covered by tests, then bump the gem and fix the broken scenarios. If some method definitions don't exist, we can figure out other ways to do the same. For instance we might not cal make_regexp but we can write a new validator method or class to perform the validation. The proxy rule pattern validator might need special attention as well.
I checked the production DB and all urls we have stored in many columns in different tables, all of them are already RFC3986 valid urls. So switching the new RFC shouldn't break anything.
|
Thanks @akostadinov and @jlledom for the feedback. That makes sense. I'll rework this PR |
❌ 12 blocking issues (12 total)
|
…ndAttributeAccessor, SpaceInsideArrayLiteralBrackets
There was a problem hiding this comment.
Overall the PR looks sound and properly addresses the review feedback — all RFC2396 references eliminated, validation moved from make_regexp to parse-based UriValidator, clean URI.decode → CGI.unescapeURIComponent migration, and app-owned regex constants for path/query patterns. A few minor questions inline.
| (provider.web_hook || provider.build_web_hook).update!(attrs) | ||
| hook = provider.web_hook || provider.build_web_hook | ||
| hook.assign_attributes(attrs) | ||
| hook.save!(validate: false) |
There was a problem hiding this comment.
Could the fixture URL be fixed to pass validation instead of skipping it? save!(validate: false) removes incidental validation coverage from these Cucumber scenarios — if a future change breaks webhook URL validation, these tests won't catch it. Not blocking, just wondering if there's a reason the fixture URLs don't pass the new uri: validator.
There was a problem hiding this comment.
I agree with the bot. Instead of modifying this, which will affect other tests, just fix the test. In the same cucumber scenario, instead of:
Hostname not supplied: 'http:://banana'
We'll see:
Must be a valid URL such as http://example.com
|
|
||
| errors: | ||
| messages: | ||
| invalid_url: "Invalid URL format" |
There was a problem hiding this comment.
Now that all validators use :invalid_url, are the old :invalid keys for the same attributes still reachable? If not, could they be removed to avoid the duplication (5 attributes have both invalid: and invalid_url: with identical messages)? If some code path still hits :invalid, keeping both is fine — just worth confirming.
| url = params.permit(:referrer)[:referrer] | ||
| if url | ||
| URI.decode(url) | ||
| CGI.unescapeURIComponent(url) |
There was a problem hiding this comment.
Minor: CGI.unescapeURIComponent raises ArgumentError on malformed percent-encoding (e.g. %ZZ), whereas the old URI.decode silently passed them through. Since referrer URLs originate from the app this is very unlikely, but is it worth a rescue here to fall back to the raw URL on ArgumentError?
There was a problem hiding this comment.
The referrer URL is generated by our app and passed through the OAuth redirect flow. Malformed percent encoding would only occur if the user tampered with their own callback URL, which isn't a scenario we need to guard against
| ops.validates :site | ||
| with_options uri: { path: true, query: true, fragment: true }, allow_blank: true do |ops| | ||
| # Skip uri: for whitespace URLs — keycloak/auth0 whitespace validator handles these | ||
| ops.validates :site, unless: -> { site.to_s.match?(/\s/) } |
There was a problem hiding this comment.
The UX improvement is nice (one error instead of two for whitespace URLs). Was stripping whitespace with .strip before validation considered as an alternative? That way " http://example.com " would pass validation and be stored clean, rather than relying on the user to fix the whitespace themselves.
| (provider.web_hook || provider.build_web_hook).update!(attrs) | ||
| hook = provider.web_hook || provider.build_web_hook | ||
| hook.assign_attributes(attrs) | ||
| hook.save!(validate: false) |
There was a problem hiding this comment.
Could the fixture URL be fixed to pass validation instead of skipping it? save!(validate: false) removes incidental validation coverage from these Cucumber scenarios — if a future change breaks webhook URL validation, these tests won't catch it. Not blocking, just wondering if there's a reason the fixture URLs don't pass the new uri: validator.
|
|
||
| errors: | ||
| messages: | ||
| invalid_url: "Invalid URL format" |
There was a problem hiding this comment.
Now that all validators use :invalid_url, are the old :invalid keys for the same attributes still reachable? If not, could they be removed to avoid the duplication (5 attributes have both invalid: and invalid_url: with identical messages). If some code path still hits :invalid, keeping both is fine — just worth confirming.
| url = params.permit(:referrer)[:referrer] | ||
| if url | ||
| URI.decode(url) | ||
| CGI.unescapeURIComponent(url) |
There was a problem hiding this comment.
Minor: CGI.unescapeURIComponent raises ArgumentError on malformed percent-encoding (e.g. %ZZ), whereas the old URI.decode silently passed them through. Since referrer URLs originate from the app this is very unlikely, but is it worth a rescue here to fall back to the raw URL on ArgumentError?
| ops.validates :site | ||
| with_options uri: { path: true, query: true, fragment: true }, allow_blank: true do |ops| | ||
| # Skip uri: for whitespace URLs — keycloak/auth0 whitespace validator handles these | ||
| ops.validates :site, unless: -> { site.to_s.match?(/\s/) } |
There was a problem hiding this comment.
The UX improvement is nice (one error instead of two for whitespace URLs). Was stripping whitespace with .strip before validation considered as an alternative? That way " http://example.com " would pass validation and be stored clean, rather than relying on the user to fix the whitespace themselves.
| query = "(?:[\\-_.!~*'()a-zA-Z\\d;/?:@&=+$,\\[\\]]|%[a-fA-F\\d]{2})*" | ||
| optional_query = "(?:\\?(#{query}))?" | ||
| URI_PATH_PART = Regexp.new('\A' + abs_path + optional_query + '\z') | ||
| HOST = /\A(?:[a-zA-Z0-9\-._]|%\h\h)+(?::\d+)?\z/ |
There was a problem hiding this comment.
Could you explain how did you come up with these and how do we know these are correct? If this is a copy of what we previously used, that's also fine but we need to be sure that what we don't us some AI generated potentially buggy stuff.
I see you said you verified it's the same as in old parser.
There was a problem hiding this comment.
Yes, its copy of original, and i verified them all in rails console, only difference is HOST, just added _ a for RFC3986 compliance
There was a problem hiding this comment.
But I don't agree that we should start allowing underscores in hostnames. I think we should keep rejecting these. Is there a reason to allow them. If there is, I'm open to change my mind. Here and in the other places.
| param = / | ||
| UNRESERVED = "\\-_.!~*'()a-zA-Z\\d" | ||
| ESCAPED = "%[a-fA-F\\d]{2}" | ||
| RESERVED = ";/?:@&=+$,\\[\\]" |
There was a problem hiding this comment.
same here
I assume you verified these as well.
There was a problem hiding this comment.
p URI::RFC2396_REGEXP::PATTERN::UNRESERVED etc
| #$& contains the whole match of the regural expression | ||
| url = $&.sub(/\.$/, '').sub(/\:$/,'') | ||
| text.scan(%r{https?://[^\s)\]>]+}) do | ||
| url = Regexp.last_match(0).sub(/[.:]+\z/, '') |
There was a problem hiding this comment.
this seems to be a behavior change. Previously only .: at the end was removed, now any sequence of . and : is removed. I suggest to leave this change for a separate PR. This one is already too tricky.
There was a problem hiding this comment.
Reverted in commit 4d98455, I also reworked the regex to accept ), ], > as valid URL characters per RFC3986.
| text.scan(URI.regexp(%w(http https))) do | ||
| #$& contains the whole match of the regural expression | ||
| url = $&.sub(/\.$/, '').sub(/\:$/,'') | ||
| text.scan(%r{https?://[^\s)\]>]+}) do |
There was a problem hiding this comment.
Ideally we should implement an RFC3986 linkifyer that actually matches valid RFC3986 urls, the uri gem provides some constants with regexps we could use. I've been trying a bit and doesn't seem to be an easy task. I'd say you should try. If you think it's not worth it. then better use %r{https?://[^\s]+} as a pattern, since ), ] and > are valid characters according to RFC 3896 AFAIK.
There was a problem hiding this comment.
I tried %r{https?://[^\s]+} but it captures surrounding punctuation e.g. in "Check this (http://example.com) for details", the closing ) becomes part of the URL.
Implemented a balanced-bracket regex instead: It matches broadly but handles () and [] as balanced groups. All existing tests pass. 4b7f655
There was a problem hiding this comment.
OK, it won't be perfect but it should work for most cases
| pchar = "(?:[\\-_.!~*'()a-zA-Z\\d:@&=+$,]|%[a-fA-F\\d]{2})" | ||
| segment = "#{pchar}*(?:;#{pchar}*)*" | ||
| abs_path = "/#{segment}(?:/#{segment})*" | ||
| query = "(?:[\\-_.!~*'()a-zA-Z\\d;/?:@&=+$,\\[\\]]|%[a-fA-F\\d]{2})*" | ||
| optional_query = "(?:\\?(#{query}))?" | ||
| URI_PATH_PART = Regexp.new('\A' + abs_path + optional_query + '\z') | ||
| HOST = /\A(?:[a-zA-Z0-9\-._]|%\h\h)+(?::\d+)?\z/ |
There was a problem hiding this comment.
About this, can't we use the constants from URI::RFC3986_PARSER.regexp?
I don't think it makes sense to eliminate all references to RFC2396 but then hardcode a copy of its patterns. In fact, we are actually using RFC2396 anyway, just in a more complicated way.
If these values are exactly the same they were in RFC2396, then I think it's better to just mention RFC2396 and take them from the gem. If they are now different and meet RFC3986, then better create a helper module with all this regexps and use it from here or the validator.
| (provider.web_hook || provider.build_web_hook).update!(attrs) | ||
| hook = provider.web_hook || provider.build_web_hook | ||
| hook.assign_attributes(attrs) | ||
| hook.save!(validate: false) |
There was a problem hiding this comment.
I agree with the bot. Instead of modifying this, which will affect other tests, just fix the test. In the same cucumber scenario, instead of:
Hostname not supplied: 'http:://banana'
We'll see:
Must be a valid URL such as http://example.com
There was a problem hiding this comment.
Not sure this is the proper place, but we should somewhere add tests for URLs including the characters that are treated differently in RFC 3986: ! ' ( ) *
There was a problem hiding this comment.
Both RFC2396 and RFC3986 accept ! ' ( ) * in URLs, the classification is changed (unreserved → sub-delimiters) but validity didn't change.
There was a problem hiding this comment.
However... even if validity doesn't change, we don't have a test to assert this validity
| text.scan(URI.regexp(%w(http https))) do | ||
| #$& contains the whole match of the regural expression | ||
| url = $&.sub(/\.$/, '').sub(/\:$/,'') | ||
| text.scan(%r{https?://[^\s)\]>]+}) do |
There was a problem hiding this comment.
OK, it won't be perfect but it should work for most cases
| # constants. RFC3986 also defines different character sets (e.g. UNRESERVED | ||
| # excludes !*'(), QUERY rejects []) which would change validation behavior. | ||
| # These RFC2396 patterns are used intentionally to preserve compatibility. | ||
| module UriPatterns |
| validates :error_status_no_match, :error_status_auth_missing, :error_status_auth_failed, :error_status_limits_exceeded, presence: true | ||
|
|
||
| uri_pattern = URI::DEFAULT_PARSER.pattern | ||
| OPTIONAL_QUERY_FORMAT = "(?:\\?(#{UriPatterns::QUERY}))?" |
|
|
||
| uri_pattern = URI::DEFAULT_PARSER.pattern | ||
| OPTIONAL_QUERY_FORMAT = "(?:\\?(#{UriPatterns::QUERY}))?" | ||
| URI_PATH_PART = Regexp.new('\A' + UriPatterns::ABS_PATH + OPTIONAL_QUERY_FORMAT + '\z') |
| uri_pattern = URI::DEFAULT_PARSER.pattern | ||
| OPTIONAL_QUERY_FORMAT = "(?:\\?(#{UriPatterns::QUERY}))?" | ||
| URI_PATH_PART = Regexp.new('\A' + UriPatterns::ABS_PATH + OPTIONAL_QUERY_FORMAT + '\z') | ||
| HOST = Regexp.new('\A' + UriPatterns::HOSTNAME + '(:\d+)?' + '\z') |
| begin | ||
| uri = URI.parse(attribute_value) | ||
| value = URI::Generic.new(uri.scheme, uri.userinfo, uri.host, uri.port, uri.registry, uri.path, uri.opaque, uri.query, uri.fragment).to_s | ||
| value = URI::Generic.new(uri.scheme, uri.userinfo, uri.host, uri.port, nil, uri.path, uri.opaque, uri.query, uri.fragment).to_s |
There was a problem hiding this comment.
Found 8 issues:
1. Proxy::PortGenerator#call refers to 'uri' more than self (maybe move it to another class?) [reek:FeatureEnvy]
2. Proxy::PortGenerator#call refers to 'uri' more than self (maybe move it to another class?) [reek:FeatureEnvy]
3. Proxy::PortGenerator#call refers to 'uri' more than self (maybe move it to another class?) [reek:FeatureEnvy]
4. Proxy::PortGenerator#call refers to 'uri' more than self (maybe move it to another class?) [reek:FeatureEnvy]
5. Proxy::PortGenerator#call refers to 'uri' more than self (maybe move it to another class?) [reek:FeatureEnvy]
6. Proxy::PortGenerator#call refers to 'uri' more than self (maybe move it to another class?) [reek:FeatureEnvy]
7. Proxy::PortGenerator#call refers to 'uri' more than self (maybe move it to another class?) [reek:FeatureEnvy]
8. Proxy::PortGenerator#call refers to 'uri' more than self (maybe move it to another class?) [reek:FeatureEnvy]
| # constants. RFC3986 also defines different character sets (e.g. UNRESERVED | ||
| # excludes !*'(), QUERY rejects []) which would change validation behavior. |
There was a problem hiding this comment.
Then, shouldn't we define UNRESERVED and QUERY as RFC3986 defines them? that's the point of the PR: migrate to RFC3986. Are there breaking changes?
There was a problem hiding this comment.
The only breaking change is QUERY rejecting [], ?ids[]=1 would fail api_test_path validation. But looking at the pr again that it was already confirmed production URLs are all RFC3986 valid, that should be safe.
But the blocker is that the gem doesn't provide UNRESERVED or QUERY as composable strings for RFC3986 — RFC3986_PARSER only exposes compiled anchored Regexps (.regexp), not string patterns (.pattern only exists on RFC2396). I explored alternatives with the help of claude (Addressable::URI::CharacterClasses, URI::RFC3986_Parser::SEG, .source extraction) but none are clean.
I have updated the comment
| class AuthenticationProvider::GitHub < AuthenticationProvider | ||
| self.authorization_scope = :branding | ||
|
|
||
| validates :site, format: { without: /\s/, message: :contains_whitespace }, allow_blank: true |
There was a problem hiding this comment.
I'm not sure we need allow_blank here. We are already setting it in authentication_provider.rb, in the call to with_options. Right?
I assume, if the Keycloack and Auth0 subclasses validate presence on :site is precisely because not doing it would allow blank values.
What this PR does / why we need it
Upgrades the
urigem from 0.13.3 to 1.1.1 with a full RFC3986 migration. AllURI::DEFAULT_PARSER,URI::RFC2396_PARSER, andURI::REGEXPreferences have been eliminated.Key Changes & Migrations
make_regexp→UriValidator: Updated inauthentication_provider.rb,web_hook.rb, andproxy_rule.rb. Replaces unanchored regex matching withURI.parse-backed validation; correctly rejects URLs with leading/trailing whitespace.URI::DEFAULT_PARSER.pattern/URI::REGEXP::PATTERN::*→ App-owned constants: Updated inproxy.rbandproxy_rule.rb. InlinedPCHAR,SEGMENT,ABS_PATH,QUERY,UNRESERVED,ESCAPED, andRESERVEDas local variables/constants (I checked its same as originals).URI.decode/URI::RFC2396_PARSER.unescape→CGI.unescapeURIComponent: Updated across 3 files. Both produce identical output for percent-decoding;+is preserved as a literal (not converted to space).make_regexptext scanning →%r{https?://[^\s)\]>]+}: Updated inmessages_helper.rb. URL detection replaced with a simple regex; existing trailing-punctuation stripping handles over-matching.HOSTNAMEregex → RFC3986reg-nameregex: Updated inproxy.rb. Widened hostname validation to allow underscores (_).Bug Fixes & Refactoring
UriValidatorcleanup (uri_validator.rb):registryandopaquefromDEFAULT_PERMISSIONS_OF_PARTS(RFC2396 artifacts; alwaysnilunder RFC3986).generic_error_messageattr_readerwas never initialized (returningnilon blank URIs). Hardcoded:invalidand removed the deadattr_reader.service_discovery/auth_controller.rb):params[:referrer]withparams.permit(:referrer)[:referrer].Behavioral Changes
AuthenticationProviderURL validation errors changed from"Invalid URL format"to"invalid"in JSON API responses (switched from:invalid_urlonformat:toUriValidator's default:invalid).' http://example.com ') now produce two validation errors (["invalid", "can't contain whitespaces"]) instead of passing unanchored validation.hostname_rewriteinproxy.rbnow accepts hostnames containing underscores (e.g.,my_proxy.internal).Which issue(s) this PR fixes
Fixes THREESCALE-15550
Verification Steps
All test should pass: