signed_form 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.travis.yml +5 -1
- data/.yardopts +0 -1
- data/Changes.md +30 -0
- data/Gemfile +2 -0
- data/README.md +152 -39
- data/app/views/signed_form/expired_form.html +25 -0
- data/lib/signed_form.rb +31 -0
- data/lib/signed_form/action_controller/permit_signed_params.rb +9 -16
- data/lib/signed_form/action_view/form_helper.rb +19 -5
- data/lib/signed_form/digest_stores.rb +7 -0
- data/lib/signed_form/digest_stores/memory_store.rb +7 -0
- data/lib/signed_form/digest_stores/null_store.rb +9 -0
- data/lib/signed_form/digestor.rb +67 -0
- data/lib/signed_form/engine.rb +5 -0
- data/lib/signed_form/errors.rb +4 -2
- data/lib/signed_form/form_builder.rb +79 -65
- data/lib/signed_form/gate_keeper.rb +50 -0
- data/lib/signed_form/hmac.rb +8 -10
- data/lib/signed_form/test_helper.rb +17 -0
- data/lib/signed_form/version.rb +2 -2
- data/signed_form.gemspec +1 -0
- data/spec/digestor_spec.rb +81 -0
- data/spec/fixtures/views/_fields.html.erb +2 -0
- data/spec/fixtures/views/form.html.erb +4 -0
- data/spec/form_builder_spec.rb +137 -30
- data/spec/hmac_spec.rb +13 -23
- data/spec/permit_signed_params_spec.rb +60 -43
- data/spec/spec_helper.rb +14 -2
- metadata +32 -3
@@ -0,0 +1,67 @@
|
|
1
|
+
require 'set'
|
2
|
+
|
3
|
+
module SignedForm
|
4
|
+
class Digestor
|
5
|
+
attr_accessor :view_paths
|
6
|
+
|
7
|
+
def initialize(template)
|
8
|
+
@view_paths = Set.new
|
9
|
+
@views = Set.new
|
10
|
+
self << template
|
11
|
+
end
|
12
|
+
|
13
|
+
def <<(template)
|
14
|
+
virtual_path = get_virtual_path(template)
|
15
|
+
raise Errors::UnableToDigest, "Unable to get virtual path from template" unless virtual_path
|
16
|
+
|
17
|
+
@views << virtual_path
|
18
|
+
@view_paths += template.view_paths.map(&:to_s)
|
19
|
+
@digest = nil
|
20
|
+
rescue NoMethodError
|
21
|
+
raise Errors::UnableToDigest, "Unable get view paths from template"
|
22
|
+
end
|
23
|
+
|
24
|
+
def marshal_dump
|
25
|
+
[@views.to_a, to_s]
|
26
|
+
end
|
27
|
+
|
28
|
+
def marshal_load(input)
|
29
|
+
@views, @digest = input
|
30
|
+
@view_paths = []
|
31
|
+
@digest.taint
|
32
|
+
end
|
33
|
+
|
34
|
+
def to_s
|
35
|
+
@digest ||= SignedForm.digest_store.fetch(@views.sort.join(':')) { hash_files(glob_files) }
|
36
|
+
end
|
37
|
+
alias_method :digest, :to_s
|
38
|
+
|
39
|
+
def refresh
|
40
|
+
@digest = nil
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def glob_files
|
46
|
+
globbed_files = []
|
47
|
+
view_paths.each do |path|
|
48
|
+
@views.each { |view| globbed_files += Dir["#{path}/#{view}.*"] }
|
49
|
+
end
|
50
|
+
globbed_files
|
51
|
+
end
|
52
|
+
|
53
|
+
def hash_files(files)
|
54
|
+
raise Errors::UnableToDigest, "No files to digest" if files.empty?
|
55
|
+
|
56
|
+
md5 = Digest::MD5.new
|
57
|
+
files.sort.each do |entry|
|
58
|
+
File.open(entry) { |f| md5 << f.read }
|
59
|
+
end
|
60
|
+
md5.to_s
|
61
|
+
end
|
62
|
+
|
63
|
+
def get_virtual_path(template)
|
64
|
+
template.respond_to?(:virtual_path) ? template.virtual_path : template.instance_variable_get(:@virtual_path)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
data/lib/signed_form/errors.rb
CHANGED
@@ -1,7 +1,9 @@
|
|
1
1
|
module SignedForm
|
2
2
|
module Errors
|
3
|
-
class NoSecretKey
|
3
|
+
class NoSecretKey < StandardError; end
|
4
4
|
class InvalidSignature < StandardError; end
|
5
|
-
class InvalidURL
|
5
|
+
class InvalidURL < StandardError; end
|
6
|
+
class UnableToDigest < StandardError; end
|
7
|
+
class ExpiredForm < StandardError; end
|
6
8
|
end
|
7
9
|
end
|
@@ -1,85 +1,99 @@
|
|
1
1
|
module SignedForm
|
2
|
-
|
2
|
+
module FormBuilder
|
3
|
+
FIELDS_TO_SIGN = [:select, :collection_select, :grouped_collection_select,
|
4
|
+
:time_zone_select, :collection_radio_buttons, :collection_check_boxes,
|
5
|
+
:date_select, :datetime_select, :time_select,
|
6
|
+
:text_field, :password_field, :hidden_field,
|
7
|
+
:file_field, :text_area, :check_box,
|
8
|
+
:radio_button, :color_field,
|
9
|
+
:telephone_field, :phone_field, :date_field,
|
10
|
+
:time_field, :datetime_field, :datetime_local_field,
|
11
|
+
:month_field, :week_field, :url_field,
|
12
|
+
:email_field, :number_field, :range_field]
|
3
13
|
|
4
|
-
|
5
|
-
|
6
|
-
module Methods
|
7
|
-
FIELDS_TO_SIGN = [:select, :collection_select, :grouped_collection_select,
|
8
|
-
:time_zone_select, :collection_radio_buttons, :collection_check_boxes,
|
9
|
-
:date_select, :datetime_select, :time_select,
|
10
|
-
:text_field, :password_field, :hidden_field,
|
11
|
-
:file_field, :text_area, :check_box,
|
12
|
-
:radio_button, :color_field, :search_field,
|
13
|
-
:telephone_field, :phone_field, :date_field,
|
14
|
-
:time_field, :datetime_field, :datetime_local_field,
|
15
|
-
:month_field, :week_field, :url_field,
|
16
|
-
:email_field, :number_field, :range_field]
|
14
|
+
FIELDS_TO_SIGN.delete_if { |e| !::ActionView::Helpers::FormBuilder.instance_methods.include?(e) }
|
15
|
+
FIELDS_TO_SIGN.freeze
|
17
16
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
define_method(h) do |field, *args|
|
23
|
-
add_signed_fields field
|
24
|
-
super(field, *args)
|
25
|
-
end
|
17
|
+
FIELDS_TO_SIGN.each do |h|
|
18
|
+
define_method(h) do |field, *args|
|
19
|
+
add_signed_fields field
|
20
|
+
super(field, *args)
|
26
21
|
end
|
22
|
+
end
|
27
23
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
self.signed_attributes_object = options[:signed_attributes_object]
|
32
|
-
else
|
33
|
-
self.signed_attributes = { object_name => [] }
|
34
|
-
self.signed_attributes_object = signed_attributes[object_name]
|
35
|
-
end
|
24
|
+
BUILDERS = Hash.new do |h,k|
|
25
|
+
h[k] = Class.new(k) do
|
26
|
+
include FormBuilder
|
36
27
|
end
|
28
|
+
end
|
37
29
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
30
|
+
def initialize(*)
|
31
|
+
super
|
32
|
+
if options[:signed_attributes_context]
|
33
|
+
@signed_attributes_context = options[:signed_attributes_context]
|
34
|
+
else
|
35
|
+
@signed_attributes = { object_name => [] }
|
36
|
+
@signed_attributes_context = @signed_attributes[object_name]
|
37
|
+
prepare_signed_attributes_hash
|
45
38
|
end
|
39
|
+
end
|
46
40
|
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
def fields_for(record_name, record_object = nil, fields_options = {}, &block)
|
51
|
-
hash = {}
|
52
|
-
array = []
|
41
|
+
def form_signature_tag
|
42
|
+
@signed_attributes.each { |k,v| v.uniq! if v.is_a?(Array) }
|
43
|
+
encoded_data = Base64.strict_encode64 Marshal.dump(@signed_attributes)
|
53
44
|
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
45
|
+
hmac = SignedForm::HMAC.new(secret_key: SignedForm.secret_key)
|
46
|
+
signature = hmac.create(encoded_data)
|
47
|
+
token = "#{encoded_data}--#{signature}"
|
48
|
+
%(<input type="hidden" name="form_signature" value="#{token}" />\n).html_safe
|
49
|
+
end
|
59
50
|
|
60
|
-
|
51
|
+
# Wrapper for Rails fields_for
|
52
|
+
#
|
53
|
+
# @see http://api.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html#method-i-fields_for
|
54
|
+
def fields_for(record_name, record_object = nil, fields_options = {}, &block)
|
55
|
+
hash = {}
|
56
|
+
array = []
|
61
57
|
|
62
|
-
|
63
|
-
array
|
64
|
-
|
58
|
+
if nested_attributes_association?(record_name)
|
59
|
+
hash["#{record_name}_attributes"] = fields_options[:signed_attributes_context] = array
|
60
|
+
else
|
61
|
+
hash[record_name] = fields_options[:signed_attributes_context] = array
|
65
62
|
end
|
66
63
|
|
67
|
-
|
68
|
-
#
|
69
|
-
# @example
|
70
|
-
# <%= signed_form_for(@user) do |f| %>
|
71
|
-
# <% f.add_signed_fields :name, :address
|
72
|
-
# <% end %>
|
73
|
-
#
|
74
|
-
def add_signed_fields(*fields)
|
75
|
-
signed_attributes_object.push(*fields)
|
76
|
-
end
|
64
|
+
add_signed_fields hash
|
77
65
|
|
78
|
-
|
66
|
+
content = super
|
67
|
+
array.uniq!
|
68
|
+
content
|
69
|
+
end
|
79
70
|
|
80
|
-
|
71
|
+
# This method is used to add additional fields to sign. A usecase for this may be if you want to add fields later with javascript.
|
72
|
+
#
|
73
|
+
# @example
|
74
|
+
# <%= signed_form_for(@user) do |f| %>
|
75
|
+
# <% f.add_signed_fields :name, :address
|
76
|
+
# <% end %>
|
77
|
+
#
|
78
|
+
def add_signed_fields(*fields)
|
79
|
+
@signed_attributes_context.push(*fields)
|
80
|
+
options[:digest] << @template if options[:digest]
|
81
81
|
end
|
82
82
|
|
83
|
-
|
83
|
+
private
|
84
|
+
|
85
|
+
def prepare_signed_attributes_hash
|
86
|
+
@signed_attributes[:_options_] = {}
|
87
|
+
|
88
|
+
if options[:sign_destination]
|
89
|
+
@signed_attributes[:_options_][:method] = options[:html][:method]
|
90
|
+
@signed_attributes[:_options_][:url] = options[:url]
|
91
|
+
end
|
92
|
+
|
93
|
+
if options[:digest]
|
94
|
+
@signed_attributes[:_options_][:digest] = options[:digest] = Digestor.new(@template)
|
95
|
+
@signed_attributes[:_options_][:digest_expiration] = Time.now + options[:digest_grace_period] if options[:digest_grace_period]
|
96
|
+
end
|
97
|
+
end
|
84
98
|
end
|
85
99
|
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module SignedForm
|
2
|
+
class GateKeeper
|
3
|
+
attr_reader :allowed_attributes
|
4
|
+
|
5
|
+
def initialize(controller)
|
6
|
+
@controller = controller
|
7
|
+
@params = controller.params
|
8
|
+
@request = controller.request
|
9
|
+
|
10
|
+
extract_and_verify_form_signature
|
11
|
+
verify_destination
|
12
|
+
verify_digest
|
13
|
+
end
|
14
|
+
|
15
|
+
def options
|
16
|
+
@options ||= {}
|
17
|
+
end
|
18
|
+
|
19
|
+
def extract_and_verify_form_signature
|
20
|
+
data, signature = @params['form_signature'].split('--', 2)
|
21
|
+
hmac = SignedForm::HMAC.new secret_key: SignedForm.secret_key
|
22
|
+
|
23
|
+
signature ||= ''
|
24
|
+
|
25
|
+
raise Errors::InvalidSignature, "Form signature is not valid" unless hmac.verify signature, data
|
26
|
+
|
27
|
+
@allowed_attributes = Marshal.load Base64.strict_decode64(data)
|
28
|
+
@options = allowed_attributes.delete(:_options_)
|
29
|
+
end
|
30
|
+
|
31
|
+
def verify_destination
|
32
|
+
return unless options[:method] && options[:url]
|
33
|
+
raise Errors::InvalidURL if options[:method].to_s.casecmp(@request.request_method) != 0
|
34
|
+
url = @controller.url_for(options[:url])
|
35
|
+
raise Errors::InvalidURL if url != @request.fullpath && url != @request.url
|
36
|
+
end
|
37
|
+
|
38
|
+
def verify_digest
|
39
|
+
return unless options[:digest]
|
40
|
+
|
41
|
+
return if options[:digest_expiration] && Time.now < options[:digest_expiration]
|
42
|
+
|
43
|
+
digestor = options[:digest]
|
44
|
+
given_digest = digestor.to_s
|
45
|
+
digestor.view_paths = @controller.view_paths.map(&:to_s)
|
46
|
+
digestor.refresh
|
47
|
+
raise Errors::ExpiredForm unless given_digest == digestor.to_s
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
data/lib/signed_form/hmac.rb
CHANGED
@@ -1,24 +1,22 @@
|
|
1
|
-
require
|
1
|
+
require "openssl"
|
2
2
|
|
3
3
|
module SignedForm
|
4
|
-
|
5
|
-
extend self
|
6
|
-
|
4
|
+
class HMAC
|
7
5
|
attr_accessor :secret_key
|
8
6
|
|
9
|
-
def
|
7
|
+
def initialize(options = {})
|
8
|
+
self.secret_key = options[:secret_key]
|
9
|
+
|
10
10
|
if secret_key.nil? || secret_key.empty?
|
11
11
|
raise Errors::NoSecretKey, "Please consult the README for instructions on creating a secret key"
|
12
12
|
end
|
13
|
+
end
|
13
14
|
|
15
|
+
def create(data)
|
14
16
|
OpenSSL::HMAC.hexdigest OpenSSL::Digest::SHA1.new, secret_key, data
|
15
17
|
end
|
16
18
|
|
17
|
-
def
|
18
|
-
if secret_key.nil? || secret_key.empty?
|
19
|
-
raise Errors::NoSecretKey, "Please consult the README for instructions on creating a secret key"
|
20
|
-
end
|
21
|
-
|
19
|
+
def verify(signature, data)
|
22
20
|
secure_compare OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, secret_key, data), signature
|
23
21
|
end
|
24
22
|
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module SignedForm
|
2
|
+
module TestHelper
|
3
|
+
def permit_all_parameters
|
4
|
+
@controller.instance_eval do
|
5
|
+
def params
|
6
|
+
@permit_all_parameters ||= super.permit!
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
if block_given?
|
11
|
+
yield
|
12
|
+
@controller.remove_instance_variable :@permit_all_parameters
|
13
|
+
@controller.singleton_class.send :remove_method, :params
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
data/lib/signed_form/version.rb
CHANGED
data/signed_form.gemspec
CHANGED
@@ -22,6 +22,7 @@ Gem::Specification.new do |spec|
|
|
22
22
|
spec.add_development_dependency "rake"
|
23
23
|
spec.add_development_dependency "rspec", "~> 2.13"
|
24
24
|
spec.add_development_dependency "activemodel", ">= 3.1"
|
25
|
+
spec.add_development_dependency "coveralls"
|
25
26
|
|
26
27
|
spec.add_dependency "actionpack", ">= 3.1"
|
27
28
|
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe SignedForm::Digestor do
|
4
|
+
let(:view_paths) { [File.join(File.dirname(__FILE__), 'fixtures', 'views')] }
|
5
|
+
let(:template) { double(view_paths: view_paths) }
|
6
|
+
|
7
|
+
before do
|
8
|
+
def template.virtual_path=(path)
|
9
|
+
@virtual_path = path
|
10
|
+
end
|
11
|
+
|
12
|
+
template.virtual_path = 'form'
|
13
|
+
end
|
14
|
+
|
15
|
+
it "should raise if template doesn't have view_paths" do
|
16
|
+
template = double(view_paths: -> { raise NoMethodError })
|
17
|
+
expect { SignedForm::Digestor.new(template) }.to raise_error(SignedForm::Errors::UnableToDigest)
|
18
|
+
end
|
19
|
+
|
20
|
+
it "should raise if the template doesn't have a virtual path" do
|
21
|
+
template = double(view_paths: view_paths)
|
22
|
+
expect { SignedForm::Digestor.new(template) }.to raise_error(SignedForm::Errors::UnableToDigest)
|
23
|
+
|
24
|
+
template.instance_variable_set(:@virtual_path, 'form')
|
25
|
+
digestor = SignedForm::Digestor.new(template)
|
26
|
+
template.instance_variable_set(:@virtual_path, nil)
|
27
|
+
expect { digestor << template }.to raise_error(SignedForm::Errors::UnableToDigest)
|
28
|
+
end
|
29
|
+
|
30
|
+
it "should not marshal view paths" do
|
31
|
+
digestor = SignedForm::Digestor.new(template)
|
32
|
+
|
33
|
+
digestor.view_paths.should_not be_empty
|
34
|
+
|
35
|
+
data = Marshal.dump digestor
|
36
|
+
digestor = Marshal.load data
|
37
|
+
|
38
|
+
digestor.view_paths.should be_empty
|
39
|
+
end
|
40
|
+
|
41
|
+
specify "#to_s should return the correct MD5 digest" do
|
42
|
+
digestor = SignedForm::Digestor.new(template)
|
43
|
+
digestor.to_s.should == "7a956713f33cabd57357c70025109e69"
|
44
|
+
template.virtual_path = "_fields"
|
45
|
+
digestor << template
|
46
|
+
digestor.to_s.should == "6a161ab9978322e8251d809b3558ab1a"
|
47
|
+
end
|
48
|
+
|
49
|
+
specify "The view order should not affect the digest" do
|
50
|
+
digestor = SignedForm::Digestor.new(template)
|
51
|
+
template.virtual_path = '_fields'
|
52
|
+
digestor << template
|
53
|
+
|
54
|
+
digestor2 = SignedForm::Digestor.new(template)
|
55
|
+
template.virtual_path = 'form'
|
56
|
+
digestor2 << template
|
57
|
+
digestor.to_s.should == digestor2.to_s
|
58
|
+
end
|
59
|
+
|
60
|
+
it "should marshal and taint the digest" do
|
61
|
+
digestor = SignedForm::Digestor.new(template)
|
62
|
+
data = Marshal.dump digestor
|
63
|
+
digestor = Marshal.load data
|
64
|
+
digestor.to_s.should be_tainted
|
65
|
+
end
|
66
|
+
|
67
|
+
it "should reset the digest if a template is added" do
|
68
|
+
digestor = SignedForm::Digestor.new(template)
|
69
|
+
first_digest = digestor.to_s
|
70
|
+
template.virtual_path = '_fields'
|
71
|
+
digestor << template
|
72
|
+
digestor.to_s.should_not == first_digest
|
73
|
+
end
|
74
|
+
|
75
|
+
it "should be idempotent" do
|
76
|
+
digestor = SignedForm::Digestor.new(template)
|
77
|
+
first_digest = digestor.to_s
|
78
|
+
digestor << template
|
79
|
+
digestor.to_s.should == first_digest
|
80
|
+
end
|
81
|
+
end
|