lib/roda/plugins/hmac_paths.rb



# frozen-string-literal: true

require 'openssl'

#
class Roda
  module RodaPlugins
    # The hmac_paths plugin allows protection of paths using an HMAC.  This can be used
    # to prevent users enumerating paths, since only paths with valid HMACs will be
    # respected.
    #
    # To use the plugin, you must provide a +secret+ option.  This sets the secret for
    # the HMACs.  Make sure to keep this value secret, as this plugin does not provide
    # protection against users who know the secret value.  The secret must be at least
    # 32 bytes.
    #
    #   plugin :hmac_paths, secret: 'some-secret-value-with-at-least-32-bytes'
    #
    # To generate a valid HMAC path, you call the +hmac_path+ method:
    #
    #   hmac_path('/widget/1')
    #   # => "/0c2feaefdfc80cc73da19b060c713d4193c57022815238c6657ce2d99b5925eb/0/widget/1"
    #
    # The first segment in the returned path is the HMAC.  The second segment is flags for
    # the type of paths (see below), and the rest of the path is as given.
    #
    # To protect a path or any subsection in the routing tree, you wrap the related code
    # in an +r.hmac_path+ block.
    #
    #   route do |r|
    #     r.hmac_path do
    #       r.get 'widget', Integer do |widget_id|
    #         # ...
    #       end
    #     end
    #   end
    #
    # If first segment of the remaining path contains a valid HMAC for the rest of the path (considering
    # the flags), then +r.hmac_path+ will match and yield to the block, and routing continues inside
    # the block with the HMAC and flags segments removed.
    #
    # In the above example, if you provide a user a link for widget with ID 1, there is no way
    # for them to guess the valid path for the widget with ID 2, preventing a user from
    # enumerating widgets, without relying on custom access control.  Users can only access
    # paths that have been generated by the application and provided to them, either directly
    # or indirectly.
    #
    # In the above example, +r.hmac_path+ is used at the root of the routing tree. If you
    # would like to call it below the root of the routing tree, it works correctly, but you
    # must pass +hmac_path+ the +:root+ option specifying where +r.hmac_paths+ will be called from.
    # Consider this example:
    #
    #   route do |r|
    #     r.on 'widget' do
    #       r.hmac_path do
    #         r.get Integer do |widget_id|
    #           # ...
    #         end
    #       end
    #     end
    #
    #     r.on 'foobar' do
    #       r.hmac_path do
    #         r.get Integer do |foobar_id|
    #           # ...
    #         end
    #       end
    #     end
    #   end
    #
    # For security reasons, the hmac_path plugin does not allow an HMAC path designed for
    # widgets to be a valid match in the +r.hmac_path+ call inside the <tt>r.on 'foobar'</tt>
    # block, preventing users who have a valid HMAC for a widget from looking at the page for
    # a foobar with the same ID.  When generating HMAC paths where the matching +r.hmac_path+
    # call is not at the root of the routing tree, you must pass the +:root+ option:
    #
    #   hmac_path('/1', root: '/widget')
    #   # => "/widget/daccafce3ce0df52e5ce774626779eaa7286085fcbde1e4681c74175ff0bbacd/0/1"
    #
    #   hmac_path('/1', root: '/foobar')
    #   # => "/foobar/c5fdaf482771d4f9f38cc13a1b2832929026a4ceb05e98ed6a0cd5a00bf180b7/0/1"
    #
    # Note how the HMAC changes even though the path is the same.
    #
    # In addition to the +:root+ option, there are additional options that further constrain
    # use of the generated paths.
    #
    # The +:method+ option creates a path that can only be called with a certain request
    # method:
    #
    #   hmac_path('/widget/1', method: :get)
    #   # => "/d38c1e634ecf9a3c0ab9d0832555b035d91b35069efcbf2670b0dfefd4b62fdd/m/widget/1"
    #
    # Note how this results in a different HMAC than the original <tt>hmac_path('/widget/1')</tt>
    # call.  This sets the flags segment to +m+, which means +r.hmac_path+ will consider the
    # request mehod when checking the HMAC, and will only match if the provided request method
    # is GET.  This allows you to provide a user the ability to submit a GET request for the
    # underlying path, without providing them the ability to submit a POST request for the
    # underlying path, with no other access control.
    #
    # The +:params+ option accepts a hash of params, converts it into a query string, and
    # includes the query string in the returned path.  It sets the flags segment to +p+, which
    # means +r.hmac_path+ will check for that exact query string.  Requests with an empty query
    # string or a different string will not match.
    #
    #   hmac_path('/widget/1', params: {foo: 'bar'})
    #   # => "/fe8d03f9572d5af6c2866295bd3c12c2ea11d290b1cbd016c3b68ee36a678139/p/widget/1?foo=bar"
    #
    # For GET requests, which cannot have request bodies, that is sufficient to ensure that the
    # submitted params are exactly as specified. However, POST requests can have request bodies,
    # and request body params override query string params in +r.params+.  So if you are using
    # this for POST requests (or other HTTP verbs that can have request bodies), use +r.GET+
    # instead of +r.params+ to specifically check query string parameters.
    #
    # The generated paths can be timestamped, so that they are only valid until a given time
    # or for a given number of seconds after they are generated, using the :until or :seconds
    # options:
    #
    #   hmac_path('/widget/1', until: Time.utc(2100))
    #   # =>  "/dc8b6e56e4cbe7815df7880d42f0e02956b2e4c49881b6060ceb0e49745a540d/t/4102444800/widget/1"
    #
    #   hmac_path('/widget/1', seconds: Time.utc(2100).to_i - Time.now.to_i)
    #   # =>  "/dc8b6e56e4cbe7815df7880d42f0e02956b2e4c49881b6060ceb0e49745a540d/t/4102444800/widget/1"
    #
    # The :namespace option, if provided, should be a string, and it modifies the generated HMACs
    # to only match those in the same namespace.  This can be used to provide different paths to
    # different users or groups of users.
    #
    #   hmac_path('/widget/1', namespace: '1')
    #   # => "/3793ac2a72ea399c40cbd63f154d19f0fe34cdf8d347772134c506a0b756d590/n/widget/1"
    #
    #   hmac_path('/widget/1', namespace: '2')
    #   # => "/0e1e748860d4fd17fe9b7c8259b1e26996502c38e465f802c2c9a0a13000087c/n/widget/1"
    #
    # The +r.hmac_path+ method accepts a :namespace option, and if a :namespace option is
    # provided, it will only match an hmac path if the namespace given matches the one used
    # when the hmac path was created.
    #
    #   r.hmac_path(namespace: '1'){}
    #   # will match "/3793ac2a72ea399c40cbd63f154d19f0fe34cdf8d347772134c506a0b756d590/n/widget/1"
    #   # will not match "/0e1e748860d4fd17fe9b7c8259b1e26996502c38e465f802c2c9a0a13000087c/n/widget/1"
    #
    # The most common use of the :namespace option is to reference session values, so the value of
    # each path depends on the logged in user.  You can use the +:namespace_session_key+ plugin
    # option to set the default namespace for both +hmac_path+ and +r.hmac_path+:
    #
    #   plugin :hmac_paths, secret: 'some-secret-value-with-at-least-32-bytes',
    #                       namespace_session_key: 'account_id'
    #
    # This will use <tt>session['account_id']</tt> as the default namespace for both +hmac_path+
    # and +r.hmac_path+ (if the session value is not nil, it is converted to a string using +to_s+).
    # You can override the default namespace by passing a +:namespace+ option when calling +hmac_path+
    # and +r.hmac_path+.
    #
    # You can use +:root+, +:method+, +:params+, and +:namespace+ at the same time:
    #
    #   hmac_path('/1', root: '/widget', method: :get, params: {foo: 'bar'}, namespace: '1')
    #   # => "/widget/c14c78a81d34d766cf334a3ddbb7a6b231bc2092ef50a77ded0028586027b14e/mpn/1?foo=bar"
    #
    # This gives you a path only valid for a GET request with a root of <tt>/widget</tt> and
    # a query string of <tt>foo=bar</tt>, using namespace +1+.
    #
    # To handle secret rotation, you can provide an +:old_secret+ option when loading the
    # plugin.
    #
    #   plugin :hmac_paths, secret: 'some-secret-value-with-at-least-32-bytes',
    #                       old_secret: 'previous-secret-value-with-at-least-32-bytes'
    #
    # This will use +:secret+ for constructing new paths, but will respect paths generated by
    # +:old_secret+.
    #
    # = HMAC Construction
    #
    # This describes the internals for how HMACs are constructed based on the options provided
    # to +hmac_path+.  In the examples below:
    #
    # * +HMAC+ is the raw HMAC-SHA256 output (first argument is secret, second is data)
    # * +HMAC_hex+ is the hexidecimal version of +HMAC+
    # * +secret+ is the plugin :secret option
    #
    # The +:secret+ plugin option is never used directly as the HMAC secret.  All HMACs are
    # generated with a root-specific secret.  The root will be the empty if no +:root+ option
    # is given.  The hmac path flags are always included in the hmac calculation, prepended to the
    # path:
    #
    #  r.hmac_path('/1')
    #  HMAC_hex(HMAC_hex(secret, ''), '/0/1')
    # 
    #  r.hmac_path('/1', root: '/2')
    #  HMAC_hex(HMAC_hex(secret, '/2'), '/0/1')
    #
    # The +:method+ option uses an uppercase version of the method prepended to the path. This
    # cannot conflict with the path itself, since paths must start with a slash.
    #
    #  r.hmac_path('/1', method: :get)
    #  HMAC_hex(HMAC_hex(secret, ''), 'GET:/m/1')
    # 
    # The +:params+ option includes the query string for the params in the HMAC:
    #
    #  r.hmac_path('/1', params: {k: 2})
    #  HMAC_hex(HMAC_hex(secret, ''), '/p/1?k=2')
    #
    # The +:until+ and +:seconds+ option include the timestamp in the HMAC:
    #
    #  r.hmac_path('/1', until: Time.utc(2100))
    #  HMAC_hex(HMAC_hex(secret, ''), '/t/4102444800/1')
    #
    # If a +:namespace+ option is provided, the original secret used before the +:root+ option is
    # an HMAC of the +:secret+ plugin option and the given namespace.
    # 
    #  r.hmac_path('/1', namespace: '2')
    #  HMAC_hex(HMAC_hex(HMAC(secret, '2'), ''), '/n/1')
    module HmacPaths
      def self.configure(app, opts=OPTS)
        hmac_secret = opts[:secret]
        unless hmac_secret.is_a?(String) && hmac_secret.bytesize >= 32
          raise RodaError, "hmac_paths plugin :secret option must be a string containing at least 32 bytes"
        end

        if hmac_old_secret = opts[:old_secret]
          unless hmac_old_secret.is_a?(String) && hmac_old_secret.bytesize >= 32
            raise RodaError, "hmac_paths plugin :old_secret option must be a string containing at least 32 bytes if present"
          end
        end

        app.opts[:hmac_paths_secret] = hmac_secret
        app.opts[:hmac_paths_old_secret] = hmac_old_secret

        if opts[:namespace_session_key]
          app.opts[:hmac_paths_namespace_session_key] = opts[:namespace_session_key]
        end
      end

      module InstanceMethods
        # Return a path with an HMAC.  Designed to be used with r.hmac_path, to make sure
        # users can only request paths that they have been provided by the application
        # (directly or indirectly).  This can prevent users of a site from enumerating
        # valid paths.  The given path should be a string starting with +/+.  Options:
        #
        # :method :: Limits the returned path to only be valid for the given request method.
        # :namespace :: Make the HMAC value depend on the given namespace. If this is not
        #               provided, the default namespace is used.  To explicitly not use a
        #               namespace when there is a default namespace, pass a nil value.
        # :params :: Includes parameters in the query string of the returned path, and
        #            limits the returned path to only be valid for that exact query string.
        # :root :: Should be an empty string or string starting with +/+. This will be
        #          the already matched path of the routing tree using r.hmac_path. Defaults
        #          to the empty string, which will returns paths valid for r.hmac_path at
        #          the top level of the routing tree.
        # :seconds :: Make the given path valid for the given integer number of seconds.
        # :until :: Make the given path valid until the given Time.
        def hmac_path(path, opts=OPTS)
          unless path.is_a?(String) && path.getbyte(0) == 47
            raise RodaError, "path must be a string starting with /"
          end

          root = opts[:root] || ''
          unless root.is_a?(String) && ((root_byte = root.getbyte(0)) == 47 || root_byte == nil)
            raise RodaError, "root must be empty string or string starting with /"
          end

          if valid_until = opts[:until]
            valid_until = valid_until.to_i
          elsif seconds = opts[:seconds]
            valid_until = Time.now.to_i + seconds
          end

          flags = String.new
          path = path.dup

          if method = opts[:method]
            flags << 'm'
          end

          if params = opts[:params]
            flags << 'p'
            path << '?' << Rack::Utils.build_query(params)
          end

          if hmac_path_namespace(opts)
            flags << 'n'
          end

          if valid_until
            flags << 't'
            path = "/#{valid_until}#{path}"
          end

          flags << '0' if flags.empty?
          
          hmac_path = if method
            "#{method.to_s.upcase}:/#{flags}#{path}"
          else
            "/#{flags}#{path}"
          end

          "#{root}/#{hmac_path_hmac(root, hmac_path, opts)}/#{flags}#{path}"
        end

        # The HMAC to use in hmac_path, for the given root, path, and options.
        def hmac_path_hmac(root, path, opts=OPTS)
          OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, hmac_path_hmac_secret(root, opts), path)
        end

        # The namespace to use for the hmac path.  If a :namespace option is not
        # provided, and a :namespace_session_key option was provided, this will
        # use the value of the related session key, if present.
        def hmac_path_namespace(opts=OPTS)
          opts.fetch(:namespace){hmac_path_default_namespace}
        end

        private

        # The secret used to calculate the HMAC in hmac_path. This is itself an HMAC, created
        # using the secret given in the plugin, for the given root and options.
        # This always returns a hexidecimal string.
        def hmac_path_hmac_secret(root, opts=OPTS)
          secret = opts[:secret] || self.opts[:hmac_paths_secret]

          if namespace = hmac_path_namespace(opts)
            secret = OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, secret, namespace)
          end

          OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, secret, root)
        end

        # The default namespace to use for hmac_path, if a :namespace option is not provided.
        def hmac_path_default_namespace
          if (key = opts[:hmac_paths_namespace_session_key]) && (value = session[key])
            value.to_s
          end
        end
      end

      module RequestMethods
        # Looks at the first segment of the remaining path, and if it contains a valid HMAC for the
        # rest of the path considering the flags in the second segment and the given options, the
        # block matches and is yielded to, and the result of the block is returned. Otherwise, the
        # block does not matches and routing continues after the call.
        def hmac_path(opts=OPTS, &block)
          orig_path = remaining_path
          mpath = matched_path

          on String do |submitted_hmac|
            rpath = remaining_path

            if submitted_hmac.bytesize == 64
              on String do |flags|
                if flags.bytesize >= 1
                  if flags.include?('n') ^ !scope.hmac_path_namespace(opts).nil?
                    # Namespace required and not provided, or provided and not required.
                    # Bail early to avoid unnecessary HMAC calculation.
                    @remaining_path = orig_path
                    return
                  end

                  if flags.include?('m')
                    rpath = "#{env['REQUEST_METHOD'].to_s.upcase}:#{rpath}"
                  end

                  if flags.include?('p')
                    rpath = "#{rpath}?#{env["QUERY_STRING"]}"
                  end

                  if hmac_path_valid?(mpath, rpath, submitted_hmac, opts)
                    if flags.include?('t')
                      on Integer do |int|
                        if int >= Time.now.to_i
                          always(&block)
                        else
                          # Return from method without matching
                          @remaining_path = orig_path
                          return
                        end
                      end
                    else
                      always(&block)
                    end
                  end
                end

                # Return from method without matching
                @remaining_path = orig_path
                return
              end
            end

            # Return from method without matching
            @remaining_path = orig_path
            return
          end
        end

        private

        # Determine whether the provided hmac matches.
        def hmac_path_valid?(root, path, hmac, opts=OPTS)
          if Rack::Utils.secure_compare(scope.hmac_path_hmac(root, path, opts), hmac)
            true
          elsif old_secret = roda_class.opts[:hmac_paths_old_secret]
            opts = opts.dup
            opts[:secret] = old_secret
            Rack::Utils.secure_compare(scope.hmac_path_hmac(root, path, opts), hmac)
          else
            false
          end
        end
      end
    end

    register_plugin(:hmac_paths, HmacPaths)
  end
end