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 r.on 'foobar'
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 hmac_path('/widget/1')
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 session['account_id']
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 /widget
and a query string of foo=bar
, 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 ofHMAC
-
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')
Classes and Modules
Public Class methods
# File lib/roda/plugins/hmac_paths.rb 214 def self.configure(app, opts=OPTS) 215 hmac_secret = opts[:secret] 216 unless hmac_secret.is_a?(String) && hmac_secret.bytesize >= 32 217 raise RodaError, "hmac_paths plugin :secret option must be a string containing at least 32 bytes" 218 end 219 220 if hmac_old_secret = opts[:old_secret] 221 unless hmac_old_secret.is_a?(String) && hmac_old_secret.bytesize >= 32 222 raise RodaError, "hmac_paths plugin :old_secret option must be a string containing at least 32 bytes if present" 223 end 224 end 225 226 app.opts[:hmac_paths_secret] = hmac_secret 227 app.opts[:hmac_paths_old_secret] = hmac_old_secret 228 229 if opts[:namespace_session_key] 230 app.opts[:hmac_paths_namespace_session_key] = opts[:namespace_session_key] 231 end 232 end