Skip to content

Commit

Permalink
Make URL escaping more consistent
Browse files Browse the repository at this point in the history
1. Escape '%' characters in URLs - only unescaped data
   should be passed to URL helpers

2. Add an `escape_segment` helper to `Router::Utils`
   that escapes '/' characters

3. Use `escape_segment` rather than `escape_fragment`
   in optimized URL generation

4. Use `escape_segment` rather than `escape_path`
   in URL generation

For point 4 there are two exceptions. Firstly, when a route uses wildcard
segments (e.g. *foo) then we use `escape_path` as the value may contain '/'
characters. This means that wildcard routes can't be optimized. Secondly,
if a `:controller` segment is used in the path then this uses `escape_path`
as the controller may be namespaced.

Fixes #14629, #14636 and #14070.

Cherry picked from:
  e2ef83f
  a617925
  5460591

Conflicts:
	actionpack/CHANGELOG.md
  • Loading branch information
nanaya authored and pixeltrix committed Apr 20, 2014
1 parent c1d9c11 commit 8a06764
Show file tree
Hide file tree
Showing 8 changed files with 133 additions and 29 deletions.
16 changes: 16 additions & 0 deletions actionpack/CHANGELOG.md
@@ -1,3 +1,19 @@
* Make URL escaping more consistent:

1. Escape '%' characters in URLs - only unescaped data should be passed to URL helpers
2. Add an `escape_segment` helper to `Router::Utils` that escapes '/' characters
3. Use `escape_segment` rather than `escape_fragment` in optimized URL generation
4. Use `escape_segment` rather than `escape_path` in URL generation

For point 4 there are two exceptions. Firstly, when a route uses wildcard segments
(e.g. *foo) then we use `escape_path` as the value may contain '/' characters. This
means that wildcard routes can't be optimized. Secondly, if a `:controller` segment
is used in the path then this uses `escape_path` as the controller may be namespaced.

Fixes #14629, #14636 and #14070.

*Andrew White*, *Edho Arief*

* Returns null type format when format is not know and controller is using `any` * Returns null type format when format is not know and controller is using `any`
format block. format block.


Expand Down
4 changes: 4 additions & 0 deletions actionpack/lib/action_dispatch/journey/route.rb
Expand Up @@ -101,6 +101,10 @@ def required_defaults
end end
end end


def glob?
!path.spec.grep(Nodes::Star).empty?
end

def dispatcher? def dispatcher?
@dispatcher @dispatcher
end end
Expand Down
68 changes: 51 additions & 17 deletions actionpack/lib/action_dispatch/journey/router/utils.rb
@@ -1,5 +1,3 @@
require 'uri'

module ActionDispatch module ActionDispatch
module Journey # :nodoc: module Journey # :nodoc:
class Router # :nodoc: class Router # :nodoc:
Expand All @@ -25,31 +23,67 @@ def self.normalize_path(path)


# URI path and fragment escaping # URI path and fragment escaping
# http://tools.ietf.org/html/rfc3986 # http://tools.ietf.org/html/rfc3986
module UriEscape # :nodoc: class UriEncoder # :nodoc:
# Symbol captures can generate multiple path segments, so include /. ENCODE = "%%%02X".freeze
reserved_segment = '/' ENCODING = Encoding::US_ASCII
reserved_fragment = '/?' EMPTY = "".force_encoding(ENCODING).freeze
reserved_pchar = ':@&=+$,;%' DEC2HEX = (0..255).to_a.map{ |i| ENCODE % i }.map{ |s| s.force_encoding(ENCODING) }


safe_pchar = "#{URI::REGEXP::PATTERN::UNRESERVED}#{reserved_pchar}" ALPHA = "a-zA-Z".freeze
safe_segment = "#{safe_pchar}#{reserved_segment}" DIGIT = "0-9".freeze
safe_fragment = "#{safe_pchar}#{reserved_fragment}" UNRESERVED = "#{ALPHA}#{DIGIT}\\-\\._~".freeze
UNSAFE_SEGMENT = Regexp.new("[^#{safe_segment}]", false).freeze SUB_DELIMS = "!\\$&'\\(\\)\\*\\+,;=".freeze
UNSAFE_FRAGMENT = Regexp.new("[^#{safe_fragment}]", false).freeze
ESCAPED = /%[a-zA-Z0-9]{2}/.freeze

FRAGMENT = /[^#{UNRESERVED}#{SUB_DELIMS}:@\/\?]/.freeze
SEGMENT = /[^#{UNRESERVED}#{SUB_DELIMS}:@]/.freeze
PATH = /[^#{UNRESERVED}#{SUB_DELIMS}:@\/]/.freeze

def escape_fragment(fragment)
escape(fragment, FRAGMENT)
end

def escape_path(path)
escape(path, PATH)
end

def escape_segment(segment)
escape(segment, SEGMENT)
end

def unescape_uri(uri)
uri.gsub(ESCAPED) { [$&[1, 2].hex].pack('C') }.force_encoding(uri.encoding)
end

protected
def escape(component, pattern)
component.gsub(pattern){ |unsafe| percent_encode(unsafe) }.force_encoding(ENCODING)
end

def percent_encode(unsafe)
safe = EMPTY.dup
unsafe.each_byte { |b| safe << DEC2HEX[b] }
safe
end
end end


Parser = URI::Parser.new ENCODER = UriEncoder.new


def self.escape_path(path) def self.escape_path(path)
Parser.escape(path.to_s, UriEscape::UNSAFE_SEGMENT) ENCODER.escape_path(path.to_s)
end

def self.escape_segment(segment)
ENCODER.escape_segment(segment.to_s)
end end


def self.escape_fragment(fragment) def self.escape_fragment(fragment)
Parser.escape(fragment.to_s, UriEscape::UNSAFE_FRAGMENT) ENCODER.escape_fragment(fragment.to_s)
end end


def self.unescape_uri(uri) def self.unescape_uri(uri)
Parser.unescape(uri) ENCODER.unescape_uri(uri)
end end
end end
end end
Expand Down
21 changes: 17 additions & 4 deletions actionpack/lib/action_dispatch/journey/visitors.rb
Expand Up @@ -114,19 +114,26 @@ def initialize(options)
end end


private private
def escape_path(value)
Router::Utils.escape_path(value)
end

def escape_segment(value)
Router::Utils.escape_segment(value)
end


def visit(node, optional = false) def visit(node, optional = false)
case node.type case node.type
when :LITERAL, :SLASH, :DOT when :LITERAL, :SLASH, :DOT
node.left node.left
when :STAR when :STAR
visit(node.left) visit_STAR(node.left)
when :GROUP when :GROUP
visit(node.left, true) visit(node.left, true)
when :CAT when :CAT
visit_CAT(node, optional) visit_CAT(node, optional)
when :SYMBOL when :SYMBOL
visit_SYMBOL(node) visit_SYMBOL(node, node.to_sym)
end end
end end


Expand All @@ -141,9 +148,15 @@ def visit_CAT(node, optional)
end end
end end


def visit_SYMBOL(node) def visit_STAR(node)
if value = options[node.to_sym] if value = options[node.to_sym]
Router::Utils.escape_path(value) escape_path(value)
end
end

def visit_SYMBOL(node, name)
if value = options[name]
name == :controller ? escape_path(value) : escape_segment(value)
end end
end end
end end
Expand Down
4 changes: 2 additions & 2 deletions actionpack/lib/action_dispatch/routing/route_set.rb
Expand Up @@ -155,7 +155,7 @@ def self.create(route, options)
end end


def self.optimize_helper?(route) def self.optimize_helper?(route)
route.requirements.except(:controller, :action).empty? !route.glob? && route.requirements.except(:controller, :action).empty?
end end


class OptimizedUrlHelper < UrlHelper # :nodoc: class OptimizedUrlHelper < UrlHelper # :nodoc:
Expand Down Expand Up @@ -194,7 +194,7 @@ def optimized_helper(args)
end end


def replace_segment(params, segment) def replace_segment(params, segment)
Symbol === segment ? @klass.escape_fragment(params[segment]) : segment Symbol === segment ? @klass.escape_segment(params[segment]) : segment
end end


def optimize_routes_generation?(t) def optimize_routes_generation?(t)
Expand Down
28 changes: 25 additions & 3 deletions actionpack/test/dispatch/routing_test.rb
Expand Up @@ -3596,16 +3596,16 @@ class TestUriPathEscaping < ActionDispatch::IntegrationTest
include Routes.url_helpers include Routes.url_helpers
def app; Routes end def app; Routes end


test 'escapes generated path segment' do test 'escapes slash in generated path segment' do
assert_equal '/a%20b/c+d', segment_path(:segment => 'a b/c+d') assert_equal '/a%20b%2Fc+d', segment_path(:segment => 'a b/c+d')
end end


test 'unescapes recognized path segment' do test 'unescapes recognized path segment' do
get '/a%20b%2Fc+d' get '/a%20b%2Fc+d'
assert_equal 'a b/c+d', @response.body assert_equal 'a b/c+d', @response.body
end end


test 'escapes generated path splat' do test 'does not escape slash in generated path splat' do
assert_equal '/a%20b/c+d', splat_path(:splat => 'a b/c+d') assert_equal '/a%20b/c+d', splat_path(:splat => 'a b/c+d')
end end


Expand Down Expand Up @@ -3790,6 +3790,8 @@ class TestOptimizedNamedRoutes < ActionDispatch::IntegrationTest
get '/post(/:action(/:id))' => ok, as: :posts get '/post(/:action(/:id))' => ok, as: :posts
get '/:foo/:foo_type/bars/:id' => ok, as: :bar get '/:foo/:foo_type/bars/:id' => ok, as: :bar
get '/projects/:id.:format' => ok, as: :project get '/projects/:id.:format' => ok, as: :project
get '/pages/:id' => ok, as: :page
get '/wiki/*page' => ok, as: :wiki
end end
end end


Expand Down Expand Up @@ -3822,6 +3824,26 @@ def app; Routes end
assert_equal '/projects/1.json', Routes.url_helpers.project_path(1, :json) assert_equal '/projects/1.json', Routes.url_helpers.project_path(1, :json)
assert_equal '/projects/1.json', project_path(1, :json) assert_equal '/projects/1.json', project_path(1, :json)
end end

test 'segments with question marks are escaped' do
assert_equal '/pages/foo%3Fbar', Routes.url_helpers.page_path('foo?bar')
assert_equal '/pages/foo%3Fbar', page_path('foo?bar')
end

test 'segments with slashes are escaped' do
assert_equal '/pages/foo%2Fbar', Routes.url_helpers.page_path('foo/bar')
assert_equal '/pages/foo%2Fbar', page_path('foo/bar')
end

test 'glob segments with question marks are escaped' do
assert_equal '/wiki/foo%3Fbar', Routes.url_helpers.wiki_path('foo?bar')
assert_equal '/wiki/foo%3Fbar', wiki_path('foo?bar')
end

test 'glob segments with slashes are not escaped' do
assert_equal '/wiki/foo/bar', Routes.url_helpers.wiki_path('foo/bar')
assert_equal '/wiki/foo/bar', wiki_path('foo/bar')
end
end end


class TestNamedRouteUrlHelpers < ActionDispatch::IntegrationTest class TestNamedRouteUrlHelpers < ActionDispatch::IntegrationTest
Expand Down
8 changes: 6 additions & 2 deletions actionpack/test/journey/router/utils_test.rb
Expand Up @@ -5,11 +5,15 @@ module Journey
class Router class Router
class TestUtils < ActiveSupport::TestCase class TestUtils < ActiveSupport::TestCase
def test_path_escape def test_path_escape
assert_equal "a/b%20c+d", Utils.escape_path("a/b c+d") assert_equal "a/b%20c+d%25", Utils.escape_path("a/b c+d%")
end

def test_segment_escape
assert_equal "a%2Fb%20c+d%25", Utils.escape_segment("a/b c+d%")
end end


def test_fragment_escape def test_fragment_escape
assert_equal "a/b%20c+d?e", Utils.escape_fragment("a/b c+d?e") assert_equal "a/b%20c+d%25?e", Utils.escape_fragment("a/b c+d%?e")
end end


def test_uri_unescape def test_uri_unescape
Expand Down
13 changes: 12 additions & 1 deletion actionpack/test/journey/router_test.rb
Expand Up @@ -367,7 +367,18 @@ def test_generate_escapes
nil, { :controller => "tasks", nil, { :controller => "tasks",
:action => "a/b c+d", :action => "a/b c+d",
}, {}) }, {})
assert_equal '/tasks/a/b%20c+d', path assert_equal '/tasks/a%2Fb%20c+d', path
end

def test_generate_escapes_with_namespaced_controller
path = Path::Pattern.new '/:controller(/:action)'
@router.routes.add_route @app, path, {}, {}, {}

path, _ = @formatter.generate(:path_info,
nil, { :controller => "admin/tasks",
:action => "a/b c+d",
}, {})
assert_equal '/admin/tasks/a%2Fb%20c+d', path
end end


def test_generate_extra_params def test_generate_extra_params
Expand Down

0 comments on commit 8a06764

Please sign in to comment.