input_sanitizer 0.1.10 → 0.4.1
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 +7 -0
- data/.github/workflows/ci.yaml +26 -0
- data/.github/workflows/gempush.yml +28 -0
- data/.gitignore +2 -1
- data/CHANGELOG +99 -0
- data/LICENSE +201 -22
- data/README.md +24 -4
- data/input_sanitizer.gemspec +10 -4
- data/lib/input_sanitizer.rb +5 -2
- data/lib/input_sanitizer/errors.rb +142 -0
- data/lib/input_sanitizer/extended_converters.rb +5 -42
- data/lib/input_sanitizer/extended_converters/comma_joined_integers_converter.rb +15 -0
- data/lib/input_sanitizer/extended_converters/comma_joined_strings_converter.rb +15 -0
- data/lib/input_sanitizer/extended_converters/positive_integer_converter.rb +12 -0
- data/lib/input_sanitizer/extended_converters/specific_values_converter.rb +19 -0
- data/lib/input_sanitizer/restricted_hash.rb +49 -8
- data/lib/input_sanitizer/v1.rb +22 -0
- data/lib/input_sanitizer/v1/clean_field.rb +38 -0
- data/lib/input_sanitizer/{default_converters.rb → v1/default_converters.rb} +20 -13
- data/lib/input_sanitizer/v1/sanitizer.rb +166 -0
- data/lib/input_sanitizer/v2.rb +13 -0
- data/lib/input_sanitizer/v2/clean_field.rb +36 -0
- data/lib/input_sanitizer/v2/clean_payload_collection_field.rb +41 -0
- data/lib/input_sanitizer/v2/clean_query_collection_field.rb +40 -0
- data/lib/input_sanitizer/v2/error_collection.rb +49 -0
- data/lib/input_sanitizer/v2/nested_sanitizer_factory.rb +19 -0
- data/lib/input_sanitizer/v2/payload_sanitizer.rb +130 -0
- data/lib/input_sanitizer/v2/payload_transform.rb +42 -0
- data/lib/input_sanitizer/v2/query_sanitizer.rb +33 -0
- data/lib/input_sanitizer/v2/types.rb +227 -0
- data/lib/input_sanitizer/version.rb +1 -1
- data/spec/extended_converters/comma_joined_integers_converter_spec.rb +18 -0
- data/spec/extended_converters/comma_joined_strings_converter_spec.rb +18 -0
- data/spec/extended_converters/positive_integer_converter_spec.rb +18 -0
- data/spec/extended_converters/specific_values_converter_spec.rb +27 -0
- data/spec/restricted_hash_spec.rb +37 -7
- data/spec/sanitizer_spec.rb +129 -26
- data/spec/spec_helper.rb +17 -2
- data/spec/v1/default_converters_spec.rb +141 -0
- data/spec/v2/converters_spec.rb +174 -0
- data/spec/v2/payload_sanitizer_spec.rb +534 -0
- data/spec/v2/payload_transform_spec.rb +98 -0
- data/spec/v2/query_sanitizer_spec.rb +300 -0
- data/v2.md +52 -0
- metadata +105 -40
- data/.travis.yml +0 -12
- data/lib/input_sanitizer/sanitizer.rb +0 -140
- data/spec/default_converters_spec.rb +0 -101
- data/spec/extended_converters_spec.rb +0 -62
data/input_sanitizer.gemspec
CHANGED
@@ -3,11 +3,12 @@ $LOAD_PATH.push File.expand_path('../lib', __FILE__)
|
|
3
3
|
require 'input_sanitizer/version'
|
4
4
|
|
5
5
|
Gem::Specification.new do |gem|
|
6
|
-
gem.authors = ["
|
7
|
-
gem.email = ["
|
6
|
+
gem.authors = ["Zendesk"]
|
7
|
+
gem.email = ["opensource@zendesk.com"]
|
8
8
|
gem.description = %q{Gem to sanitize hash of incoming data}
|
9
9
|
gem.summary = %q{Gem to sanitize hash of incoming data}
|
10
10
|
gem.homepage = ""
|
11
|
+
gem.license = "Apache-2.0"
|
11
12
|
|
12
13
|
gem.files = `git ls-files`.split($\)
|
13
14
|
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
@@ -16,6 +17,11 @@ Gem::Specification.new do |gem|
|
|
16
17
|
gem.require_paths = ["lib"]
|
17
18
|
gem.version = InputSanitizer::VERSION
|
18
19
|
|
19
|
-
gem.
|
20
|
-
|
20
|
+
gem.add_runtime_dependency "method_struct", ">= 0.2.2"
|
21
|
+
|
22
|
+
gem.add_runtime_dependency "activesupport", ">= 3.0.0"
|
23
|
+
gem.add_development_dependency "pry", "~> 0.10.1"
|
24
|
+
gem.add_development_dependency "simplecov", "~> 0.9.2"
|
25
|
+
|
26
|
+
gem.add_development_dependency "rspec", "~> 3.2.0"
|
21
27
|
end
|
data/lib/input_sanitizer.rb
CHANGED
@@ -0,0 +1,142 @@
|
|
1
|
+
module InputSanitizer
|
2
|
+
class OptionalValueOmitted < StandardError; end
|
3
|
+
|
4
|
+
class KeyNotAllowedError < ArgumentError; end
|
5
|
+
|
6
|
+
class ValidationError < StandardError
|
7
|
+
attr_accessor :field
|
8
|
+
attr_reader :value
|
9
|
+
end
|
10
|
+
|
11
|
+
class ConversionError < ValidationError; end
|
12
|
+
|
13
|
+
class ValueMissingError < ValidationError
|
14
|
+
def code
|
15
|
+
:missing
|
16
|
+
end
|
17
|
+
|
18
|
+
def initialize
|
19
|
+
super("is missing")
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
class BlankValueError < ValidationError
|
24
|
+
def code
|
25
|
+
:blank
|
26
|
+
end
|
27
|
+
|
28
|
+
def initialize
|
29
|
+
super("can't be blank")
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class RegexpMismatchError < ValidationError
|
34
|
+
def code
|
35
|
+
:regexp
|
36
|
+
end
|
37
|
+
|
38
|
+
def initialize
|
39
|
+
super("does not match regular expression")
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
class ValueNotAllowedError < ValidationError
|
44
|
+
def code
|
45
|
+
:inclusion
|
46
|
+
end
|
47
|
+
|
48
|
+
def initialize(value)
|
49
|
+
@value = value
|
50
|
+
super("is not included in the list")
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
class TypeMismatchError < ValidationError
|
55
|
+
def code
|
56
|
+
case @type
|
57
|
+
when :integer
|
58
|
+
:not_an_integer
|
59
|
+
when :url
|
60
|
+
:invalid_uri
|
61
|
+
else
|
62
|
+
:invalid_type
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def initialize(value, type)
|
67
|
+
@value = value
|
68
|
+
@type = type
|
69
|
+
|
70
|
+
message = case @type
|
71
|
+
when :integer
|
72
|
+
"must be an integer"
|
73
|
+
when :url
|
74
|
+
'must be a valid URI (include the scheme name part, both http and https are accepted, '\
|
75
|
+
'and the hierarchical part)'
|
76
|
+
else
|
77
|
+
"must be a value of type '#{type}'"
|
78
|
+
end
|
79
|
+
|
80
|
+
super(message)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
class ExtraneousParamError < ValidationError
|
85
|
+
def code
|
86
|
+
:unknown_param
|
87
|
+
end
|
88
|
+
|
89
|
+
def initialize(name)
|
90
|
+
@field = name
|
91
|
+
super("is an unexpected parameter")
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
class CollectionLengthError < ValidationError
|
96
|
+
def code
|
97
|
+
:invalid_length
|
98
|
+
end
|
99
|
+
|
100
|
+
def initialize(value, min, max)
|
101
|
+
if min && max
|
102
|
+
super("must be of length between #{min} and #{max}, given: #{value}")
|
103
|
+
elsif min
|
104
|
+
super("must be of length greater than or equal to #{min}, given: #{value}")
|
105
|
+
else
|
106
|
+
super("must be of length less than or equal to #{max}, given: #{value}")
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
class ValueError < ValidationError
|
112
|
+
def code
|
113
|
+
:invalid_value
|
114
|
+
end
|
115
|
+
|
116
|
+
def initialize(value, min, max)
|
117
|
+
if min && max
|
118
|
+
super("must be between #{min} and #{max}, given: #{value}")
|
119
|
+
elsif min
|
120
|
+
super("must be higher than or equal to #{min}, given: #{value}")
|
121
|
+
else
|
122
|
+
super("must be lower than or equal to #{max}, given: #{value}")
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
class NestedError < ValidationError
|
128
|
+
attr_reader :nested_errors
|
129
|
+
|
130
|
+
def initialize(nested_errors)
|
131
|
+
@nested_errors = nested_errors
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
class CollectionError < ValidationError
|
136
|
+
attr_reader :collection_errors
|
137
|
+
|
138
|
+
def initialize(collection_errors)
|
139
|
+
@collection_errors = collection_errors
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
@@ -1,42 +1,5 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
val
|
7
|
-
end
|
8
|
-
end
|
9
|
-
|
10
|
-
class CommaJoinedIntegersConverter
|
11
|
-
def call(value)
|
12
|
-
non_valid = value.gsub(/[0-9,]/, "")
|
13
|
-
if non_valid.empty?
|
14
|
-
parts = value.split(",").map(&:to_i)
|
15
|
-
else
|
16
|
-
invalid_chars = non_valid.split(//)
|
17
|
-
invalid_chars_desc = invalid_chars.join(", ")
|
18
|
-
raise InputSanitizer::ConversionError.new("Invalid chars: #{invalid_chars_desc}")
|
19
|
-
end
|
20
|
-
end
|
21
|
-
end
|
22
|
-
|
23
|
-
class SpecificValuesConverter
|
24
|
-
def initialize(values)
|
25
|
-
@valid_values = values
|
26
|
-
end
|
27
|
-
|
28
|
-
def call(value)
|
29
|
-
found = @valid_values.include?(value) ? value : nil
|
30
|
-
if !found
|
31
|
-
found = @valid_values.include?(value.to_sym) ? value.to_sym : nil
|
32
|
-
end
|
33
|
-
if !found
|
34
|
-
values_joined = @valid_values.join(", ")
|
35
|
-
error_message = "Possible values: #{values_joined}"
|
36
|
-
raise InputSanitizer::ConversionError.new(error_message)
|
37
|
-
else
|
38
|
-
found
|
39
|
-
end
|
40
|
-
end
|
41
|
-
end
|
42
|
-
end
|
1
|
+
dir = File.dirname(__FILE__)
|
2
|
+
require File.join(dir, 'extended_converters', 'positive_integer_converter')
|
3
|
+
require File.join(dir, 'extended_converters', 'comma_joined_integers_converter')
|
4
|
+
require File.join(dir, 'extended_converters', 'comma_joined_strings_converter')
|
5
|
+
require File.join(dir, 'extended_converters', 'specific_values_converter')
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module InputSanitizer
|
2
|
+
class CommaJoinedIntegersConverter
|
3
|
+
def call(value)
|
4
|
+
value = value.to_s
|
5
|
+
non_valid = value.gsub(/[0-9,]/, "")
|
6
|
+
|
7
|
+
if non_valid.empty?
|
8
|
+
value.split(",").map(&:to_i)
|
9
|
+
else
|
10
|
+
invalid_chars = non_valid.split(//).join(", ")
|
11
|
+
raise InputSanitizer::ConversionError.new("Invalid integers: #{invalid_chars}")
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module InputSanitizer
|
2
|
+
class CommaJoinedStringsConverter
|
3
|
+
def call(value)
|
4
|
+
value = value.to_s
|
5
|
+
non_valid = value.gsub(/[a-zA-Z,_]/, "")
|
6
|
+
|
7
|
+
if non_valid.empty?
|
8
|
+
value.split(",").map(&:to_s)
|
9
|
+
else
|
10
|
+
invalid_chars = non_valid.split(//).join(", ")
|
11
|
+
raise InputSanitizer::ConversionError.new("Invalid strings: #{invalid_chars}")
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module InputSanitizer
|
2
|
+
class PositiveIntegerConverter < V1::IntegerConverter
|
3
|
+
def call(value)
|
4
|
+
super.tap { |value| raise_error if value <= 0 }
|
5
|
+
end
|
6
|
+
|
7
|
+
private
|
8
|
+
def raise_error
|
9
|
+
raise ConversionError.new("invalid integer (neagtive or zero)")
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module InputSanitizer
|
2
|
+
class SpecificValuesConverter
|
3
|
+
def initialize(values)
|
4
|
+
@valid_values = values
|
5
|
+
end
|
6
|
+
|
7
|
+
def call(value)
|
8
|
+
case
|
9
|
+
when @valid_values.include?(value)
|
10
|
+
value
|
11
|
+
when value.respond_to?(:to_sym) && @valid_values.include?(value.to_sym)
|
12
|
+
value.to_sym
|
13
|
+
else
|
14
|
+
values_joined = @valid_values.join(", ")
|
15
|
+
raise InputSanitizer::ConversionError.new("Possible values: #{values_joined}")
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -1,24 +1,65 @@
|
|
1
1
|
module InputSanitizer
|
2
|
-
class KeyNotAllowedError < ArgumentError; end
|
3
|
-
|
4
2
|
class RestrictedHash < Hash
|
5
3
|
def initialize(allowed_keys)
|
6
|
-
@allowed_keys = allowed_keys
|
7
|
-
super()
|
4
|
+
@allowed_keys = Set.new(allowed_keys)
|
5
|
+
super()
|
6
|
+
end
|
7
|
+
|
8
|
+
def [](key)
|
9
|
+
raise_not_allowed(key) unless key_allowed?(key)
|
10
|
+
fetch(key, nil)
|
11
|
+
end
|
12
|
+
|
13
|
+
def []=(key, val)
|
14
|
+
@allowed_keys.add(key)
|
15
|
+
super
|
16
|
+
end
|
17
|
+
|
18
|
+
def store(key, val)
|
19
|
+
@allowed_keys.add(key)
|
20
|
+
super
|
21
|
+
end
|
22
|
+
|
23
|
+
def merge!(hash, &block)
|
24
|
+
@allowed_keys.merge(Set[*hash.keys])
|
25
|
+
super
|
26
|
+
end
|
27
|
+
|
28
|
+
def merge(hash, &block)
|
29
|
+
@allowed_keys.merge(Set[*hash.keys])
|
30
|
+
super
|
8
31
|
end
|
9
32
|
|
10
33
|
def key_allowed?(key)
|
11
34
|
@allowed_keys.include?(key)
|
12
35
|
end
|
13
36
|
|
14
|
-
|
15
|
-
|
16
|
-
|
37
|
+
def transform_keys
|
38
|
+
return enum_for(:transform_keys) unless block_given?
|
39
|
+
|
40
|
+
new_allowed_keys = @allowed_keys.map { |key| yield(key) }
|
41
|
+
result = self.class.new(new_allowed_keys)
|
42
|
+
each_key do |key|
|
43
|
+
result[yield(key)] = self[key]
|
44
|
+
end
|
45
|
+
result
|
17
46
|
end
|
18
47
|
|
48
|
+
def transform_keys!
|
49
|
+
return enum_for(:transform_keys!) unless block_given?
|
50
|
+
|
51
|
+
@allowed_keys.map! { |key| yield(key) }
|
52
|
+
keys.each do |key|
|
53
|
+
self[yield(key)] = delete(key)
|
54
|
+
end
|
55
|
+
self
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
19
60
|
def raise_not_allowed(key)
|
20
61
|
msg = "Key not allowed: #{key}"
|
21
|
-
raise KeyNotAllowedError
|
62
|
+
raise KeyNotAllowedError, msg
|
22
63
|
end
|
23
64
|
end
|
24
65
|
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module InputSanitizer::V1
|
2
|
+
end
|
3
|
+
|
4
|
+
dir = File.dirname(__FILE__)
|
5
|
+
require File.join(dir, 'errors')
|
6
|
+
require File.join(dir, 'restricted_hash')
|
7
|
+
require File.join(dir, 'v1', 'default_converters')
|
8
|
+
require File.join(dir, 'v1', 'clean_field')
|
9
|
+
require File.join(dir, 'v1', 'sanitizer')
|
10
|
+
require File.join(dir, 'extended_converters')
|
11
|
+
|
12
|
+
# Backward compatibility
|
13
|
+
module InputSanitizer
|
14
|
+
Sanitizer = V1::Sanitizer
|
15
|
+
|
16
|
+
IntegerConverter = V1::IntegerConverter
|
17
|
+
StringConverter = V1::StringConverter
|
18
|
+
DateConverter = V1::DateConverter
|
19
|
+
TimeConverter = V1::TimeConverter
|
20
|
+
BooleanConverter = V1::BooleanConverter
|
21
|
+
AllowNil = V1::AllowNil
|
22
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
InputSanitizer::V1::CleanField = MethodStruct.new(:data, :has_key, :converter, :required, :collection, :namespace, :default, :provide, :allow) do
|
2
|
+
def call
|
3
|
+
if has_key
|
4
|
+
convert
|
5
|
+
elsif default
|
6
|
+
converter.call(default)
|
7
|
+
elsif required
|
8
|
+
raise InputSanitizer::ValueMissingError
|
9
|
+
else
|
10
|
+
raise InputSanitizer::OptionalValueOmitted
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
def convert
|
16
|
+
if collection
|
17
|
+
data.map { |value| convert_single(value) }
|
18
|
+
else
|
19
|
+
convert_single(data)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def convert_single(value)
|
24
|
+
if namespace
|
25
|
+
{ namespace => convert_value(value[namespace]) }
|
26
|
+
else
|
27
|
+
convert_value(value)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def convert_value(value)
|
32
|
+
if provide
|
33
|
+
converter.call(value, provide)
|
34
|
+
else
|
35
|
+
converter.call(value)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -1,14 +1,12 @@
|
|
1
1
|
require 'time'
|
2
|
+
require 'date'
|
2
3
|
|
3
|
-
module InputSanitizer
|
4
|
-
class ConversionError < Exception
|
5
|
-
end
|
6
|
-
|
4
|
+
module InputSanitizer::V1
|
7
5
|
class IntegerConverter
|
8
6
|
def call(value)
|
9
7
|
cast = value.to_i
|
10
8
|
if cast.to_s != value.to_s
|
11
|
-
raise ConversionError.new("invalid integer")
|
9
|
+
raise InputSanitizer::ConversionError.new("invalid integer")
|
12
10
|
end
|
13
11
|
cast
|
14
12
|
end
|
@@ -24,24 +22,31 @@ module InputSanitizer
|
|
24
22
|
ISO_RE = /\A\d{4}-?\d{2}-?\d{2}/
|
25
23
|
|
26
24
|
def call(value)
|
27
|
-
raise ConversionError.new("invalid time") unless value =~ ISO_RE
|
25
|
+
raise InputSanitizer::ConversionError.new("invalid time") unless value =~ ISO_RE
|
28
26
|
Date.parse(value)
|
29
27
|
rescue ArgumentError
|
30
|
-
raise ConversionError.new("invalid iso8601 date")
|
28
|
+
raise InputSanitizer::ConversionError.new("invalid iso8601 date")
|
31
29
|
end
|
32
30
|
end
|
33
31
|
|
34
32
|
class TimeConverter
|
35
|
-
ISO_RE = /\A\d{4}-?\d{2}-?\d{2}([T ]?\d{2}(:?\d{2}(:?\d{2})?)?)?\Z/
|
33
|
+
ISO_RE = /\A\d{4}-?\d{2}-?\d{2}([T ]?\d{2}(:?\d{2}(:?\d{2}((\.)?\d{0,3}(Z)?)?)?)?)?\Z/
|
36
34
|
|
37
35
|
def call(value)
|
38
|
-
|
39
|
-
|
36
|
+
case value
|
37
|
+
when Time
|
38
|
+
value.getutc
|
39
|
+
when String
|
40
|
+
if value =~ ISO_RE
|
41
|
+
strip_timezone(Time.parse(value))
|
42
|
+
else
|
43
|
+
raise InputSanitizer::ConversionError.new("invalid time")
|
44
|
+
end
|
40
45
|
else
|
41
|
-
raise ConversionError.new("invalid time")
|
46
|
+
raise InputSanitizer::ConversionError.new("invalid time")
|
42
47
|
end
|
43
48
|
rescue ArgumentError
|
44
|
-
raise ConversionError.new("invalid time")
|
49
|
+
raise InputSanitizer::ConversionError.new("invalid time")
|
45
50
|
end
|
46
51
|
|
47
52
|
def strip_timezone(time)
|
@@ -59,6 +64,8 @@ module InputSanitizer
|
|
59
64
|
'0' => false,
|
60
65
|
'yes' => true,
|
61
66
|
'no' => false,
|
67
|
+
1 => true,
|
68
|
+
0 => false,
|
62
69
|
}
|
63
70
|
|
64
71
|
def call(value)
|
@@ -74,7 +81,7 @@ module InputSanitizer
|
|
74
81
|
message += " for true, or "
|
75
82
|
message += falsy.join(", ")
|
76
83
|
message += " for false."
|
77
|
-
raise ConversionError.new(message)
|
84
|
+
raise InputSanitizer::ConversionError.new(message)
|
78
85
|
end
|
79
86
|
end
|
80
87
|
end
|