full_metal_body 0.1.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 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