Skip to content

Commit

Permalink
Add hidden inputs to work with formaction/formmethod support in Roda …
Browse files Browse the repository at this point in the history
…3.77+ route_csrf plugin

A previous commit added :formaction attribute support for buttons, but if you used that
on a POST form, and you were using request specific tokens, the form submission wouldn't
pass CSRF validation, because the CSRF value would not match the path it was actually
submitted to.

Changes were made in Roda 3.77.0 to support multiple CSRF values in form submissions, and
being able to pick the correct one for the current path.  This uses that support to set
the correct hidden inputs. It also updates the forme_set CSRF metadata to perform a similar
check.
  • Loading branch information
jeremyevans committed Feb 12, 2024
1 parent ee3fceb commit 26a9d00
Show file tree
Hide file tree
Showing 5 changed files with 83 additions and 3 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
=== master

* Add hidden inputs to work with formaction/formmethod support in Roda 3.77+ route_csrf plugin (jeremyevans)

* Support :formaction option on buttons (jeremyevans)

* Support emit: false option for non-rails template forms allowing block based form use without appending to template (jeremyevans)
Expand Down
19 changes: 18 additions & 1 deletion lib/roda/plugins/forme_route_csrf.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def _forme_form_options(obj, attr, opts)
end

if apply_csrf
token = if opts.fetch(:use_request_specific_token){use_request_specific_csrf_tokens?}
token = if use_request_specific_token = opts.fetch(:use_request_specific_token){use_request_specific_csrf_tokens?}
csrf_token(csrf_path(attr[:action]), method)
else
csrf_token
Expand All @@ -46,6 +46,23 @@ def _forme_form_options(obj, attr, opts)
opts[:_before] = lambda do |form|
form.tag(:input, :type=>:hidden, :name=>csrf_field, :value=>token)
end

if use_request_specific_token && (formaction_field = csrf_options[:formaction_field])
formactions = opts[:formactions] = []
formaction_tokens = opts[:formaction_tokens] = {}
_after = opts[:_after]
opts[:formaction_csrfs] = [formaction_field, formaction_tokens]
formaction_field = csrf_options[:formaction_field]
opts[:_after] = lambda do |form|
formactions.each do |action, method|
path = csrf_path(action)
fa_token = csrf_token(path, method)
formaction_tokens[path] = fa_token
form.tag(:input, :type=>:hidden, :name=>"#{formaction_field}[#{path}]", :value=>fa_token)
end
_after.call(form) if _after
end
end
end
end
end
Expand Down
15 changes: 14 additions & 1 deletion lib/roda/plugins/forme_set.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ def _forme_parse_error(type, obj)
def _forme_form_options(obj, attr, opts)
super

_after = opts[:_after]
opts[:_after] = lambda do |form|
_after.call(form) if _after
if (obj = form.opts[:obj]) && obj.respond_to?(:forme_inputs) && (forme_inputs = obj.forme_inputs)
columns = []
valid_values = {}
Expand All @@ -129,6 +131,9 @@ def _forme_form_options(obj, attr, opts)
data['columns'] = columns
data['namespaces'] = form.opts[:namespace]
data['csrf'] = form.opts[:csrf]
if (formactions = form.opts[:formaction_csrfs]) && !formactions[1].empty?
data['formaction_csrfs'] = formactions
end
data['valid_values'] = valid_values unless valid_values.empty?
data['form_version'] = form.opts[:form_version] if form.opts[:form_version]

Expand All @@ -154,8 +159,16 @@ def _forme_parse(obj)

data = JSON.parse(data)
csrf_field, hmac_csrf_value = data['csrf']
formaction_csrf_field, formaction_values = data['formaction_csrfs']

if csrf_field
csrf_value = params[csrf_field].to_s
formaction_params = params[formaction_csrf_field]
if formaction_csrf_field && (formaction_params = params[formaction_csrf_field]).is_a?(Hash) && (csrf_value = formaction_params[request.path])
hmac_csrf_value = formaction_values[request.path]
else
csrf_value = params[csrf_field].to_s
end

hmac_csrf_value = hmac_csrf_value.to_s
unless Rack::Utils.secure_compare(csrf_value.ljust(hmac_csrf_value.length), hmac_csrf_value) && csrf_value.length == hmac_csrf_value.length
return _forme_parse_error(:csrf_mismatch, obj)
Expand Down
16 changes: 16 additions & 0 deletions lib/sequel/plugins/forme.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ class Error < ::Forme::Error
# that use a <tt>Sequel::Model</tt> instance as the form's
# +obj+.
module SequelForm
# If the form has the :formactions option and the button has the
# formaction option or attribute, append the action and method
# for this button to the formactions. This is used by the
# Roda forme_route_csrf and forme_set plugins so that formaction
# can work as expected.
def button(opts={})
if opts.is_a?(Hash) && (formactions = self.opts[:formactions]) &&
(formaction = opts[:formaction] || ((attr = opts[:attr]) && (attr[:formaction] || attr['formaction'])))
formmethod = opts[:formmethod] ||
((attr = opts[:attr]) && (attr[:formmethod] || attr['formmethod'])) ||
((attr = form_tag_attributes) && (attr[:method] || attr['method']))
formactions << [formaction, formmethod]
end
super
end

# Use the post method by default for Sequel forms, unless
# overridden with the :method attribute.
def form(attr={}, &block)
Expand Down
34 changes: 33 additions & 1 deletion spec/roda_integration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ def _forme_set(meth, obj, orig_hash, *form_args, &block)
nil
end
end
_, headers, body = test.req('PATH_INFO'=>'/', 'SCRIPT_NAME'=>'', 'REQUEST_METHOD'=>'GET', :args=>[album, *form_args], :block=>block)
_, _, body = test.req('PATH_INFO'=>'/', 'SCRIPT_NAME'=>'', 'REQUEST_METHOD'=>'GET', :args=>[album, *form_args], :block=>block)
search = body = body.join
regexp = %r|<form(?: action="([a-z/]+)")?.*?<input name="_csrf" type="hidden" value="([^"]+)"/>.*?<input name="_forme_set_data" type="hidden" value="([^"]+)"/><input name="_forme_set_data_hmac" type="hidden" value="([^"]+)"/>|n
if match
Expand All @@ -222,12 +222,44 @@ def _forme_set(meth, obj, orig_hash, *form_args, &block)
end
data.gsub!("&quot;", '"') if data
h = {"album"=>hash, "_forme_set_data"=>data, "_forme_set_data_hmac"=>hmac, "_csrf"=>csrf, "body"=>body}
if @app.opts[:route_csrf][:require_request_specific_tokens] != false && body =~ /formaction="([a-z\/]+)"/
@path_info = $1
body =~ %r|<input name="_csrfs\[([a-z\/]+)\]" type="hidden" value="([^"]+)"/>|
csrf = $2
raise "#{@path_info} != #{$1}" unless @path_info == $1
h['_csrfs'] = {@path_info=>csrf}
end
if data && hmac
forme_call(h)
end
meth == :forme_parse ? ret : h
end

it "#forme_set should handle :action attribute in form" do
forme_set(@ab, {:name=>'Foo'}, :action=>"/baz", :method=>:post){|f| f.input(:name); f.button('Submit')}
@ab.name.must_equal 'Foo'
end

if Roda::RodaVersionNumber >= 30770
it "#forme_set should handle :formaction attribute in button" do
forme_set(@ab, {:name=>'Foo'}, :method=>:post){|f| f.input(:name); f.button(:value=>'Submit', :formaction=>'/baz')}
@ab.name.must_equal 'Foo'
if plugin_opts[:require_request_specific_tokens] != false
@path_info.must_equal '/baz'
end
end

it "#forme_set should handle :formaction attribute in button with custom :_after option" do
called = false
forme_set(@ab, {:name=>'Foo'}, {:method=>:post}, :_after=>proc{|f| called = f}){|f| f.input(:name); f.button(:value=>'Submit', :formaction=>'/baz')}
@ab.name.must_equal 'Foo'
if plugin_opts[:require_request_specific_tokens] != false
@path_info.must_equal '/baz'
end
called.must_be_kind_of(Sequel::Plugins::Forme::Form)
end
end

it "should have subform work correctly" do
@app.route do |r|
@album = Album.load(:name=>'N', :copies_sold=>2, :id=>1)
Expand Down

0 comments on commit 26a9d00

Please sign in to comment.