full_metal_body 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullMetalBody
4
+ module InputKeyUtils
5
+
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ # Check if key is a numeric.
11
+ # @param [String,Integer] key
12
+ # @return [Boolean] (true, false)
13
+ def key_numeric?(key)
14
+ key.to_i.to_s == key || key.is_a?(Numeric)
15
+ end
16
+
17
+ # Divide the keys by the number representing the array.
18
+ # @param [Array<String,Symbol>] keys
19
+ # @return [Array<Array>] parent_keys and child_keys
20
+ def separate_by_array_key(keys)
21
+ idx = keys.find_index { |k| key_numeric?(k) }
22
+ parent_keys, child_keys = keys.partition.with_index { |_, i| i <= idx }
23
+ parent_keys.delete_at(-1) # parent_keys.last is unnecessary.
24
+ return parent_keys, child_keys
25
+ end
26
+
27
+ end
28
+
29
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'full_metal_body/input_key_utils'
4
+ require 'full_metal_body/internal/input_file_validator'
5
+ require 'full_metal_body/internal/input_string_validator'
6
+ require 'full_metal_body/internal/reasonable_boolean_validator'
7
+ require 'full_metal_body/internal/reasonable_date_validator'
8
+
9
+ module FullMetalBody
10
+ class InputValidation
11
+
12
+ include InputKeyUtils
13
+ include ActiveModel::Validations
14
+
15
+ attr_accessor :key, :value, :key_type
16
+
17
+ validates :key, "full_metal_body/internal/input_string": true, if: :check_key?
18
+
19
+ # @param [Array<String, Symbol>] key
20
+ # @param [Object] value
21
+ # @param [Hash] whitelist
22
+ def initialize(key, value, whitelist)
23
+ @key = key
24
+ @value = value
25
+ set_key_type(whitelist)
26
+ if value_validate
27
+ class_eval %(
28
+ validates :value, #{value_validate} # validates :value, input_string: true
29
+ ), __FILE__, __LINE__ - 2
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ # Whether to validate the key
36
+ #
37
+ # @return [Boolean] (true, false)
38
+ def check_key?
39
+ !@key_type
40
+ end
41
+
42
+ # Which validator to validate the value with
43
+ #
44
+ # @return [String] validator type
45
+ def value_validate
46
+ if @key_type.nil?
47
+ if @value.is_a?(ActionDispatch::Http::UploadedFile)
48
+ return "'full_metal_body/internal/input_file': true"
49
+ end
50
+
51
+ return "'full_metal_body/internal/input_string': true"
52
+ end
53
+
54
+ case @key_type['type']
55
+ when 'string'
56
+ options = @key_type['options']&.symbolize_keys || true
57
+ "'full_metal_body/internal/input_string': #{options}"
58
+ when 'number'
59
+ options = @key_type['options']&.symbolize_keys || { allow_blank: true }
60
+ if @value.is_a?(Numeric)
61
+ "numericality: #{options}"
62
+ else
63
+ "'full_metal_body/internal/input_string': true, numericality: #{options}"
64
+ end
65
+ when 'date'
66
+ if @value.is_a?(Date)
67
+ "'full_metal_body/internal/reasonable_date': true"
68
+ else
69
+ "'full_metal_body/internal/input_string': true, 'full_metal_body/internal/reasonable_date': true"
70
+ end
71
+ when 'file'
72
+ options = @key_type['options']&.symbolize_keys || true
73
+ "'full_metal_body/internal/input_file': #{options}"
74
+ when 'boolean'
75
+ options = @key_type['options']&.symbolize_keys || true
76
+ if [TrueClass, FalseClass].any? { |klass| @value.is_a?(klass) }
77
+ "'full_metal_body/internal/reasonable_boolean': #{options}"
78
+ else
79
+ "'full_metal_body/internal/input_string': true, 'full_metal_body/internal/reasonable_boolean': #{options}"
80
+ end
81
+ end
82
+ end
83
+
84
+ # Set type-definition to @key_type
85
+ #
86
+ # @param [Hash] whitelist
87
+ def set_key_type(whitelist)
88
+ return @key_type = nil if whitelist.nil?
89
+
90
+ # When ype-definition by key is exists in whitelist
91
+ return @key_type = whitelist.dig(*@key) if has_type?(whitelist.dig(*@key))
92
+
93
+ # When @key has the index number of the array
94
+ if @key.find_index { |k| key_numeric?(k) }
95
+ result = get_key_type_recursively(@key, whitelist)
96
+ return @key_type = result if has_type?(result)
97
+ end
98
+
99
+ # When no type-definition by key in whitelist
100
+ @key_type = nil
101
+ end
102
+
103
+ # Search type-definition by key recursively from whitelist
104
+ #
105
+ # @param [Array<String>] keys
106
+ # @param [Hash] whitelist
107
+ #
108
+ # @return [Hash, NilClass] type-definition by key
109
+ def get_key_type_recursively(keys, whitelist)
110
+ parent_keys, child_keys = separate_by_array_key(keys)
111
+ parent = parent_keys.empty? ? whitelist : whitelist.dig(*parent_keys)
112
+
113
+ if parent['type'] == 'array'
114
+ if child_keys.empty?
115
+ # { type: array, properties: { type: string } }
116
+ parent['properties']
117
+ elsif parent['properties'].dig(*child_keys)
118
+ # { type: array, properties: { hoge: { type: string } } }
119
+ parent['properties'].dig(*child_keys)
120
+ elsif parent.dig('properties', 'type') == 'array'
121
+ # { type: array, properties: { type: array, properties: { ... } } }
122
+ get_key_type_recursively(child_keys, parent['properties'])
123
+ elsif parent.dig('properties', child_keys.first, 'type') == 'array'
124
+ # { type: array, properties: { hoge: { type: array, properties: { ... } } } }
125
+ first_key = child_keys.shift
126
+ get_key_type_recursively(child_keys, parent.dig('properties', first_key))
127
+ else
128
+ nil
129
+ end
130
+ else
131
+ nil
132
+ end
133
+ end
134
+
135
+ # Whether or not the item has a type attribute.
136
+ #
137
+ # @param [Object] item
138
+ #
139
+ # @return [Boolean] (true, false)
140
+ def has_type?(item)
141
+ return false unless item.is_a?(Hash)
142
+
143
+ %w(string number date file boolean).include?(item['type'])
144
+ end
145
+
146
+ end
147
+
148
+ end
@@ -0,0 +1,47 @@
1
+
2
+ module FullMetalBody
3
+ module Internal
4
+ class InputFileValidator < ActiveModel::EachValidator
5
+
6
+ DEFAULT_CONTENT_TYPES = '*'.freeze
7
+ DEFAULT_FILE_SIZE = 100.megabytes
8
+
9
+ def check_validity!
10
+ if options[:content_type]
11
+ value = options[:content_type]
12
+ unless value.is_a?(String) || (value.is_a?(Array) && value.all?(String))
13
+ raise ArgumentError, ":content_type must be String or Array<String>"
14
+ end
15
+ end
16
+ if options[:file_size]
17
+ value = options[:file_size]
18
+ raise ArgumentError, ":file_size must be a non-negative Integer" unless value.is_a?(Integer) && value >= 0
19
+ end
20
+ end
21
+
22
+ def validate_each(record, attribute, value)
23
+ return if value.nil?
24
+
25
+ content_types = options[:content_type] || DEFAULT_CONTENT_TYPES
26
+ file_size = options[:file_size] || DEFAULT_FILE_SIZE
27
+
28
+ # type
29
+ unless value.is_a? ActionDispatch::Http::UploadedFile
30
+ return record.errors.add(attribute, :file_wrong_type, value: value)
31
+ end
32
+
33
+ # content type
34
+ if content_types != '*' && Array(content_types).exclude?(value.content_type)
35
+ return record.errors.add(attribute, :invalid_content_type)
36
+ end
37
+
38
+ # file size
39
+ if value.size > file_size
40
+ record.errors.add(attribute, :too_large_size)
41
+ end
42
+ end
43
+
44
+ end
45
+
46
+ end
47
+ end
@@ -0,0 +1,60 @@
1
+ module FullMetalBody
2
+ module Internal
3
+
4
+ class InputStringValidator < ActiveModel::EachValidator
5
+
6
+ DEFAULT_MAX_LENGTH = 1024
7
+
8
+ def check_validity!
9
+ if options[:max_length]
10
+ value = options[:max_length]
11
+ raise ArgumentError, ":max_length must be a non-negative Integer" unless value.is_a?(Integer) && value >= 0
12
+ end
13
+ end
14
+
15
+ def validate_each(record, attribute, value)
16
+ return if value.nil?
17
+
18
+ if value.is_a?(Array)
19
+ value.each do |v|
20
+ validate_value(record, attribute, v.dup)
21
+ end
22
+ else
23
+ validate_value(record, attribute, value.dup)
24
+ end
25
+ end
26
+
27
+ def validate_value(record, attribute, value)
28
+ max_length = options[:max_length] || DEFAULT_MAX_LENGTH
29
+
30
+ # type
31
+ unless value.is_a? String
32
+ return record.errors.add(attribute, :wrong_type, value: value)
33
+ end
34
+
35
+ # length
36
+ if value.size > max_length
37
+ return record.errors.add(attribute, :too_long, value: byteslice(value), count: value.size)
38
+ end
39
+
40
+ # encoding
41
+ original_encoding = value.encoding.name
42
+ unless value.force_encoding('UTF-8').valid_encoding?
43
+ return record.errors.add(attribute, :wrong_encoding, value: byteslice(value), encoding: original_encoding)
44
+ end
45
+
46
+ # cntrl
47
+ # Replace and delete line feed codes and horizontal tabs (vertical tabs are not)
48
+ value = value.gsub(/\R|\t/, '')
49
+ if /[[:cntrl:]]/.match?(value)
50
+ record.errors.add(attribute, :include_cntrl, value: byteslice(value))
51
+ end
52
+ end
53
+
54
+ def byteslice(value)
55
+ value.byteslice(0, 1024)
56
+ end
57
+
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,16 @@
1
+ module FullMetalBody
2
+ module Internal
3
+ class ReasonableBooleanValidator < ActiveModel::EachValidator
4
+
5
+ TRUE_VALUES = [true, "1", "t", "T", "true", "TRUE", "on", "ON"].to_set.freeze
6
+ FALSE_VALUES = [false, "0", "f", "F", "false", "FALSE", "off", "OFF"].to_set.freeze
7
+
8
+ def validate_each(record, attribute, value)
9
+ unless [TRUE_VALUES, FALSE_VALUES].any? { |b| b.include?(value) }
10
+ record.errors.add(attribute, :not_reasonable_boolean, value: value)
11
+ end
12
+ end
13
+
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,27 @@
1
+ module FullMetalBody
2
+ module Internal
3
+
4
+ class ReasonableDateValidator < ActiveModel::EachValidator
5
+
6
+ def validate_each(record, attribute, value)
7
+ return if value.blank? || value.is_a?(Date)
8
+
9
+ unless date_valid?(value)
10
+ record.errors.add(attribute, :not_reasonable_date, value: value)
11
+ end
12
+ end
13
+
14
+ def date_valid?(str)
15
+ # Allow only slash and hyphen separators
16
+ unless str.match?(%r{^\d{4}/\d{1,2}/\d{1,2}$|^\d{4}-\d{1,2}-\d{1,2}$})
17
+ return false
18
+ end
19
+
20
+ !!Date.parse(str)
21
+ rescue
22
+ false
23
+ end
24
+
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullMetalBody
4
+ class BlockedAction < ActiveRecord::Base
5
+
6
+ has_many :blocked_keys, dependent: :destroy
7
+
8
+ validates :controller, presence: true, uniqueness: { scope: :action }
9
+ validates :action, presence: true
10
+
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullMetalBody
4
+ class BlockedKey < ActiveRecord::Base
5
+
6
+ belongs_to :blocked_action
7
+
8
+ validates :blocked_key, presence: true
9
+ validates :blocked_action, uniqueness: { scope: :blocked_key }
10
+
11
+ end
12
+ end
@@ -0,0 +1,4 @@
1
+ module FullMetalBody
2
+ class Railtie < ::Rails::Railtie
3
+ end
4
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullMetalBody
4
+ class SaveBlockedKeysService
5
+
6
+ class << self
7
+
8
+ # Save blocked_keys to the database using bulk insert.
9
+ #
10
+ # @param [String] controller_path
11
+ # @param [String] action_name
12
+ # @param [Array<Array<String>>] blocked_keys
13
+ # @return [ActiveRecord::Result]
14
+ def execute!(controller_path, action_name, blocked_keys)
15
+ ApplicationRecord.transaction do
16
+ blocked_action = BlockedAction.find_or_create_by!(controller: controller_path, action: action_name)
17
+ now = Time.zone.now
18
+ if rails_6_1_and_up?
19
+ attributes = blocked_keys.map { |key| { blocked_key: key } }
20
+ blocked_action.blocked_keys.create_with(
21
+ created_at: now,
22
+ updated_at: now,
23
+ ).insert_all(attributes, returning: [:id], unique_by: [:blocked_action_id, :blocked_key])
24
+ elsif rails_6_0?
25
+ attributes = blocked_keys.map do |key|
26
+ {
27
+ blocked_action_id: blocked_action.id,
28
+ blocked_key: key,
29
+ created_at: now,
30
+ updated_at: now,
31
+ }
32
+ end
33
+ blocked_action.blocked_keys.insert_all(
34
+ attributes,
35
+ returning: [:id],
36
+ unique_by: [:blocked_action_id, :blocked_key],
37
+ )
38
+ else
39
+ blocked_keys.each { |key| blocked_action.blocked_keys.find_or_create_by(blocked_key: key) }
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def rails_6_1_and_up?
47
+ Gem::Version.new(Rails.version) >= Gem::Version.new("6.1.0")
48
+ end
49
+
50
+ def rails_6_0?
51
+ Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR == 0
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,3 @@
1
+ module FullMetalBody
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'full_metal_body/deep_sort'
5
+ require 'full_metal_body/dynamic_whitelist_generator'
6
+ using FullMetalBody::DeepSort
7
+
8
+ module FullMetalBody
9
+
10
+ class WhitelistWriter
11
+
12
+ # @param [String] controller_path
13
+ # @param [String] action_name
14
+ def initialize(controller_path, action_name)
15
+ @controller_path = controller_path
16
+ @action_name = action_name
17
+ @data = {}
18
+ end
19
+
20
+ # @param [Array<Array>] blocked_keys
21
+ def write!(blocked_keys)
22
+ blocked_keys.each { |key| update_data!(key) }
23
+
24
+ dir = File.dirname(save_path)
25
+ FileUtils.mkdir_p(dir, mode: 0o777) unless Dir.exist?(dir)
26
+ if File.exist?(save_path)
27
+ whitelist = YAML.load_file(save_path)
28
+ @data.deep_merge!(whitelist)
29
+ end
30
+ @data.deep_sort!
31
+ File.open(save_path, "w") do |file|
32
+ YAML.dump(@data, file)
33
+ end
34
+ FileUtils.chmod(0o777, save_path)
35
+ end
36
+
37
+ # Return path to store the whitelist.
38
+ # @return [Pathname]
39
+ def save_path
40
+ @save_path ||= if Rails.env.test?
41
+ Rails.root.join('tmp', "test#{ENV.fetch('TEST_ENV_NUMBER', '')}", 'whitelist', "#{@controller_path}.yml")
42
+ else
43
+ Rails.root.join('tmp', 'whitelist', "#{@controller_path}.yml")
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :action_name
50
+
51
+ def controller_name
52
+ @controller_path.split('/').last
53
+ end
54
+
55
+ # Dynamically generate whitelist from keys.
56
+ # @param [Array] keys
57
+ def update_data!(keys)
58
+ whitelist = DynamicWhitelistGenerator.new(keys, 'string').execute!
59
+ @data.bury!([controller_name, action_name], whitelist.to_hash)
60
+ end
61
+
62
+ end
63
+
64
+ end
@@ -0,0 +1,9 @@
1
+ require "full_metal_body/version"
2
+ require "full_metal_body/railtie"
3
+ require "bury"
4
+
5
+ module FullMetalBody
6
+ require 'full_metal_body/models/blocked_action'
7
+ require 'full_metal_body/models/blocked_key'
8
+ require 'full_metal_body/controllers/concerns/input_validation_action'
9
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullMetalBody
4
+ class InstallGenerator < ::Rails::Generators::Base
5
+ include ::Rails::Generators::Migration
6
+
7
+ source_root File.expand_path('templates', __dir__)
8
+
9
+ def self.next_migration_number(_path)
10
+ Time.now.utc.strftime('%Y%m%d%H%M%S')
11
+ end
12
+
13
+ def create_migration_file
14
+ template = 'create_blocked_actions'
15
+ migration_dir = File.expand_path('db/migrate')
16
+ migration_template(
17
+ "#{template}.rb.erb",
18
+ "db/migrate/#{template}.rb",
19
+ migration_version: migration_version,
20
+ )
21
+ end
22
+
23
+ def create_whitelist_dir
24
+ empty_directory('config/whitelist')
25
+ end
26
+
27
+ private
28
+
29
+ def migration_version
30
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" if over_rails5?
31
+ end
32
+
33
+ def over_rails5?
34
+ Rails::VERSION::MAJOR >= 5
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateBlockedActions < ActiveRecord::Migration<%= migration_version %>
4
+
5
+ def change
6
+ create_table :blocked_actions do |t|
7
+ t.string :controller, null: false
8
+ t.string :action, null: false
9
+
10
+ t.timestamps
11
+ end
12
+ add_index :blocked_actions, [:controller, :action], unique: true
13
+
14
+ create_table :blocked_keys do |t|
15
+ t.references :blocked_action, foreign_key: true, null: false
16
+ t.string :blocked_key, array: true, null: false
17
+
18
+ t.timestamps
19
+ end
20
+ add_index :blocked_keys, [:blocked_action_id, :blocked_key], unique: true
21
+ end
22
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :full_metal_body do
3
+ # # Task goes here
4
+ # end