diff --git a/context/getting-started.md b/context/getting-started.md index 4d0d2734..11ec2443 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -36,7 +36,7 @@ Utopia includes a redirection middleware to redirect all root-level requests to Application = Utopia::Application.build do use Utopia::Redirection::Rewrite, - "/" => "/welcome/index" + {"/" => "/welcome/index"} end ``` diff --git a/context/middleware.md b/context/middleware.md index 018bd83c..a31492bf 100644 --- a/context/middleware.md +++ b/context/middleware.md @@ -18,18 +18,25 @@ use Utopia::Static, ## Redirection -The {ruby Utopia::Redirection} middleware is used for redirecting requests based on patterns and status codes. +The redirection middleware is used for redirecting requests based on paths. ~~~ ruby # String (fast hash lookup) rewriting: use Utopia::Redirection::Rewrite, - '/' => '/welcome/index' + {'/' => '/welcome/index'} # Redirect directories (e.g. /) to an index file (e.g. /index): use Utopia::Redirection::DirectoryIndex, index: 'index.html' -# Redirect (error) status codes to actual pages: +# Redirect matching path prefixes: +use Utopia::Redirection::Moved, + '/old/', '/new/' +~~~ + +The {ruby Utopia::Redirection::Errors} middleware maps unhandled error responses to internal error documents. It retains the original response status and does not issue a client-visible redirect: + +~~~ ruby use Utopia::Redirection::Errors, 404 => '/errors/file-not-found' ~~~ diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 4d0d2734..11ec2443 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -36,7 +36,7 @@ Utopia includes a redirection middleware to redirect all root-level requests to Application = Utopia::Application.build do use Utopia::Redirection::Rewrite, - "/" => "/welcome/index" + {"/" => "/welcome/index"} end ``` diff --git a/guides/middleware/readme.md b/guides/middleware/readme.md index 40152254..51ab2191 100644 --- a/guides/middleware/readme.md +++ b/guides/middleware/readme.md @@ -18,18 +18,25 @@ use Utopia::Static, ## Redirection -The {ruby Utopia::Redirection} middleware is used for redirecting requests based on patterns and status codes. +The redirection middleware is used for redirecting requests based on paths. ~~~ ruby # String (fast hash lookup) rewriting: use Utopia::Redirection::Rewrite, - '/' => '/welcome/index' + {'/' => '/welcome/index'} # Redirect directories (e.g. /) to an index file (e.g. /index): use Utopia::Redirection::DirectoryIndex, index: 'index.html' -# Redirect (error) status codes to actual pages: +# Redirect matching path prefixes: +use Utopia::Redirection::Moved, + '/old/', '/new/' +~~~ + +The {ruby Utopia::Redirection::Errors} middleware maps unhandled error responses to internal error documents. It retains the original response status and does not issue a client-visible redirect: + +~~~ ruby use Utopia::Redirection::Errors, 404 => '/errors/file-not-found' ~~~ diff --git a/lib/utopia/content/links.rb b/lib/utopia/content/links.rb index 33215ca4..07d3a76b 100644 --- a/lib/utopia/content/links.rb +++ b/lib/utopia/content/links.rb @@ -6,6 +6,7 @@ require_relative "link" require "concurrent/map" +require "protocol/url/path" module Utopia module Content @@ -128,7 +129,10 @@ def metadata(path) # @parameter path [Utopia::Path | String] The path. # @returns [Resolver] The link resolver. def links(path) - @links_cache.fetch_or_store(path.to_s) do + path = Path.create(path) + key = path.dup.freeze + + @links_cache.fetch_or_store(key) do load_links(path) end end @@ -170,13 +174,18 @@ def initialize(links, top = Path.root) @top = top - # top.components.first == '', but this isn't a problem here. - @path = File.join(links.root, top.components) - @ordered = [] @named = {} - if File.directory?(@path) + begin + # Preserve URL segment boundaries when mapping the route to the filesystem: + url_path = Protocol::URL::Path.for(top.components) + @path = url_path.local_path(links.root) + rescue ArgumentError + @path = nil + end + + if @path && File.directory?(@path) @metadata = links.metadata(@path) load_links(@metadata.dup) do |link| diff --git a/lib/utopia/content/middleware.rb b/lib/utopia/content/middleware.rb index 37472a0b..3fda37a7 100644 --- a/lib/utopia/content/middleware.rb +++ b/lib/utopia/content/middleware.rb @@ -123,17 +123,19 @@ def respond(link, request, localization: request.localization) # @parameter request [Utopia::Request] The request. # @returns [Protocol::HTTP::Response] The content, redirect, or downstream response. def call(request) - path = Path.create(request.path_info) + path = Path.create(request.url.path) # Check if the request is to a non-specific index. This only works for requests with a given name: basename = path.basename - directory_path = File.join(@root, path.dirname.components, basename) + directory_path = local_path(path) # If the request for /foo/bar is actually a directory, rewrite it to /foo/bar/index: - if File.directory? directory_path - index_path = [basename, INDEX] - - return Utopia::Response[307, {HTTP::LOCATION => path.dirname.join(index_path).to_s}, []] + if directory_path + if File.directory?(directory_path) + index_path = [basename, INDEX] + + return Utopia::Response[307, {HTTP::LOCATION => path.dirname.join(index_path).to_s}, []] + end end response = resolve_localized(request) do |localization| @@ -153,6 +155,14 @@ def call(request) private + # Resolve a decoded content path without losing its URL segment boundaries: + def local_path(path) + url_path = Protocol::URL::Path.for(path.components) + return url_path.local_path(@root) + rescue ArgumentError + return nil + end + def lookup_content(name, parent_path) if String === name && name.index("/") name = Path.create(name) diff --git a/lib/utopia/controller/middleware.rb b/lib/utopia/controller/middleware.rb index 8c732829..d258f331 100644 --- a/lib/utopia/controller/middleware.rb +++ b/lib/utopia/controller/middleware.rb @@ -15,6 +15,7 @@ require_relative "actions" require "concurrent/map" +require "protocol/url/path" module Utopia # A middleware which loads controller classes and invokes functionality based on the requested path. @@ -49,14 +50,22 @@ def freeze # Fetch the controller for the given relative path. May be cached. def lookup_controller(path) - @controller_cache.fetch_or_store(path.to_s) do + key = path.dup.freeze + + @controller_cache.fetch_or_store(key) do load_controller_file(path) end end # Loads the controller file for the given relative url_path. def load_controller_file(uri_path) - base_path = File.join(@root, uri_path.components) + begin + # Preserve URL segment boundaries when mapping the route to the filesystem: + url_path = Protocol::URL::Path.for(uri_path.components) + base_path = url_path.local_path(@root) + rescue ArgumentError + return nil + end controller_path = File.join(base_path, CONTROLLER_RB) # puts "load_controller_file(#{path.inspect}) => #{controller_path}" @@ -86,7 +95,7 @@ def load_controller_file(uri_path) # Invoke the controller layer for a given request. The request path may be rewritten. def invoke_controllers(request) - request_path = Path.from_string(request.path_info) + request_path = Path[request.url.path] # The request path must be absolute. We could handle this internally but it is probably better for this to be an error: raise ArgumentError.new("Invalid request path #{request_path}") unless request_path.absolute? @@ -114,8 +123,8 @@ def invoke_controllers(request) end end - # Controllers can directly modify relative_path, which is copied into controller_path. The controllers may have rewriten the path so we update the path info: - request.path_info = controller_path.to_s + # Controllers can directly modify the remaining path, so update the current request URL: + request.url = request.url.with(path: Protocol::URL::Path.for(controller_path.components)) # No controller gave a useful result: return nil diff --git a/lib/utopia/exceptions/handler.rb b/lib/utopia/exceptions/handler.rb index 8662be7c..55ed0cdc 100644 --- a/lib/utopia/exceptions/handler.rb +++ b/lib/utopia/exceptions/handler.rb @@ -44,7 +44,7 @@ def call(request) # We do an internal redirection to the error location: error_request = request.with( method: "GET", - path_info: @location + url: request.url.with(path: @location) ) error_request.exception = exception diff --git a/lib/utopia/exceptions/mailer.rb b/lib/utopia/exceptions/mailer.rb index 0739b0a4..df2a35d6 100644 --- a/lib/utopia/exceptions/mailer.rb +++ b/lib/utopia/exceptions/mailer.rb @@ -79,7 +79,7 @@ def call(request) :referrer, :path, :request_path, - :path_info, + :url, :query, :user_agent, ] diff --git a/lib/utopia/invalid_path_error.rb b/lib/utopia/invalid_path_error.rb new file mode 100644 index 00000000..00ad38d2 --- /dev/null +++ b/lib/utopia/invalid_path_error.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/http/error" + +module Utopia + # Raised when an external request path cannot be normalized safely. + class InvalidPathError < Protocol::HTTP::Error + include Protocol::HTTP::BadRequest + + # Initialize the invalid path error. + # @parameter path [String] The invalid request path. + # @parameter message [String] The reason the path is invalid. + def initialize(path, message) + @path = path + + super("Invalid request path #{path.inspect}: #{message}") + end + + # The invalid request path. + attr :path + end +end diff --git a/lib/utopia/localization/middleware.rb b/lib/utopia/localization/middleware.rb index 579401eb..3fea759f 100644 --- a/lib/utopia/localization/middleware.rb +++ b/lib/utopia/localization/middleware.rb @@ -111,13 +111,14 @@ def host_preferred_locales(request) # @parameter request [Utopia::Request] The application request. # @returns [Array(Utopia::Request, String | Nil)] The request and extracted locale. def extract_path_locale(request) - path = Path[request.path_info] + path = Path[request.url.path] if request_locale = @all_locales.patterns[path.first] # Remove the localization prefix: path.delete_at(0) - return request.with(path_info: path.to_s), request_locale + url_path = Protocol::URL::Path.for(path.components) + return request.with(url: request.url.with(path: url_path)), request_locale else return request, nil end @@ -147,8 +148,8 @@ def browser_preferred_locales(request) # @returns [Boolean] Whether the path is eligible for localization. def localized?(request) # Ignore requests which match the ignored paths: - path_info = request.path_info - return false if @ignore.any?{|pattern| path_info[pattern] != nil} + path = request.url.path.encoded + return false if @ignore.any?{|pattern| path[pattern] != nil} return true end diff --git a/lib/utopia/localization/resolver.rb b/lib/utopia/localization/resolver.rb index 67b3ac80..d4b2b590 100644 --- a/lib/utopia/localization/resolver.rb +++ b/lib/utopia/localization/resolver.rb @@ -37,7 +37,7 @@ def localized_response(request, response, localization) if locale = localization.locale response.headers[CONTENT_LANGUAGE] = locale - response.headers[CONTENT_LOCATION] = localization.localized_path(request.path_info) + response.headers[CONTENT_LOCATION] = localization.localized_path(request.url.path.encoded) end return response diff --git a/lib/utopia/path.rb b/lib/utopia/path.rb index 47d3e346..194372ce 100644 --- a/lib/utopia/path.rb +++ b/lib/utopia/path.rb @@ -3,6 +3,8 @@ # Released under the MIT License. # Copyright, 2009-2025, by Samuel Williams. +require "protocol/url/path" + module Utopia # Represents a path as an array of path components. Useful for efficient URL manipulation. class Path @@ -72,11 +74,11 @@ def shortest_path(root) self.class.shortest_path(self, root) end - # Decode URL-encoded path content, converting `+` to whitespace and percent-encoded bytes to their corresponding characters. + # Decode URL-encoded path content, preserving literal `+` characters. # @parameter string [String] The encoded content. # @returns [String] The decoded content. def self.unescape(string) - string.tr("+", " ").gsub(/((?:%[0-9a-fA-F]{2})+)/n) do + string.gsub(/((?:%[0-9a-fA-F]{2})+)/n) do [$1.delete("%")].pack("H*") end end @@ -89,12 +91,14 @@ def self.[] path end # Convert a path value into an array of components. - # @parameter path [Utopia::Path | String] The path. + # @parameter path [Utopia::Path | Protocol::URL::Path | String] The path. # @returns [Array] The path components. def self.split(path) case path when Path return path.to_a + when Protocol::URL::Path + return path.components when Array return path when String @@ -105,10 +109,10 @@ def self.split(path) end # Construct a path from URL-encoded text. This is an optimized direct entry point used by controller invocations. - # @parameter string [String] The encoded path. + # @parameter path [Protocol::URL::Path | String] The encoded path. # @returns [Path] The decoded path. - def self.from_string(string) - self.new(unescape(string).split(SEPARATOR, -1)) + def self.from_string(path) + self.new(Protocol::URL::Path[path].components) end # Load a path from its serialized form. @@ -126,16 +130,18 @@ def self.dump(instance) end # Coerce a value into a path. - # @parameter path [Path | Array | String | Object | Nil] The value to coerce. + # @parameter path [Path | Protocol::URL::Path | Array | String | Object | Nil] The value to coerce. # @returns [Path | Nil] The coerced path. def self.create(path) case path when Path return path + when Protocol::URL::Path + return self.new(path.components) when Array return self.new(path) when String - return self.new(unescape(path).split(SEPARATOR, -1)) + return self.from_string(path) when nil return nil else diff --git a/lib/utopia/redirection.rb b/lib/utopia/redirection.rb index af84d8e7..a372b67f 100644 --- a/lib/utopia/redirection.rb +++ b/lib/utopia/redirection.rb @@ -3,241 +3,9 @@ # Released under the MIT License. # Copyright, 2009-2026, by Samuel Williams. -require_relative "middleware" -require_relative "request" -require_relative "response" - -module Utopia - # A middleware which assists with redirecting from one path to another. - module Redirection - # An error handler fails to redirect to a valid page. - class RequestFailure < StandardError - # Describe a failed attempt to render an error document. - # @parameter resource_path [Object] The resource path. - # @parameter resource_status [Object] The resource status. - # @parameter error_path [Object] The error path. - # @parameter error_status [Object] The error status. - def initialize(resource_path, resource_status, error_path, error_status) - @resource_path = resource_path - @resource_status = resource_status - - @error_path = error_path - @error_status = error_status - - super "Requested resource #{@resource_path} resulted in a #{@resource_status} error. Requested error handler #{@error_path} resulted in a #{@error_status} error." - end - end - - # A middleware which performs internal redirects based on error status codes. - class Errors < Protocol::HTTP::Middleware - # @param codes [Hash] The redirection path for a given error code. - def initialize(app, codes = {}) - super(app) - - @codes = codes - end - - # Freeze this object and its internal state. - # @returns [self] This object. - def freeze - return self if frozen? - - @codes.freeze - - super - end - - # Check whether the response status requires error handling. - # @parameter response [Protocol::HTTP::Response] The response. - # @returns [Boolean] Whether the response is an error without handler-provided headers. - def unhandled_error?(response) - response.status >= 400 && response.headers.empty? - end - - # Replace an unhandled error response with its configured error document. - # @parameter request [Utopia::Request] The request. - # @returns [Protocol::HTTP::Response] The original or error-document response. - # @raises [RequestFailure] If the configured error document also fails. - def call(request) - response = Response.wrap(@delegate.call(request)) - - if unhandled_error?(response) && location = @codes[response.status] - resource_status = response.status - - # The original response is replaced by the configured error document: - response.close - - error_request = request.with(method: "GET", path_info: location) - - error_response = Response.wrap(@delegate.call(error_request)) - - if error_response.status >= 400 - error = RequestFailure.new(request.path_info, resource_status, location, error_response.status) - - # The failed error document will not be returned to the server: - error_response.close(error) - - raise error - else - # Feed the error code back with the error document: - error_response.status = resource_status - return error_response - end - else - return response - end - end - end - - # We cache 301 redirects for 24 hours. - DEFAULT_MAX_AGE = 3600*24 - - # A basic client-side redirect. - class ClientRedirect < Protocol::HTTP::Middleware - # Initialize client-side redirection behavior. - # @parameter app [Interface(:call)] The downstream application. - # @parameter status [Integer] The status. - # @parameter max_age [Integer] The maximum cache age in seconds. - def initialize(app, status: 307, max_age: DEFAULT_MAX_AGE) - super(app) - - @status = status - @max_age = max_age - end - - # Freeze this object and its internal state. - # @returns [self] This object. - def freeze - return self if frozen? - - @status.freeze - @max_age.freeze - - super - end - - attr :status - attr :max_age - - # Build the cache control header value. - # @returns [String] The cache-control value. - def cache_control - # http://jacquesmattheij.com/301-redirects-a-dangerous-one-way-street - "max-age=#{self.max_age}" - end - - # Build headers for a client redirect. - # @parameter location [String] The redirect location. - # @returns [Hash(String, String)] The redirect headers. - def make_headers(location) - { - HTTP::LOCATION => location, - HTTP::CACHE_CONTROL => self.cache_control - } - end - - # Build a redirect response for the given location. - # @parameter location [String] The redirect location. - # @returns [Protocol::HTTP::Response] The redirect response. - def redirect(location) - return Response[self.status, self.make_headers(location), []] - end - - # Resolve a normalized request path to a redirect response. - # @parameter path [String] The normalized request path. - # @returns [Protocol::HTTP::Response | false] The redirect response, or `false` by default. - def [] path - false - end - - # Redirect a normalized request path when it matches, otherwise invoke the application. - # @parameter request [Utopia::Request] The request. - # @returns [Protocol::HTTP::Response] The redirect or downstream response. - def call(request) - # Normalize the path to remove redundant slashes, `.` and `..` segments. - # This prevents protocol-relative redirect URLs (e.g. //evil.com/index) - # from being generated when PATH_INFO contains a double leading slash. - path = Path.create(request.path_info).simplify.to_s - - if redirection = self[path] - return redirection - end - - return @delegate.call(request) - end - end - - # Redirect urls that end with a `/`, e.g. directories. - class DirectoryIndex < ClientRedirect - # Initialize directory-index redirection. - # @parameter app [Interface(:call)] The downstream application. - # @parameter index [Integer] The index. - def initialize(app, index: "index") - @index = index - - super(app) - end - - # Redirect a directory path to its index path. - # @parameter path [String] The normalized request path. - # @returns [Protocol::HTTP::Response | Nil] The redirect response when the path ends with `/`. - def [] path - if path.end_with?("/") - return redirect(path + @index) - end - end - end - - # Rewrite requests that match the given pattern to a single destination. - class Rewrite < ClientRedirect - # Initialize exact-path redirections. - # @parameter app [Interface(:call)] The downstream application. - # @parameter patterns [Hash] The path rewrite patterns. - # @parameter status [Integer] The status. - def initialize(app, patterns, status: 301) - @patterns = patterns - - super(app, status: status) - end - - # Redirect a path found in the rewrite map. - # @parameter path [String] The normalized request path. - # @returns [Protocol::HTTP::Response | Nil] The redirect response when the path is mapped. - def [] path - if location = @patterns[path] - return redirect(location) - end - end - end - - # Rewrite requests that match the given pattern to a new prefix. - class Moved < ClientRedirect - # Initialize prefix redirection behavior. - # @parameter app [Interface(:call)] The downstream application. - # @parameter pattern [Regexp] The path pattern. - # @parameter prefix [String] The prefix. - # @parameter status [Integer] The status. - # @parameter flatten [bool] Whether to flatten the rewritten path. - def initialize(app, pattern, prefix, status: 301, flatten: false) - @pattern = pattern - @prefix = prefix - @flatten = flatten - - super(app, status: status) - end - - # Redirect a matching path to the configured prefix. - # @parameter path [String] The normalized request path. - # @returns [Protocol::HTTP::Response | Nil] The redirect response when the pattern matches. - def [] path - if path.start_with?(@pattern) - if @flatten - return redirect(@prefix) - else - return redirect(path.sub(@pattern, @prefix)) - end - end - end - end - end -end +require_relative "redirection/request_failure" +require_relative "redirection/errors" +require_relative "redirection/client_redirect" +require_relative "redirection/directory_index" +require_relative "redirection/rewrite" +require_relative "redirection/moved" diff --git a/lib/utopia/redirection/client_redirect.rb b/lib/utopia/redirection/client_redirect.rb new file mode 100644 index 00000000..5758ffdb --- /dev/null +++ b/lib/utopia/redirection/client_redirect.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2009-2026, by Samuel Williams. + +require "protocol/http/middleware" + +require_relative "../response" + +module Utopia + # Redirects requests and error responses to configured locations. + module Redirection + # The common implementation for client-visible redirects. + class ClientRedirect < Protocol::HTTP::Middleware + # The default redirect cache lifetime is 24 hours. + MAX_AGE = 3600*24 + + # Initialize client-side redirection behavior. + # @parameter delegate [Protocol::HTTP::Middleware] The downstream middleware. + # @parameter status [Integer] The redirect response status. + # @parameter max_age [Integer] The redirect cache lifetime in seconds. + def initialize(delegate, status: 307, max_age: MAX_AGE) + super(delegate) + + @status = status + @max_age = max_age + end + + # Freeze this object and its internal state. + # @returns [self] This object. + def freeze + return self if frozen? + + @status.freeze + @max_age.freeze + + return super + end + + # The redirect response status. + attr :status + + # The redirect cache lifetime in seconds. + attr :max_age + + # Build the cache-control header value. + # @returns [String] The cache-control value. + def cache_control + # http://jacquesmattheij.com/301-redirects-a-dangerous-one-way-street + return "max-age=#{self.max_age}" + end + + # Build headers for a client redirect. + # @parameter location [String] The redirect location. + # @returns [Hash(String, String)] The redirect headers. + def make_headers(location) + return { + HTTP::LOCATION => location, + HTTP::CACHE_CONTROL => self.cache_control + } + end + + # Build a redirect response for the given location. + # @parameter location [String] The redirect location. + # @returns [Protocol::HTTP::Response] The redirect response. + def redirect(location) + return Response[self.status, self.make_headers(location), []] + end + + # Resolve a normalized request path to a redirect response. + # @parameter path [String] The normalized request path. + # @returns [Protocol::HTTP::Response | false] The redirect response, or `false` by default. + def [](path) + return false + end + + # Redirect a matching request path, otherwise invoke the delegate. + # @parameter request [Utopia::Request] The normalized request. + # @returns [Protocol::HTTP::Response] The redirect or downstream response. + def call(request) + if redirection = self[request.url.path.encoded] + return redirection + end + + return super + end + end + end +end diff --git a/lib/utopia/redirection/directory_index.rb b/lib/utopia/redirection/directory_index.rb new file mode 100644 index 00000000..219fcc05 --- /dev/null +++ b/lib/utopia/redirection/directory_index.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2009-2026, by Samuel Williams. + +require_relative "client_redirect" + +module Utopia + module Redirection + # Redirect directory paths to an index path. + class DirectoryIndex < ClientRedirect + # Initialize directory-index redirection. + # @parameter delegate [Protocol::HTTP::Middleware] The downstream middleware. + # @parameter index [String] The index path component. + def initialize(delegate, index: "index") + @index = index + + super(delegate) + end + + # Redirect a directory path to its index path. + # @parameter path [String] The normalized request path. + # @returns [Protocol::HTTP::Response | Nil] The redirect response when the path ends with `/`. + def [](path) + if path.end_with?("/") + return redirect(path + @index) + end + end + end + end +end diff --git a/lib/utopia/redirection/errors.rb b/lib/utopia/redirection/errors.rb new file mode 100644 index 00000000..96f98ab7 --- /dev/null +++ b/lib/utopia/redirection/errors.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2009-2026, by Samuel Williams. + +require_relative "../middleware" +require_relative "../request" +require_relative "../response" +require_relative "request_failure" + +module Utopia + module Redirection + # Performs internal redirections for unhandled error responses. + class Errors < Protocol::HTTP::Middleware + # Initialize internal error redirections. + # @parameter delegate [Protocol::HTTP::Middleware] The downstream middleware. + # @parameter codes [Hash(Integer, String)] The internal path for each error status. + def initialize(delegate, codes = {}) + super(delegate) + + @codes = codes + end + + # Freeze this object and its internal state. + # @returns [self] This object. + def freeze + return self if frozen? + + @codes.freeze + + return super + end + + # Check whether the response status requires error handling. + # @parameter response [Protocol::HTTP::Response] The response. + # @returns [Boolean] Whether the response is an error without handler-provided headers. + def unhandled_error?(response) + response.status >= 400 && response.headers.empty? + end + + # Replace an unhandled error response with its configured error document. + # @parameter request [Utopia::Request] The request. + # @parameter response [Protocol::HTTP::Response] The unhandled error response. + # @parameter location [String] The configured error document path. + # @returns [Protocol::HTTP::Response] The error-document response. + # @raises [RequestFailure] If the configured error document also fails. + def replace_error(request, response, location) + resource_status = response.status + + # The original response is replaced by the configured error document: + response.close + + error_request = request.with(method: "GET", url: request.url.with(path: location)) + error_response = Response.wrap(@delegate.call(error_request)) + + if error_response.status >= 400 + error = RequestFailure.new(request.url.path.encoded, resource_status, location, error_response.status) + + # The failed error document will not be returned to the server: + error_response.close(error) + + raise error + end + + # Feed the error code back with the error document: + error_response.status = resource_status + return error_response + end + + # Replace configured unhandled responses through an internal request. + # @parameter request [Utopia::Request] The request. + # @returns [Protocol::HTTP::Response] The original or error-document response. + def call(request) + response = Response.wrap(@delegate.call(request)) + + if unhandled_error?(response) && location = @codes[response.status] + return replace_error(request, response, location) + end + + return response + end + end + end +end diff --git a/lib/utopia/redirection/moved.rb b/lib/utopia/redirection/moved.rb new file mode 100644 index 00000000..eb378544 --- /dev/null +++ b/lib/utopia/redirection/moved.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2009-2026, by Samuel Williams. + +require_relative "client_redirect" + +module Utopia + module Redirection + # Redirect request paths from one prefix to another. + class Moved < ClientRedirect + # Initialize prefix redirection behavior. + # @parameter delegate [Protocol::HTTP::Middleware] The downstream middleware. + # @parameter pattern [String] The path prefix to replace. + # @parameter prefix [String] The replacement prefix. + # @parameter status [Integer] The redirect response status. + # @parameter flatten [Boolean] Whether to discard the matched path suffix. + def initialize(delegate, pattern, prefix, status: 301, flatten: false) + @pattern = pattern + @prefix = prefix + @flatten = flatten + + super(delegate, status: status) + end + + # Redirect a matching path to the configured prefix. + # @parameter path [String] The normalized request path. + # @returns [Protocol::HTTP::Response | Nil] The redirect response when the pattern matches. + def [](path) + if path.start_with?(@pattern) + if @flatten + return redirect(@prefix) + else + return redirect(path.sub(@pattern, @prefix)) + end + end + end + end + end +end diff --git a/lib/utopia/redirection/request_failure.rb b/lib/utopia/redirection/request_failure.rb new file mode 100644 index 00000000..40d95286 --- /dev/null +++ b/lib/utopia/redirection/request_failure.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Utopia + module Redirection + # An error handler failed to produce a valid response. + class RequestFailure < StandardError + # Describe a failed attempt to render an error document. + # @parameter resource_path [Object] The resource path. + # @parameter resource_status [Object] The resource status. + # @parameter error_path [Object] The error path. + # @parameter error_status [Object] The error status. + def initialize(resource_path, resource_status, error_path, error_status) + @resource_path = resource_path + @resource_status = resource_status + + @error_path = error_path + @error_status = error_status + + super "Requested resource #{@resource_path} resulted in a #{@resource_status} error. Requested error handler #{@error_path} resulted in a #{@error_status} error." + end + end + end +end diff --git a/lib/utopia/redirection/rewrite.rb b/lib/utopia/redirection/rewrite.rb new file mode 100644 index 00000000..a170ad6f --- /dev/null +++ b/lib/utopia/redirection/rewrite.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2009-2026, by Samuel Williams. + +require_relative "client_redirect" + +module Utopia + module Redirection + # Redirect exact request paths using a lookup table. + class Rewrite < ClientRedirect + # Initialize exact-path redirections. + # @parameter delegate [Protocol::HTTP::Middleware] The downstream middleware. + # @parameter patterns [Hash(String, String)] The path rewrite patterns. + # @parameter status [Integer] The redirect response status. + def initialize(delegate, patterns, status: 301) + @patterns = patterns + + super(delegate, status: status) + end + + # Redirect a path found in the rewrite map. + # @parameter path [String] The normalized request path. + # @returns [Protocol::HTTP::Response | Nil] The redirect response when the path is mapped. + def [](path) + if location = @patterns[path] + return redirect(location) + end + end + end + end +end diff --git a/lib/utopia/request.rb b/lib/utopia/request.rb index 6fe3ccf6..35b83734 100644 --- a/lib/utopia/request.rb +++ b/lib/utopia/request.rb @@ -6,25 +6,33 @@ require "stringio" require "protocol/http/request" +require "protocol/url" require "protocol/url/form_data/parser" +require_relative "invalid_path_error" + module Utopia # Utopia's application-facing request wrapper. # # Protocol request methods are delegated to the underlying request; parsing # and application conveniences live here rather than on protocol-http itself. + # External URL paths are normalized once during construction; subsequent + # request target assignments are trusted internal rewrites. class Request + ASTERISK_PATH = Protocol::URL::Path["*"].freeze + private_constant :ASTERISK_PATH + # Build a Utopia request from the given protocol request arguments. def self.[](*arguments) self.new(Protocol::HTTP::Request[*arguments]) end - # Initialize the request proxy. + # Initialize the request proxy and normalize its external request path. # @parameter delegate [Protocol::HTTP::Request] The underlying protocol request. - # @parameter request_path [String | Nil] The original path before internal rewrites. - def initialize(delegate, request_path: nil) + def initialize(delegate) @delegate = delegate - @request_path = request_path + @url = nil + @request_path = nil @session = nil @variables = nil @localization = nil @@ -32,6 +40,8 @@ def initialize(delegate, request_path: nil) @query_arguments = nil @cookies = nil + + parse_url! end # The underlying protocol request. @@ -49,11 +59,8 @@ def initialize_copy(other) # Assign the request path including query string. def path= value - if value != @delegate.path - @request_path ||= self.path_info - end - @delegate.path = value + @url = parse_url(value) @query_arguments = nil end @@ -62,34 +69,14 @@ def post? self.method == "POST" end - # The request path without the query string. - def path_info - self.path&.split("?", 2)&.first - end - - # Set the request path while preserving the query string. - def path_info= value - @request_path ||= self.path_info - - if query = self.query - self.path = "#{value}?#{query}" - else - self.path = value - end - end - # The original request path, before any internal request rewrites. def request_path - @request_path || self.path_info + @request_path || @url&.path end # The query string without the leading question mark. def query - path = self.path - - if path&.include?("?") - return path.split("?", 2).last - end + @url&.query end # Decoded query arguments. @@ -134,41 +121,121 @@ def ip self.peer&.ip_address end - # The full request URL, if scheme and host are available. + # The normalized request URL. + # @returns [Protocol::URL::Absolute | Protocol::URL::Relative | Nil] The request URL. def url + return unless @url + return @url if @url.path == ASTERISK_PATH + if scheme = self.scheme and host = self.host - "#{scheme}://#{host}#{self.path}" - else - self.path + return Protocol::URL::Absolute.new(scheme, host, @url.path, @url.query).freeze end + + return @url end - # Build a derived request with updated protocol fields. - def with(method: self.method, path: self.path, path_info: nil) - delegate = @delegate.dup - delegate.method = method + # Assign the normalized request URL and update the protocol request target. + # @parameter url [Protocol::URL::Absolute | Protocol::URL::Relative | String | Nil] The request URL. + def url=(url) + url = Protocol::URL[url] - request = self.class.new(delegate, request_path: self.request_path) - request.session = @session - request.variables = @variables - request.localization = @localization - request.exception = @exception + if url&.fragment + raise ArgumentError, "HTTP request URLs cannot contain a fragment!" + end - if path_info - if query = self.query - request.path = "#{path_info}?#{query}" - else - request.path = path_info - end + if url.is_a?(Protocol::URL::Absolute) + @delegate.scheme = url.scheme + @delegate.authority = url.authority + end + + if url + self.path = Protocol::URL::Relative.new(url.path, url.query).to_s else - request.path = path + self.path = nil end + end + + # Build a derived request with an updated method or URL. + def with(method: self.method, url: self.url) + request = self.dup + request.method = method + request.url = url return request end private + # Parse and normalize the untrusted external URL exactly once during construction: + def parse_url! + target = @delegate.path + + unless target + return + end + + path, separator, query = target.partition("?") + path = Protocol::URL::Path[path] + @request_path = path.freeze + + # The asterisk-form is the standard server-wide OPTIONS target: + if @delegate.method == "OPTIONS" && path == ASTERISK_PATH && separator.empty? + @url = make_url(path) + return + end + + if target.include?("#") + raise InvalidPathError.new(path.encoded, "contains a fragment delimiter") + end + + path = normalize_path(path) + @url = make_url(path, separator.empty? ? nil : query) + end + + # Parse a trusted internal request target without normalizing its path: + def parse_url(target) + return unless target + + path, separator, query = target.partition("?") + return make_url(path, separator.empty? ? nil : query) + end + + # Construct an immutable relative URL for the current request target: + def make_url(path, query = nil) + path = Protocol::URL::Path[path].freeze + query = -query if query + + return Protocol::URL::Relative.new(path, query).freeze + end + + # Validate and simplify an untrusted external URL path: + def normalize_path(path) + unless path.absolute? + raise InvalidPathError.new(path.encoded, "expected an absolute path") + end + + begin + components = path.components + rescue ArgumentError => error + raise InvalidPathError.new(path.encoded, error.message) + end + + components.each do |component| + if component.include?("\\") + raise InvalidPathError.new(path.encoded, "contains an ambiguous separator") + end + + # Control characters cannot be represented safely in application paths: + component.b.each_byte do |byte| + if byte < 32 || byte == 127 + raise InvalidPathError.new(path.encoded, "contains a control character") + end + end + end + + return path.simplify.freeze + end + # These inherited methods conflict with the protocol request interface, so remove them to allow delegation. undef_method :method, :to_s diff --git a/lib/utopia/static/local_file.rb b/lib/utopia/static/local_file.rb index 029a6543..ab51da1a 100644 --- a/lib/utopia/static/local_file.rb +++ b/lib/utopia/static/local_file.rb @@ -8,6 +8,7 @@ require "protocol/http/body/file" require "protocol/http/header/range" +require "protocol/url/path" require_relative "../response" @@ -18,10 +19,12 @@ module Static class LocalFile # Initialize metadata for a file beneath a static root. # @parameter root [String] The root directory. - # @parameter path [Utopia::Path | String] The path. - def initialize(root, path) + # @parameter path [Utopia::Path] The decoded path. + # @parameter full_path [String] The resolved filesystem path. + def initialize(root, path, full_path = Protocol::URL::Path.for(path.components).local_path(root)) @root = root @path = path + @full_path = full_path fingerprint = Digest::SHA1.hexdigest("#{File.size(full_path)}#{mtime_date}") @etag = %Q{W/"#{fingerprint}"} end @@ -32,9 +35,7 @@ def initialize(root, path) # Resolve this file beneath its configured root. # @returns [String] The full filesystem path. - def full_path - File.join(@root, @path.components) - end + attr :full_path # Format the file's modification time for an HTTP header. # @returns [String] The HTTP-date modification time. diff --git a/lib/utopia/static/middleware.rb b/lib/utopia/static/middleware.rb index a32b038c..a41cb3af 100644 --- a/lib/utopia/static/middleware.rb +++ b/lib/utopia/static/middleware.rb @@ -47,16 +47,19 @@ def freeze end # Open metadata for an existing file under the static root. - # @parameter path [Utopia::Path | String] The path. + # @parameter path [Utopia::Path] The decoded path. # @returns [LocalFile | Nil] The local file, or `nil` when it does not exist. def fetch_file(path) - file_path = File.join(@root, path.components) + url_path = Protocol::URL::Path.for(path.components) + file_path = url_path.local_path(@root) if File.exist?(file_path) - return LocalFile.new(@root, path) + return LocalFile.new(@root, path, file_path) else return nil end + rescue ArgumentError + return nil end attr :extensions @@ -89,12 +92,12 @@ def response_headers_for(file, content_type) # Respond. # @parameter request [Utopia::Request] The request. - # @parameter path_info [String] The request path to serve. + # @parameter path [Protocol::URL::Path] The request path to serve. # @parameter extension [String] The file extension. # @parameter localization [Utopia::Localization::Preferences | Nil] The selected localization. # @returns [Protocol::HTTP::Response] The response. - def respond(request, path_info, extension, localization: request.localization) - path = Path[path_info].simplify + def respond(request, path, extension, localization: request.localization) + path = Path[path] if locale = localization&.locale path.last.insert(path.last.rindex(".") || -1, ".#{locale}") @@ -115,12 +118,12 @@ def respond(request, path_info, extension, localization: request.localization) # @parameter request [Utopia::Request] The request. # @returns [Protocol::HTTP::Response] The static-file or downstream response. def call(request) - path_info = request.path_info - extension = File.extname(path_info) + path = request.url.path + extension = File.extname(path.basename.to_s) if @extensions.key?(extension.downcase) response = resolve_localized(request) do |localization| - self.respond(request, path_info, extension, localization: localization) + self.respond(request, path, extension, localization: localization) end if response @@ -134,9 +137,9 @@ def call(request) end Traces::Provider(Static) do - def respond(request, path_info, extension, localization: request.localization) + def respond(request, path, extension, localization: request.localization) attributes = { - path_info: path_info, + path: path, locale: localization&.locale, } diff --git a/releases.md b/releases.md index f1f0e85b..987d8803 100644 --- a/releases.md +++ b/releases.md @@ -4,6 +4,7 @@ - **Security** Fix handling of redirects that start with `//` to prevent open redirect vulnerabilities. - Use `protocol-media` and `protocol-http` for response and language negotiation, removing the `http-accept` dependency. + - Restore separate client redirection middleware and normalize external request paths during `Utopia::Request` construction. ## v2.31.0 diff --git a/setup/site/config/application.rb b/setup/site/config/application.rb index a520688c..64111ff0 100644 --- a/setup/site/config/application.rb +++ b/setup/site/config/application.rb @@ -30,10 +30,7 @@ } use Utopia::Redirection::DirectoryIndex - - use Utopia::Redirection::Errors, { - 404 => "/errors/file-not-found" - } + use Utopia::Redirection::Errors, 404 => "/errors/file-not-found" use Utopia::Session, expires_after: 3600 * 24, diff --git a/test/utopia/.performance/config/application.rb b/test/utopia/.performance/config/application.rb index 46c0f513..9ef83eda 100644 --- a/test/utopia/.performance/config/application.rb +++ b/test/utopia/.performance/config/application.rb @@ -16,10 +16,7 @@ } use Utopia::Redirection::DirectoryIndex - - use Utopia::Redirection::Errors, { - 404 => "/errors/file-not-found" - } + use Utopia::Redirection::Errors, 404 => "/errors/file-not-found" use Utopia::Controller, root: ROOT use Utopia::Static, root: ROOT diff --git a/test/utopia/application.rb b/test/utopia/application.rb index daf69d3d..74e19d1a 100644 --- a/test/utopia/application.rb +++ b/test/utopia/application.rb @@ -73,7 +73,7 @@ def response_object.to_response require "utopia/application" Application = Utopia::Application.build do - run Protocol::HTTP::Middleware.for{|request| Utopia::Response.text(request.path_info)} + run Protocol::HTTP::Middleware.for{|request| Utopia::Response.text(request.url.path.encoded)} end RUBY diff --git a/test/utopia/application_middleware.rb b/test/utopia/application_middleware.rb index 6a4745c0..6d3a83b5 100644 --- a/test/utopia/application_middleware.rb +++ b/test/utopia/application_middleware.rb @@ -26,7 +26,7 @@ def request(path, headers: nil) run Protocol::HTTP::Middleware.for{|request| seen_request = request - Utopia::Response.text(request.path_info) + Utopia::Response.text(request.url.path.encoded) } end diff --git a/test/utopia/content.rb b/test/utopia/content.rb index 512a7b0c..447f2558 100755 --- a/test/utopia/content.rb +++ b/test/utopia/content.rb @@ -48,6 +48,14 @@ expect(last_response.read).to be == "

Hello World

" end + it "does not interpret encoded URL separators as content path separators" do + client.get "/content%2F/test-partial" + expect(last_response.status).to be == 404 + + client.get "/content/test-partial" + expect(last_response.read).to be == "10" + end + it "should render partials correctly" do client.get "/content/test-partial" diff --git a/test/utopia/content/document.rb b/test/utopia/content/document.rb index 4a848104..ecc9cfb9 100644 --- a/test/utopia/content/document.rb +++ b/test/utopia/content/document.rb @@ -17,7 +17,7 @@ end it "uses the original request path" do - request.path_info = "/rewritten" + request.url = request.url.with(path: "/rewritten") expect(document.request_path).to be == Utopia::Path["/index"] end diff --git a/test/utopia/controller/middleware.rb b/test/utopia/controller/middleware.rb index 962280cf..c850f7bc 100755 --- a/test/utopia/controller/middleware.rb +++ b/test/utopia/controller/middleware.rb @@ -56,6 +56,15 @@ expect(last_response.read).to be == "Hello World" end + it "does not interpret encoded URL separators as controller path separators" do + client.get "/controller%2Fnested/hello-world" + expect(last_response.status).to be == 404 + + # The invalid lookup must not poison the component-aware controller cache: + client.get "/controller/nested/hello-world" + expect(last_response.status).to be == 200 + end + it "shouldn't call the nested controller method" do client.get "/controller/nested/flat" diff --git a/test/utopia/controller/respond.rb b/test/utopia/controller/respond.rb index e444f937..12632236 100644 --- a/test/utopia/controller/respond.rb +++ b/test/utopia/controller/respond.rb @@ -53,7 +53,7 @@ def self.uri_path def mock_request(path, headers = {}) request = Utopia::Request["GET", path, headers] - return request, Utopia::Path[request.path_info] + return request, Utopia::Path[request.url.path] end it "should serialize response as JSON" do diff --git a/test/utopia/controller/rewrite.rb b/test/utopia/controller/rewrite.rb index cbce008d..eab0e0d1 100644 --- a/test/utopia/controller/rewrite.rb +++ b/test/utopia/controller/rewrite.rb @@ -35,7 +35,7 @@ def self.uri_path def mock_request(path) request = Utopia::Request["GET", path] - return request, Utopia::Path[request.path_info] + return request, Utopia::Path[request.url.path] end it "should match path prefix and extract parameters" do diff --git a/test/utopia/path.rb b/test/utopia/path.rb index 417f2b89..b51ec394 100755 --- a/test/utopia/path.rb +++ b/test/utopia/path.rb @@ -85,6 +85,16 @@ end end + it "creates a decoded routing path from a structured URL path" do + path = Protocol::URL::Path["/files/a%2Fb"] + + expect(subject.create(path).components).to be == ["", "files", "a/b"] + end + + it "preserves plus characters when decoding paths" do + expect(subject.create("/a+b").to_s).to be == "/a+b" + end + with "#first" do let(:path) {subject.create(value)} diff --git a/test/utopia/redirection.rb b/test/utopia/redirection.rb index 1076e9f3..ea5e62b0 100644 --- a/test/utopia/redirection.rb +++ b/test/utopia/redirection.rb @@ -23,7 +23,7 @@ def tracked_body(name, events) let(:middleware) do Utopia::Application.build(Protocol::HTTP::Middleware.for{|request| - case request.path_info + case request.url.path.encoded when "/error" Utopia::Response.text("File not found :(", 200) when "/teapot" @@ -34,13 +34,10 @@ def tracked_body(name, events) }) do use Utopia::Redirection::Rewrite, {"/" => "/welcome/index"} use Utopia::Redirection::DirectoryIndex - use Utopia::Redirection::Errors, { - 404 => "/error", - 418 => "/teapot" - } use Utopia::Redirection::Moved, "/a", "/b" use Utopia::Redirection::Moved, "/hierarchy/", "/hierarchy", flatten: true use Utopia::Redirection::Moved, "/weird", "/status", status: 333 + use Utopia::Redirection::Errors, 404 => "/error", 418 => "/teapot" end end @@ -84,10 +81,29 @@ def tracked_body(name, events) expect(last_response.read).to be == "File not found :(" end + it "bypasses request redirections for internal error documents" do + application = Utopia::Application.build(Protocol::HTTP::Middleware.for do |request| + if request.url.path.encoded == "/error" + Utopia::Response.text("Internal error document") + else + Utopia::Response[404, {}, []] + end + end) do + use Utopia::Redirection::Rewrite, {"/error" => "/redirected"} + use Utopia::Redirection::Errors, 404 => "/error" + end + + response = application.call(Protocol::HTTP::Request["GET", "/missing"]) + + expect(response.status).to be == 404 + expect(response.headers["location"]).to be == nil + expect(response.read).to be == "Internal error document" + end + it "closes the response replaced by an error document" do events = [] application = Utopia::Application.build(Protocol::HTTP::Middleware.for do |request| - if request.path_info == "/error" + if request.url.path.encoded == "/error" Utopia::Response[200, {}, tracked_body(:error, events)] else Utopia::Response[404, {}, tracked_body(:original, events)] @@ -110,7 +126,7 @@ def tracked_body(name, events) it "closes both responses when the error document fails" do events = [] application = Utopia::Application.build(Protocol::HTTP::Middleware.for do |request| - if request.path_info == "/error" + if request.url.path.encoded == "/error" Utopia::Response[500, {}, tracked_body(:error, events)] else Utopia::Response[404, {}, tracked_body(:original, events)] @@ -142,4 +158,14 @@ def tracked_body(name, events) expect(last_response.status).to be == 333 expect(last_response.headers["location"]).to be == "/status" end + + it "uses exact-path lookup" do + application = Utopia::Application.build do + use Utopia::Redirection::Rewrite, {"/files/" => "/exact"} + end + + response = application.call(Protocol::HTTP::Request["GET", "/files/"]) + + expect(response.headers["location"]).to be == "/exact" + end end diff --git a/test/utopia/request.rb b/test/utopia/request.rb index 9d2fc78b..12d93479 100644 --- a/test/utopia/request.rb +++ b/test/utopia/request.rb @@ -5,6 +5,7 @@ require "protocol/http/request" require "utopia/request" +require "utopia/path" describe Utopia::Request do let(:request) {subject["POST", "/search?q=utopia&tag[]=ruby&tag[]=async", {"cookie" => "a=1; b=2"}]} @@ -31,17 +32,87 @@ expect{request.unknown_request_method}.to raise_exception(NoMethodError) end - it "provides path information" do - expect(request.path_info).to be == "/search" + it "provides a structured URL" do + expect(request.url).to be_a(Protocol::URL::Relative) + expect(request.url.path).to be == Protocol::URL::Path["/search"] + expect(request.url.query).to be == "q=utopia&tag[]=ruby&tag[]=async" expect(request.query).to be == "q=utopia&tag[]=ruby&tag[]=async" end - it "updates path information while preserving query string" do - request.path_info = "/find" + it "normalizes the external path while preserving the query" do + request = subject["GET", "//users/./samuel/../amy?redirect=/a//b&value=%2e%2e"] + + expect(request.path).to be == "//users/./samuel/../amy?redirect=/a//b&value=%2e%2e" + expect(request.url.path).to be == Protocol::URL::Path["/users/amy"] + expect(request.url.query).to be == "redirect=/a//b&value=%2e%2e" + expect(request.query).to be == "redirect=/a//b&value=%2e%2e" + expect(request.request_path).to be == Protocol::URL::Path["//users/./samuel/../amy"] + end + + it "keeps encoded path delimiters separate from the query" do + request = subject["GET", "/search%3farchive?query=%"] + + expect(request.url.path).to be == Protocol::URL::Path["/search%3farchive"] + expect(request.query).to be == "query=%" + end + + it "does not decode external paths more than once" do + request = subject["GET", "/%252e%252e/value"] + + expect(request.url.path).to be == Protocol::URL::Path["/%252e%252e/value"] + expect(Utopia::Path.create(request.url.path).components).to be == ["", "%2e%2e", "value"] + end + + it "clamps parent components at the root" do + request = subject["GET", "/../../../foo"] + + expect(request.url.path).to be == Protocol::URL::Path["/foo"] + end + + it "rejects malformed or ambiguous external paths as bad requests" do + [ + "relative/path", + "/invalid%2", + "/fragment#value", + "/control\0value", + "/control%00value", + "/ambiguous\\value", + "/ambiguous%5Cvalue", + ].each do |path| + expect do + subject["GET", path] + end.to raise_exception(Utopia::InvalidPathError) do |error| + expect(error).to be_a(Protocol::HTTP::BadRequest) + end + end + end + + it "preserves encoded path separators as component data" do + request = subject["GET", "/files/a%2Fb"] + + expect(request.url.path.encoded).to be == "/files/a%2Fb" + expect(request.url.path.components).to be == ["", "files", "a/b"] + expect(Utopia::Path[request.url.path].components).to be == ["", "files", "a/b"] + end + + it "supports the server-wide OPTIONS target" do + request = subject["OPTIONS", "*"] + + expect(request.url.path).to be == Protocol::URL::Path["*"] + end + + it "updates the URL while preserving its query string" do + request.url = request.url.with(path: "/find") expect(request.path).to be == "/find?q=utopia&tag[]=ruby&tag[]=async" - expect(request.path_info).to be == "/find" - expect(request.request_path).to be == "/search" + expect(request.url.path).to be == Protocol::URL::Path["/find"] + expect(request.request_path).to be == Protocol::URL::Path["/search"] + end + + it "does not normalize trusted internal request target assignments" do + request.path = "/internal/../target" + + expect(request.url.path).to be == Protocol::URL::Path["/internal/../target"] end it "identifies POST requests" do @@ -105,7 +176,8 @@ expect(request.scheme).to be == "https" expect(request.host).to be == "example.com" - expect(request.url).to be == "https://example.com/search?q=utopia&tag[]=ruby&tag[]=async" + expect(request.url).to be_a(Protocol::URL::Absolute) + expect(request.url.to_s).to be == "https://example.com/search?q=utopia&tag[]=ruby&tag[]=async" expect(request.referrer).to be == "/from" end @@ -119,12 +191,12 @@ exception = StandardError.new("Boom") request.exception = exception - derived = request.with(method: "GET", path_info: "/find") + derived = request.with(method: "GET", url: request.url.with(path: "/find")) expect(derived).not.to be_equal(request) expect(derived.method).to be == "GET" expect(derived.path).to be == "/find?q=utopia&tag[]=ruby&tag[]=async" - expect(derived.request_path).to be == "/search" + expect(derived.request_path).to be == Protocol::URL::Path["/search"] expect(derived.delegate).not.to be_equal(request.delegate) expect(derived.session).to be_equal(session) expect(derived.variables).to be_equal(variables) @@ -132,11 +204,18 @@ expect(derived.exception).to be_equal(exception) end + it "does not normalize trusted derived request paths" do + url = Protocol::URL::Relative.new("/internal/../target", request.url.query) + derived = request.with(url: url) + + expect(derived.url.path).to be == Protocol::URL::Path["/internal/../target"] + end + it "preserves the original request path across multiple derived requests" do - derived = request.with(path_info: "/find") - derived = derived.with(path_info: "/lookup") + derived = request.with(url: request.url.with(path: "/find")) + derived = derived.with(url: derived.url.with(path: "/lookup")) - expect(derived.path_info).to be == "/lookup" - expect(derived.request_path).to be == "/search" + expect(derived.url.path).to be == Protocol::URL::Path["/lookup"] + expect(derived.request_path).to be == Protocol::URL::Path["/search"] end end diff --git a/test/utopia/session.rb b/test/utopia/session.rb index 8a6c922b..da61936b 100755 --- a/test/utopia/session.rb +++ b/test/utopia/session.rb @@ -13,7 +13,7 @@ let(:middleware) do Utopia::Application.build(Protocol::HTTP::Middleware.for{|request| - case request.path_info + case request.url.path.encoded when "/login" request.session["login"] = "true" @@ -161,7 +161,7 @@ def middleware(**options) let(:middleware) do Utopia::Application.build(Protocol::HTTP::Middleware.for{|request| - case request.path_info + case request.url.path.encoded when "/session-set" request.session[request.query_arguments["key"].to_sym] = request.query_arguments["value"] diff --git a/test/utopia/static.rb b/test/utopia/static.rb index 2ef78d6a..68499109 100755 --- a/test/utopia/static.rb +++ b/test/utopia/static.rb @@ -4,6 +4,8 @@ # Copyright, 2014-2025, by Samuel Williams. require "sus/fixtures/protocol/http/middleware_context" +require "fileutils" +require "tmpdir" require "utopia/application" require "utopia/static" @@ -25,6 +27,21 @@ expect(last_response.body).to be_a(Protocol::HTTP::Body::File) end + it "does not interpret encoded URL separators as filesystem separators" do + Dir.mktmpdir do |root| + FileUtils.mkdir_p(File.join(root, "private")) + File.write(File.join(root, "private", "secret.txt"), "Secret") + + application = Utopia::Application.build do + use Utopia::Static, root: root + end + + response = application.call(Protocol::HTTP::Request["GET", "/private%2Fsecret.txt"]) + + expect(response.status).to be == 404 + end + end + it "returns not modified for matching entity tags" do client.get "/test.txt" etag = last_response.headers["etag"] diff --git a/utopia.gemspec b/utopia.gemspec index 297b27c2..b500849c 100644 --- a/utopia.gemspec +++ b/utopia.gemspec @@ -36,7 +36,7 @@ Gem::Specification.new do |spec| spec.add_dependency "protocol-content", "~> 0.1" spec.add_dependency "protocol-http", "~> 0.70" spec.add_dependency "protocol-media", "~> 0.3" - spec.add_dependency "protocol-url", "~> 0.10" + spec.add_dependency "protocol-url", "~> 0.11" spec.add_dependency "samovar", "~> 2.1" spec.add_dependency "traces", "~> 0.10" spec.add_dependency "variant", "~> 0.1"