full_metal_body 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5913971dd94fbbab8523c89193d31ee59f62ea723ebdfcafd0b93d935c861a1a
4
+ data.tar.gz: f6ad58265ae64f3800256909269a7486b2fa3f37496e48cab82700a71c6e6e00
5
+ SHA512:
6
+ metadata.gz: 4f12a070d08fe7fa391893103a6089ab60b64eb3b123c3e4dc6a1cb78d4dcef64aa705720f7e9ecc1deb17c26c33d395694d4128764a27f51d6f6ba29c6b577d
7
+ data.tar.gz: 8353c73e9630609a6b4c7a5200ed9133044c264b7e912ccd0c33014dbf36d11de00761c9fc2ec9672a65cee903af623b3a6559ba95ce4188cc0a075555b65f77
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2022 patorash
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,227 @@
1
+ # FullMetalBody
2
+
3
+ [![Test](https://github.com/hayashima/full_metal_body/actions/workflows/test.yml/badge.svg)](https://github.com/hayashima/full_metal_body/actions/workflows/test.yml)
4
+
5
+ FullMetalBody is a Rails Plugin for input validation in the before_action stage.
6
+
7
+ If you write a whitelist of parameters in a YAML file, only allowed keys and values will be passed through.
8
+
9
+ However, in other cases, for example, if it detects a string with control characters or a large number of strings aiming for overflow, it will immediately return `400 Bad Request`.
10
+
11
+ ### Format of whitelist
12
+
13
+ ```yaml
14
+ ---
15
+ controller_name:
16
+ action_name:
17
+ parameter_name:
18
+ type: (string|number|date|boolean)
19
+ options:
20
+ validator_option1: (value)
21
+ validator_option2: (value)
22
+ array_parameters:
23
+ type: array
24
+ properties:
25
+ parameter_name:
26
+ type: (string|number|date|boolean)
27
+ options:
28
+ validator_option1: (value)
29
+ validator_option2: (value)
30
+ ```
31
+
32
+ ### Sample of whitelist
33
+
34
+ For example, suppose you have created a Scaffold for the `Article` model as shown below.
35
+
36
+ ```bash
37
+ bin/rails g scaffold Article title:string content:text
38
+ ```
39
+
40
+ The whitelist for it is as follows.
41
+
42
+ ```yaml
43
+ ---
44
+ articles:
45
+ index:
46
+ p:
47
+ type: number
48
+ show:
49
+ id:
50
+ type: number
51
+ create:
52
+ article:
53
+ title:
54
+ type: string
55
+ content:
56
+ type: string
57
+ options:
58
+ max_length: 4096
59
+ edit:
60
+ id:
61
+ type: number
62
+ update:
63
+ id:
64
+ type: number
65
+ article:
66
+ title:
67
+ type: string
68
+ content:
69
+ type: string
70
+ options:
71
+ max_length: 4096
72
+ destroy:
73
+ id:
74
+ type: number
75
+ ```
76
+
77
+ ## Table of contents
78
+
79
+ * [FullMetalBody](#fullmetalbody)
80
+ * [Table of contents](#tableofcontents)
81
+ * [Installation](#installation)
82
+ * [Usage](#usage)
83
+ * [Migration](#migration)
84
+ * [Modify ApplicationController](#modifyapplicationcontroller)
85
+ * [Creating a whitelist template](#creatingawhitelisttemplate)
86
+ * [If you want to allow all parameters](#ifyouwanttoallowallparameters)
87
+ * [Development](#development)
88
+ * [Preparation](#preparation)
89
+ * [Test](#test)
90
+ * [Contributing](#contributing)
91
+ * [License](#license)
92
+
93
+ ## Installation
94
+
95
+ Add this line to your application's Gemfile:
96
+
97
+ ```ruby
98
+ gem "full_metal_body"
99
+ ```
100
+
101
+ And then execute:
102
+
103
+ ```bash
104
+ $ bundle
105
+ ```
106
+
107
+ ## Usage
108
+
109
+ ### Migration
110
+
111
+ Create a migration file to store controllers, actions, and parameter keys that do not exist in the whitelist in the database.
112
+
113
+ ```bash
114
+ bin/rails g full_metal_body:install
115
+ ```
116
+
117
+ And then execute:
118
+
119
+ ```bash
120
+ bin/rails db:migrate
121
+ ```
122
+
123
+ The `blocked_actions` table and the `blocked_keys` table will be created in the database.
124
+
125
+ ### Modify ApplicationController
126
+
127
+ Include `FullMetalBody::InputValidationAction` in ApplicationController.
128
+
129
+ ```ruby
130
+ class ApplicationController < ActionController::Base
131
+ include FullMetalBody::InputValidationAction
132
+ end
133
+ ```
134
+
135
+ ### Creating a whitelist template
136
+
137
+ You can write your own whitelist, but that's a lot of work.
138
+
139
+ Therefore, FullMetalBody creates a template whitelist in `tmp/whitelist/**/*.yml` by accessing each action in development mode, and then raises an exception.
140
+ At that time, the data will be registered in the blocked_actions table and the blocked_keys table.
141
+ Also, the contents will be output to the log.
142
+
143
+ For example, if you access `GET article_path(@article)`, `tmp/whitelist/articles.yml` will be created.
144
+
145
+ The contents will look like the following.
146
+
147
+ ```yaml
148
+ ---
149
+ articles:
150
+ show:
151
+ id:
152
+ type: string
153
+ ```
154
+
155
+ If you copy the contents to `config/whitelist/articles.yml` and then access it again, the exception will not occur.
156
+ By repeating this process for each action, we can create a whitelist.
157
+ By default, the whitelist is generated as `string`, so change the type to match the parameters (string|number|date|boolean).
158
+
159
+ Parameters will be merged into the template as needed for each action, so you don't have to delete them.
160
+ For example, if you access `DELETE article_path(@article)` after this, `tmp/whitelist/articles.yml` will look like this
161
+
162
+ ```yaml
163
+ ---
164
+ articles:
165
+ destroy:
166
+ id:
167
+ type: string
168
+ show:
169
+ id:
170
+ type: string
171
+ ```
172
+
173
+ ### If you want to allow all parameters
174
+
175
+ When using GraphQL, it is not possible to create a whitelist for the `variables` parameter, since it is defined on the client side.
176
+
177
+ In such a case, you need to allow everything under `variables`. If you want to allow them all, specify `_permit_all: true`.
178
+
179
+ ```yaml
180
+ ---
181
+ graphql:
182
+ execute:
183
+ operationName:
184
+ type: string
185
+ query:
186
+ options:
187
+ max_length: 1048576
188
+ type: string
189
+ variables:
190
+ _permit_all: true
191
+ ```
192
+
193
+ However, although all keys are allowed, to prevent attacks,
194
+ the type will be inferred from the value and the input value will be validated with the default rules for that type.
195
+
196
+ ## Development
197
+
198
+ Please clone the repository and start development.
199
+
200
+ ### Preparation
201
+
202
+ To develop, start PostgreSQL with docker-compose.
203
+
204
+ ```bash
205
+ docker-compose up -d
206
+ ```
207
+
208
+ And then execute:
209
+
210
+ ```bash
211
+ bundle install
212
+ ```
213
+
214
+ ### Test
215
+
216
+ The test uses minitest.
217
+
218
+ ```bash
219
+ bin/test
220
+ ```
221
+
222
+ ## Contributing
223
+
224
+ If you have any bug reports or pull requests, please let me know.
225
+
226
+ ## License
227
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'full_metal_body/input_validation'
4
+ require 'full_metal_body/services/save_blocked_keys_service'
5
+ require 'full_metal_body/whitelist_writer'
6
+
7
+ module FullMetalBody
8
+ module InputValidationAction
9
+
10
+ extend ActiveSupport::Concern
11
+
12
+ RAILS_KEYS = %w(controller action format).map(&:freeze).freeze
13
+ MAX_BLOCKED_KEYS_COUNT = 3
14
+
15
+ included do
16
+ before_action :validate_params
17
+
18
+ unless respond_to?(:current_user, true)
19
+ class_eval do
20
+ def current_user
21
+ nil
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def validate_params
30
+ blocked_keys = []
31
+ whitelist = get_whitelist
32
+
33
+ hash_keys(params.to_unsafe_h).each do |key|
34
+ next if RAILS_KEYS.include?(key.join('.'))
35
+
36
+ value = params.dig(*key)
37
+ if permit_all_params?(key, whitelist)
38
+ dynamic_whitelist_generator = DynamicWhitelistGenerator.new(key, value, whitelist)
39
+ whitelist = dynamic_whitelist_generator.execute!
40
+ # rubocop:disable Lint/UselessAssignment
41
+ dynamic_whitelist_generator = nil
42
+ # rubocop:enable Lint/UselessAssignment
43
+ end
44
+
45
+ valid, result = validate_each(key, value, whitelist)
46
+ if valid
47
+ if result.nil? && !permit_all_params?(key, whitelist)
48
+ record_blocked_key(key.join('.'))
49
+ blocked_keys << key
50
+
51
+ next if (ENV['USE_WHITELIST_COUNT_CHECK'] || '1') == '0'
52
+
53
+ if blocked_keys.size > MAX_BLOCKED_KEYS_COUNT
54
+ SaveBlockedKeysService.execute!(controller_path, action_name, blocked_keys)
55
+ output_error(
56
+ blocked_keys.to_s,
57
+ "Unknown parameters existed over #{MAX_BLOCKED_KEYS_COUNT}.",
58
+ )
59
+ return nil
60
+ end
61
+ end
62
+ else
63
+ output_error(key.join('.'), result.details)
64
+ return nil
65
+ end
66
+ end
67
+
68
+ return if blocked_keys.empty?
69
+
70
+ SaveBlockedKeysService.execute!(controller_path, action_name, blocked_keys)
71
+
72
+ return unless Rails.env.development?
73
+
74
+ WhitelistWriter.new(controller_path, action_name).write!(blocked_keys)
75
+ raise StandardError, "#{blocked_keys} are not included in whitelist. A template has been created in 'tmp/whitelist/#{controller_path}'"
76
+ end
77
+
78
+ # Validate with whitelist
79
+ #
80
+ # @param [Array<String>] key
81
+ # @param [Object] value
82
+ # @param [Hash] whitelist
83
+ #
84
+ # @return [Boolean] (true, false)
85
+ # @return [Hash, ActiveModel::Errors] In success: Type definition. In failure: Error infos.
86
+ def validate_each(key, value, whitelist)
87
+ key_type = nil
88
+ (value.nil? ? [nil] : Array(value)).flatten.each do |v|
89
+ validation = InputValidation.new(key.map(&:to_s), v, whitelist)
90
+ key_type = validation.key_type
91
+ return false, validation.errors unless validation.valid?
92
+ end
93
+ return true, key_type
94
+ end
95
+
96
+ # Get hash keys recursively.
97
+ #
98
+ # @param [Object] obj object
99
+ # @param [Array<String>] key
100
+ # @param [Array<String>] result
101
+ #
102
+ # @return [Array<String>] result
103
+ #
104
+ def hash_keys(obj, key = [], result = [])
105
+ case obj
106
+ when Hash
107
+ obj.each do |k, v|
108
+ hash_keys(v, key + [k], result)
109
+ end
110
+ when Array
111
+ obj.each_with_index do |v, idx|
112
+ hash_keys(v, key + [idx], result)
113
+ end
114
+ else
115
+ result << key
116
+ end
117
+ result
118
+ end
119
+
120
+ #
121
+ # Get a whitelist from config/whitelist/**/*.yml
122
+ #
123
+ # @return [Hash] Whitelist
124
+ #
125
+ def get_whitelist
126
+ path = Rails.root.join('config', 'whitelist', "#{controller_path}.yml")
127
+ return nil unless File.exist?(path)
128
+
129
+ yaml = File.open(path, "r") do |file|
130
+ YAML.safe_load(file)&.deep_stringify_keys
131
+ end
132
+ yaml&.dig(controller_name, action_name)
133
+ end
134
+
135
+ #
136
+ # Output validation errors
137
+ #
138
+ # @param [String] key
139
+ # @param [String] errors
140
+ #
141
+ def output_error(key, errors)
142
+ error_message = <<~ERR
143
+ Input validation error detected
144
+ Process: #{controller_path}##{action_name}
145
+ User: #{current_user&.id || 'unknown'}
146
+ IP: #{request.remote_ip}
147
+ Key: #{key}
148
+ Errors: #{errors}
149
+ ERR
150
+ # TODO: I want to be able to handle more than just Bugsnag.
151
+ if Rails.env.production?
152
+ Bugsnag.notify(error_message) if defined?(Bugsnag)
153
+ end
154
+ logger.error error_message
155
+ head :bad_request
156
+ end
157
+
158
+ # Logging key and process_name when key is not included in whitelist.
159
+ #
160
+ # @param [String] key
161
+ #
162
+ def record_blocked_key(key)
163
+ process_name = "#{controller_path}##{action_name}"
164
+ message = "Input validation warning ('#{key}' not include in whitelist for '#{process_name}')"
165
+ logger.warn message
166
+ end
167
+
168
+ # Permit all parameters if '_permit_all: true' is existed.
169
+ #
170
+ # @param [Array<String,Symbol>] keys
171
+ # @param [Hash] whitelist
172
+ # @return [Boolean] (true, false)
173
+ def permit_all_params?(keys, whitelist)
174
+ return false if whitelist.blank?
175
+
176
+ keys.size.downto(1).any? { |i| whitelist.dig(*keys.first(i))&.fetch('_permit_all', false) }
177
+ end
178
+
179
+ end
180
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullMetalBody
4
+ module DeepSort
5
+
6
+ class Error < StandardError; end
7
+
8
+ refine Hash do
9
+ def deep_sort
10
+ keys = self.keys
11
+ raise DeepSort::Error, "Invalid Keys(#{keys})" unless keys.all? { |k| k.is_a?(String) || k.is_a?(Symbol) || k.is_a?(Numeric) }
12
+
13
+ sort.to_h.transform_values { |v| v.respond_to?(:deep_sort) ? v.deep_sort : v }
14
+ end
15
+
16
+ def deep_sort!
17
+ replace(deep_sort)
18
+ end
19
+ end
20
+
21
+ refine Array do
22
+ def deep_sort
23
+ case
24
+ when all?(Numeric) then sort
25
+ when all?(String), all?(Symbol) then map(&:to_s).sort
26
+ else
27
+ map { |v| v.respond_to?(:deep_sort) ? v.deep_sort : v }.sort_by(&:to_s)
28
+ end
29
+ end
30
+
31
+ def deep_sort!
32
+ case
33
+ when all?(Numeric) then sort!
34
+ when all?(String), all?(Symbol) then map!(&:to_s).sort!
35
+ else
36
+ map! { |v| v.respond_to?(:deep_sort!) ? v.deep_sort! : v }.sort_by!(&:to_s)
37
+ end
38
+ end
39
+ end
40
+
41
+ end
42
+
43
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FullMetalBody
4
+ class DynamicWhitelistGenerator
5
+
6
+ include InputKeyUtils
7
+
8
+ # @param [Array<String,Symbol>] keys
9
+ # @param [Object] value
10
+ # @param [Hash] whitelist
11
+ def initialize(keys, value, whitelist = {})
12
+ @keys = keys
13
+ @value = value
14
+ @whitelist = whitelist
15
+ end
16
+
17
+ # Dynamically generate a whitelist and return a merged one.
18
+ # @return [ActiveSupport::HashWithIndifferentAccess]
19
+ def execute!
20
+ if keys_isnt_whitelisted?
21
+ if keys_include_array?(@keys)
22
+ generate_whitelist_for_array(@keys)
23
+ else
24
+ @whitelist.bury!((@keys + ['type']), type_definition_by_value)
25
+ end
26
+ end
27
+ @whitelist.with_indifferent_access
28
+ end
29
+
30
+ private
31
+
32
+ # Check to see if the keys are on the whitelist.
33
+ # @return [Boolean] (true, false)
34
+ def keys_isnt_whitelisted?
35
+ !@whitelist.dig(*@keys)
36
+ end
37
+
38
+ # Check to see if the keys contain the array index.
39
+ # @return [Boolean] (true, false)
40
+ def keys_include_array?(keys)
41
+ !!keys.find_index { |k| key_numeric?(k) }
42
+ end
43
+
44
+ # Return type-definition of value.
45
+ # @return [String] Type-definition
46
+ def type_definition_by_value
47
+ case @value
48
+ when Numeric
49
+ 'number'
50
+ when Date
51
+ 'date'
52
+ when TrueClass, FalseClass
53
+ 'boolean'
54
+ else
55
+ 'string'
56
+ end
57
+ end
58
+
59
+ # Dynamically generate an array whitelist.
60
+ # @param [Array<String,Symbol>] keys
61
+ # @option [Array<String,Symbol>] prefix_keys
62
+ # @raise [DynamicWhitelistGenerator::ParseArrayError]
63
+ def generate_whitelist_for_array(keys, prefix_keys = [])
64
+ parent_keys, child_keys = separate_by_array_key(keys)
65
+ parent_keys = prefix_keys + parent_keys
66
+ @whitelist.bury!((parent_keys + ['type']), 'array')
67
+ if child_keys.empty?
68
+ # keys = ["pref_ids", 0]
69
+ # prefix_keys # => []
70
+ # parent_keys # => ["pref_ids"]
71
+ # child_keys # => []
72
+ # {
73
+ # "pref_ids" => {
74
+ # "type" => "array",
75
+ # "properties" => {
76
+ # "type" => type_definition_by_value
77
+ # }
78
+ # }
79
+ # }
80
+ @whitelist.bury!((parent_keys + ['properties', 'type']), type_definition_by_value)
81
+ elsif child_keys.size == 1 && !key_numeric?(child_keys.first)
82
+ # keys = ["models", 0, "model_id"]
83
+ # prefix_keys # => []
84
+ # parent_keys # => ["models"]
85
+ # child_keys # => ["model_id"]
86
+ # {
87
+ # "models" => {
88
+ # "type" => "array",
89
+ # "properties" => {
90
+ # "model_id" => {
91
+ # "type" => type_definition_by_value
92
+ # }
93
+ # }
94
+ # }
95
+ # }
96
+ @whitelist.bury!((parent_keys + ['properties', child_keys.first, 'type']), type_definition_by_value)
97
+ elsif keys_include_array?(child_keys)
98
+ # case 1: 2D array
99
+ # Lap 1
100
+ # keys = ["models", 0, 0]
101
+ # prefix_keys # => []
102
+ # parent_keys # => ["models"]
103
+ # child_keys # => [0]
104
+ # generate_whitelist_for_array([0], ["models", "properties"])
105
+ # Lap 2
106
+ # keys = [0]
107
+ # prefix_keys = ["models", "properties"]
108
+ # parent_keys = ["models", "properties"]
109
+ # child_keys = []
110
+ # Enter `if child_keys.empty?`
111
+ # {
112
+ # "models" => {
113
+ # "type" => "array",
114
+ # "properties" => {
115
+ # "type" => "array",
116
+ # "properties" => {
117
+ # "type" => type_definition_by_value
118
+ # }
119
+ # }
120
+ # }
121
+ # }
122
+ #
123
+ # case 2: An object in an array has a further array.
124
+ # Lap 1
125
+ # keys = ["models", 0, "model_ids", 0]
126
+ # prefix_keys # => []
127
+ # parent_keys # => ["models"]
128
+ # child_keys # => ["model_ids", 0]
129
+ # generate_whitelist_for_array(["model_ids", 0], ["models", "properties"])
130
+ # Lap 2
131
+ # keys = ["model_ids", 0]
132
+ # prefix_keys = ["models", "properties"]
133
+ # parent_keys = ["models", "properties", "model_ids"]
134
+ # child_keys = []
135
+ # Enter `if child_keys.empty?`
136
+ # {
137
+ # "models" => {
138
+ # "type" => "array",
139
+ # "properties" => {
140
+ # "model_ids" => {
141
+ # "type" => "array",
142
+ # "properties" => {
143
+ # "type" => type_definition_by_value
144
+ # }
145
+ # }
146
+ # }
147
+ # }
148
+ # }
149
+ #
150
+ # case 3: The object in the array has a further array, and the object in the array
151
+ # Lap 1
152
+ # keys = ["articles", 0, "comments", 0, "content"]
153
+ # prefix_keys # => []
154
+ # parent_keys # => ["articles"]
155
+ # child_keys # => ["comments", 0, "content"]
156
+ # generate_whitelist_for_array(["comments", 0, "content"], ["articles", "properties"])
157
+ # Lap 2
158
+ # keys = ["comments", 0, "content"]
159
+ # prefix_keys = ["articles", "properties"]
160
+ # parent_keys = ["articles", "properties", "comments"]
161
+ # child_keys = ["content"]
162
+ # Enter `elsif child_keys.size == 1 && !key_numeric?(child_keys.first)`
163
+ # {
164
+ # "articles" => {
165
+ # "type" => "array",
166
+ # "properties" => {
167
+ # "comments" => {
168
+ # "type" => "array",
169
+ # "properties" => {
170
+ # "content" => {
171
+ # "type" => type_definition_by_value
172
+ # }
173
+ # }
174
+ # }
175
+ # }
176
+ # }
177
+ # }
178
+ generate_whitelist_for_array(child_keys, parent_keys + ['properties'])
179
+ else
180
+ # Normally, there are no cases that come here.
181
+ # But if it should come, raise Exception.
182
+ invalid_keys = prefix_keys + keys
183
+ invalid_keys.delete('properties')
184
+ raise DynamicWhitelistGenerator::ParseArrayError.new("Invalid keys", invalid_keys)
185
+ end
186
+ end
187
+
188
+ class ParseArrayError < StandardError
189
+
190
+ # @param [String] message
191
+ # @param [Array<String,Symbol>] keys
192
+ def initialize(message, keys)
193
+ @keys = keys
194
+ super("#{message}: #{@keys}")
195
+ end
196
+
197
+ end
198
+
199
+ end
200
+
201
+ end