Skip to content

Commit

Permalink
Support :namespace option in hmac_paths plugin, allowing for easy per…
Browse files Browse the repository at this point in the history
…-user/per-group HMAC paths

For applications where the same path shows different content
depending on the logged in user, user A who was provided an
hmac path could give that path to user B who had not been
provided an hmac path, and that path would work for user B.
By using the :namespace option, you the HMAC paths are
namespace-specific, so a path for a namespace used for user
A would not be valid in a namespace for user B (assuming the
namespaces are different).

The majority of cases where HMAC path namespaces are useful
will based the namespace on some session value, so a
:namespace_session_key plugin option has been added, which
sets a default namespace used by both the hmac_path and
r.hmac_path methods, so the namespace doesn't need to be
specified manually per-call.
  • Loading branch information
jeremyevans committed Apr 18, 2024
1 parent 446633c commit e2ef45b
Show file tree
Hide file tree
Showing 3 changed files with 435 additions and 35 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
= master

* Support :namespace option in hmac_paths plugin, allowing for easy per-user/per-group HMAC paths (jeremyevans)

= 3.79.0 (2024-04-12)

* Do not update template mtime when there is an error reloading templates in the render plugin (jeremyevans)
Expand Down
127 changes: 117 additions & 10 deletions lib/roda/plugins/hmac_paths.rb
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,43 @@ module RodaPlugins
# 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.
#
# You can use +:root+, +:method+, and +:params+ at the same time:
# 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('/1', root: '/widget', method: :get, params: {foo: 'bar'})
# # => "/widget/9169af1b8f40c62a1c2bb15b1b377c65bda681b8efded0e613a4176387468c15/mp/1?foo=bar"
# 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>.
# 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.
Expand All @@ -128,6 +158,43 @@ module RodaPlugins
#
# 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')
#
# 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]
Expand All @@ -143,6 +210,10 @@ def self.configure(app, opts=OPTS)

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
Expand All @@ -152,6 +223,9 @@ module InstanceMethods
# 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
Expand Down Expand Up @@ -180,6 +254,10 @@ def hmac_path(path, opts=OPTS)
path << '?' << Rack::Utils.build_query(params)
end

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

flags << '0' if flags.empty?

hmac_path = if method
Expand All @@ -188,22 +266,42 @@ def hmac_path(path, opts=OPTS)
"/#{flags}#{path}"
end

"#{root}/#{hmac_path_hmac(root, hmac_path)}/#{flags}#{path}"
"#{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. If the
# 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
Expand All @@ -221,6 +319,13 @@ def hmac_path(opts=OPTS, &block)
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
Expand All @@ -229,7 +334,7 @@ def hmac_path(opts=OPTS, &block)
rpath = "#{rpath}?#{env["QUERY_STRING"]}"
end

if hmac_path_valid?(mpath, rpath, submitted_hmac)
if hmac_path_valid?(mpath, rpath, submitted_hmac, opts)
always(&block)
end
end
Expand All @@ -249,11 +354,13 @@ def hmac_path(opts=OPTS, &block)
private

# Determine whether the provided hmac matches.
def hmac_path_valid?(root, path, hmac)
if Rack::Utils.secure_compare(scope.hmac_path_hmac(root, path), hmac)
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]
Rack::Utils.secure_compare(scope.hmac_path_hmac(root, path, secret: old_secret), hmac)
opts = opts.dup
opts[:secret] = old_secret
Rack::Utils.secure_compare(scope.hmac_path_hmac(root, path, opts), hmac)
else
false
end
Expand Down

0 comments on commit e2ef45b

Please sign in to comment.