signed_form 0.1.2 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|