active_storage_validations 3.0.5 → 4.0.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 +4 -4
- data/MIT-LICENSE +1 -1
- data/README.md +178 -115
- data/Rakefile +13 -15
- data/lib/active_storage_validations/analyzer/audio_analyzer.rb +0 -1
- data/lib/active_storage_validations/analyzer/content_type_analyzer/file.rb +31 -0
- data/lib/active_storage_validations/analyzer/content_type_analyzer/magika.rb +44 -0
- data/lib/active_storage_validations/analyzer/content_type_analyzer.rb +15 -24
- data/lib/active_storage_validations/analyzer/image_analyzer/image_magick.rb +41 -7
- data/lib/active_storage_validations/analyzer/image_analyzer/vips.rb +36 -10
- data/lib/active_storage_validations/analyzer/pdf_analyzer.rb +3 -9
- data/lib/active_storage_validations/analyzer/shared/asv_ff_probable.rb +5 -4
- data/lib/active_storage_validations/analyzer/video_analyzer.rb +0 -1
- data/lib/active_storage_validations/analyzer.rb +35 -3
- data/lib/active_storage_validations/content_type_validator.rb +24 -6
- data/lib/active_storage_validations/dimension_validator.rb +4 -2
- data/lib/active_storage_validations/form_builder.rb +43 -0
- data/lib/active_storage_validations/matchers/aspect_ratio_validator_matcher.rb +8 -0
- data/lib/active_storage_validations/matchers/attached_validator_matcher.rb +4 -0
- data/lib/active_storage_validations/matchers/base_comparison_validator_matcher.rb +11 -1
- data/lib/active_storage_validations/matchers/content_type_validator_matcher.rb +12 -0
- data/lib/active_storage_validations/matchers/dimension_validator_matcher.rb +8 -0
- data/lib/active_storage_validations/matchers/duration_validator_matcher.rb +8 -0
- data/lib/active_storage_validations/matchers/limit_validator_matcher.rb +4 -0
- data/lib/active_storage_validations/matchers/pages_validator_matcher.rb +8 -0
- data/lib/active_storage_validations/matchers/processable_file_validator_matcher.rb +8 -0
- data/lib/active_storage_validations/matchers/shared/asv_except_onable.rb +57 -0
- data/lib/active_storage_validations/matchers/shared/asv_spoofing_protectable.rb +44 -0
- data/lib/active_storage_validations/matchers/shared/asv_timeoutable.rb +32 -0
- data/lib/active_storage_validations/matchers.rb +9 -1
- data/lib/active_storage_validations/processable_file_validator.rb +60 -1
- data/lib/active_storage_validations/railtie.rb +6 -0
- data/lib/active_storage_validations/shared/asv_analyzable.rb +66 -8
- data/lib/active_storage_validations/shared/asv_attachable.rb +9 -10
- data/lib/active_storage_validations/shared/asv_commandable.rb +95 -0
- data/lib/active_storage_validations/shared/asv_optionable.rb +5 -1
- data/lib/active_storage_validations/version.rb +1 -1
- data/lib/active_storage_validations.rb +22 -0
- metadata +25 -58
|
@@ -7,8 +7,11 @@ require_relative "shared/asv_active_storageable"
|
|
|
7
7
|
require_relative "shared/asv_allow_blankable"
|
|
8
8
|
require_relative "shared/asv_attachable"
|
|
9
9
|
require_relative "shared/asv_contextable"
|
|
10
|
+
require_relative "shared/asv_except_onable"
|
|
10
11
|
require_relative "shared/asv_messageable"
|
|
11
12
|
require_relative "shared/asv_rspecable"
|
|
13
|
+
require_relative "shared/asv_spoofing_protectable"
|
|
14
|
+
require_relative "shared/asv_timeoutable"
|
|
12
15
|
require_relative "shared/asv_validatable"
|
|
13
16
|
|
|
14
17
|
module ActiveStorageValidations
|
|
@@ -22,15 +25,21 @@ module ActiveStorageValidations
|
|
|
22
25
|
include ASVAllowBlankable
|
|
23
26
|
include ASVAttachable
|
|
24
27
|
include ASVContextable
|
|
28
|
+
include ASVExceptOnable
|
|
25
29
|
include ASVMessageable
|
|
26
30
|
include ASVRspecable
|
|
31
|
+
include ASVSpoofingProtectable
|
|
32
|
+
include ASVTimeoutable
|
|
27
33
|
include ASVValidatable
|
|
28
34
|
|
|
29
35
|
def initialize(attribute_name)
|
|
30
36
|
initialize_allow_blankable
|
|
31
37
|
initialize_contextable
|
|
38
|
+
initialize_except_onable
|
|
32
39
|
initialize_messageable
|
|
33
40
|
initialize_rspecable
|
|
41
|
+
initialize_spoofing_protectable
|
|
42
|
+
initialize_timeoutable
|
|
34
43
|
@attribute_name = attribute_name
|
|
35
44
|
@allowed_content_types = @rejected_content_types = []
|
|
36
45
|
end
|
|
@@ -62,7 +71,10 @@ module ActiveStorageValidations
|
|
|
62
71
|
|
|
63
72
|
is_a_valid_active_storage_attribute? &&
|
|
64
73
|
is_context_valid? &&
|
|
74
|
+
is_except_on_valid? &&
|
|
65
75
|
is_allowing_blank? &&
|
|
76
|
+
is_timeout_valid? &&
|
|
77
|
+
is_spoofing_protection_valid? &&
|
|
66
78
|
is_custom_message_valid? &&
|
|
67
79
|
all_allowed_content_types_allowed? &&
|
|
68
80
|
all_rejected_content_types_rejected?
|
|
@@ -4,8 +4,10 @@ require_relative "shared/asv_active_storageable"
|
|
|
4
4
|
require_relative "shared/asv_allow_blankable"
|
|
5
5
|
require_relative "shared/asv_attachable"
|
|
6
6
|
require_relative "shared/asv_contextable"
|
|
7
|
+
require_relative "shared/asv_except_onable"
|
|
7
8
|
require_relative "shared/asv_messageable"
|
|
8
9
|
require_relative "shared/asv_rspecable"
|
|
10
|
+
require_relative "shared/asv_timeoutable"
|
|
9
11
|
require_relative "shared/asv_validatable"
|
|
10
12
|
|
|
11
13
|
module ActiveStorageValidations
|
|
@@ -19,15 +21,19 @@ module ActiveStorageValidations
|
|
|
19
21
|
include ASVAllowBlankable
|
|
20
22
|
include ASVAttachable
|
|
21
23
|
include ASVContextable
|
|
24
|
+
include ASVExceptOnable
|
|
22
25
|
include ASVMessageable
|
|
23
26
|
include ASVRspecable
|
|
27
|
+
include ASVTimeoutable
|
|
24
28
|
include ASVValidatable
|
|
25
29
|
|
|
26
30
|
def initialize(attribute_name)
|
|
27
31
|
initialize_allow_blankable
|
|
28
32
|
initialize_contextable
|
|
33
|
+
initialize_except_onable
|
|
29
34
|
initialize_messageable
|
|
30
35
|
initialize_rspecable
|
|
36
|
+
initialize_timeoutable
|
|
31
37
|
@attribute_name = attribute_name
|
|
32
38
|
@width_min = @width_max = @height_min = @height_max = nil
|
|
33
39
|
end
|
|
@@ -87,7 +93,9 @@ module ActiveStorageValidations
|
|
|
87
93
|
|
|
88
94
|
is_a_valid_active_storage_attribute? &&
|
|
89
95
|
is_context_valid? &&
|
|
96
|
+
is_except_on_valid? &&
|
|
90
97
|
is_allowing_blank? &&
|
|
98
|
+
is_timeout_valid? &&
|
|
91
99
|
is_custom_message_valid? &&
|
|
92
100
|
width_not_smaller_than_min? &&
|
|
93
101
|
width_larger_than_min? &&
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base_comparison_validator_matcher"
|
|
4
|
+
require_relative "shared/asv_timeoutable"
|
|
4
5
|
|
|
5
6
|
module ActiveStorageValidations
|
|
6
7
|
module Matchers
|
|
@@ -9,6 +10,13 @@ module ActiveStorageValidations
|
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
class DurationValidatorMatcher < BaseComparisonValidatorMatcher
|
|
13
|
+
include ASVTimeoutable
|
|
14
|
+
|
|
15
|
+
def initialize(attribute_name)
|
|
16
|
+
super
|
|
17
|
+
initialize_timeoutable
|
|
18
|
+
end
|
|
19
|
+
|
|
12
20
|
def description
|
|
13
21
|
"validate file duration of :#{@attribute_name}"
|
|
14
22
|
end
|
|
@@ -4,6 +4,7 @@ require_relative "shared/asv_active_storageable"
|
|
|
4
4
|
require_relative "shared/asv_allow_blankable"
|
|
5
5
|
require_relative "shared/asv_attachable"
|
|
6
6
|
require_relative "shared/asv_contextable"
|
|
7
|
+
require_relative "shared/asv_except_onable"
|
|
7
8
|
require_relative "shared/asv_messageable"
|
|
8
9
|
require_relative "shared/asv_rspecable"
|
|
9
10
|
require_relative "shared/asv_validatable"
|
|
@@ -19,6 +20,7 @@ module ActiveStorageValidations
|
|
|
19
20
|
include ASVAllowBlankable
|
|
20
21
|
include ASVAttachable
|
|
21
22
|
include ASVContextable
|
|
23
|
+
include ASVExceptOnable
|
|
22
24
|
include ASVMessageable
|
|
23
25
|
include ASVRspecable
|
|
24
26
|
include ASVValidatable
|
|
@@ -26,6 +28,7 @@ module ActiveStorageValidations
|
|
|
26
28
|
def initialize(attribute_name)
|
|
27
29
|
initialize_allow_blankable
|
|
28
30
|
initialize_contextable
|
|
31
|
+
initialize_except_onable
|
|
29
32
|
initialize_messageable
|
|
30
33
|
initialize_rspecable
|
|
31
34
|
@attribute_name = attribute_name
|
|
@@ -57,6 +60,7 @@ module ActiveStorageValidations
|
|
|
57
60
|
|
|
58
61
|
is_a_valid_active_storage_attribute? &&
|
|
59
62
|
is_context_valid? &&
|
|
63
|
+
is_except_on_valid? &&
|
|
60
64
|
is_custom_message_valid? &&
|
|
61
65
|
file_count_not_smaller_than_min? &&
|
|
62
66
|
file_count_equal_min? &&
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base_comparison_validator_matcher"
|
|
4
|
+
require_relative "shared/asv_timeoutable"
|
|
4
5
|
|
|
5
6
|
module ActiveStorageValidations
|
|
6
7
|
module Matchers
|
|
@@ -9,6 +10,13 @@ module ActiveStorageValidations
|
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
class PagesValidatorMatcher < BaseComparisonValidatorMatcher
|
|
13
|
+
include ASVTimeoutable
|
|
14
|
+
|
|
15
|
+
def initialize(attribute_name)
|
|
16
|
+
super
|
|
17
|
+
initialize_timeoutable
|
|
18
|
+
end
|
|
19
|
+
|
|
12
20
|
def description
|
|
13
21
|
"validate file number of pages of :#{@attribute_name}"
|
|
14
22
|
end
|
|
@@ -4,8 +4,10 @@ require_relative "shared/asv_active_storageable"
|
|
|
4
4
|
require_relative "shared/asv_allow_blankable"
|
|
5
5
|
require_relative "shared/asv_attachable"
|
|
6
6
|
require_relative "shared/asv_contextable"
|
|
7
|
+
require_relative "shared/asv_except_onable"
|
|
7
8
|
require_relative "shared/asv_messageable"
|
|
8
9
|
require_relative "shared/asv_rspecable"
|
|
10
|
+
require_relative "shared/asv_timeoutable"
|
|
9
11
|
require_relative "shared/asv_validatable"
|
|
10
12
|
|
|
11
13
|
module ActiveStorageValidations
|
|
@@ -19,15 +21,19 @@ module ActiveStorageValidations
|
|
|
19
21
|
include ASVAllowBlankable
|
|
20
22
|
include ASVAttachable
|
|
21
23
|
include ASVContextable
|
|
24
|
+
include ASVExceptOnable
|
|
22
25
|
include ASVMessageable
|
|
23
26
|
include ASVRspecable
|
|
27
|
+
include ASVTimeoutable
|
|
24
28
|
include ASVValidatable
|
|
25
29
|
|
|
26
30
|
def initialize(attribute_name)
|
|
27
31
|
initialize_allow_blankable
|
|
28
32
|
initialize_contextable
|
|
33
|
+
initialize_except_onable
|
|
29
34
|
initialize_messageable
|
|
30
35
|
initialize_rspecable
|
|
36
|
+
initialize_timeoutable
|
|
31
37
|
@attribute_name = attribute_name
|
|
32
38
|
end
|
|
33
39
|
|
|
@@ -44,6 +50,8 @@ module ActiveStorageValidations
|
|
|
44
50
|
|
|
45
51
|
is_a_valid_active_storage_attribute? &&
|
|
46
52
|
is_context_valid? &&
|
|
53
|
+
is_except_on_valid? &&
|
|
54
|
+
is_timeout_valid? &&
|
|
47
55
|
is_custom_message_valid? &&
|
|
48
56
|
is_valid_when_image_processable? &&
|
|
49
57
|
is_invalid_when_file_not_processable?
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module ActiveStorageValidations
|
|
6
|
+
module Matchers
|
|
7
|
+
module ASVExceptOnable
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
def initialize_except_onable
|
|
11
|
+
@except_on = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def except_on(context)
|
|
15
|
+
@except_on = context
|
|
16
|
+
self
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def is_except_on_valid?
|
|
22
|
+
return true if !@except_on && attribute_validators.none? { |validator| validator.options[:except_on] }
|
|
23
|
+
|
|
24
|
+
ensure_except_on_present!
|
|
25
|
+
ensure_except_on_valid!
|
|
26
|
+
|
|
27
|
+
if @except_on.is_a?(Array)
|
|
28
|
+
(validator_except_on_contexts & @except_on.map(&:to_s)) == validator_except_on_contexts || raise_except_on_not_listed_error
|
|
29
|
+
elsif @except_on.is_a?(Symbol)
|
|
30
|
+
validator_except_on_contexts.include?(@except_on.to_s) || raise_except_on_not_listed_error
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def ensure_except_on_present!
|
|
35
|
+
raise ArgumentError, "This validator matcher needs the #except_on option to work since its validator has one" if !@except_on && attribute_validators.all? { |validator| validator.options[:except_on] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ensure_except_on_valid!
|
|
39
|
+
raise ArgumentError, "This validator matcher option only allows a symbol or an array" if !(@except_on.is_a?(Symbol) || @except_on.is_a?(Array))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def validator_except_on_contexts
|
|
43
|
+
attribute_validators.map do |validator|
|
|
44
|
+
case validator.options[:except_on]
|
|
45
|
+
when Array then validator.options[:except_on].map { |context| context.to_s }
|
|
46
|
+
when NilClass then nil
|
|
47
|
+
else validator.options[:except_on].to_s
|
|
48
|
+
end
|
|
49
|
+
end.flatten.compact
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def raise_except_on_not_listed_error
|
|
53
|
+
raise ArgumentError, "One of the provided contexts to the #except_on method is not found in any of the listed contexts for this attribute"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module ActiveStorageValidations
|
|
6
|
+
module Matchers
|
|
7
|
+
module ASVSpoofingProtectable
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
UNSET = Object.new.freeze
|
|
11
|
+
|
|
12
|
+
def initialize_spoofing_protectable
|
|
13
|
+
@spoofing_protection = UNSET
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def spoofing_protection(value = true)
|
|
17
|
+
@spoofing_protection = value
|
|
18
|
+
self
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def is_spoofing_protection_valid?
|
|
24
|
+
return true if @spoofing_protection.equal?(UNSET)
|
|
25
|
+
|
|
26
|
+
expected = normalize_spoofing_protection(@spoofing_protection)
|
|
27
|
+
attribute_validators.any? do |validator|
|
|
28
|
+
next false unless validator.options.key?(:spoofing_protection)
|
|
29
|
+
|
|
30
|
+
normalize_spoofing_protection(validator.options[:spoofing_protection]) == expected
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def normalize_spoofing_protection(value)
|
|
35
|
+
case value
|
|
36
|
+
when true, :file then :file
|
|
37
|
+
when :magika then :magika
|
|
38
|
+
when false then false
|
|
39
|
+
else value
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module ActiveStorageValidations
|
|
6
|
+
module Matchers
|
|
7
|
+
module ASVTimeoutable
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
UNSET = Object.new.freeze
|
|
11
|
+
|
|
12
|
+
def initialize_timeoutable
|
|
13
|
+
@timeout = UNSET
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def timeout(value)
|
|
17
|
+
@timeout = value
|
|
18
|
+
self
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def is_timeout_valid?
|
|
24
|
+
return true if @timeout.equal?(UNSET)
|
|
25
|
+
|
|
26
|
+
attribute_validators.any? do |validator|
|
|
27
|
+
validator.options.key?(:timeout) && validator.options[:timeout] == @timeout
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -29,7 +29,15 @@ module ActiveStorageValidations
|
|
|
29
29
|
|
|
30
30
|
def self.mock_metadata(attachment, metadata = {})
|
|
31
31
|
asv_metadata_available_keys = { width: nil, height: nil, duration: nil, content_type: nil }
|
|
32
|
-
|
|
32
|
+
merged = asv_metadata_available_keys.merge(metadata)
|
|
33
|
+
# Stubbing Analyzer.new also covers content-type sniffers (File / Magika),
|
|
34
|
+
# which call #content_type — not #metadata. Default to the dummy file type
|
|
35
|
+
# so stacked validators with spoofing_protection still pass under the mock.
|
|
36
|
+
detected_content_type = merged[:content_type].presence || "image/png"
|
|
37
|
+
mock = Struct.new(:metadata, :content_type).new(
|
|
38
|
+
merged,
|
|
39
|
+
{ content_type: detected_content_type, content_type_backend: "file" }
|
|
40
|
+
)
|
|
33
41
|
|
|
34
42
|
stub_method(ActiveStorageValidations::Analyzer, :new, mock) do
|
|
35
43
|
yield
|
|
@@ -19,6 +19,28 @@ module ActiveStorageValidations
|
|
|
19
19
|
].freeze
|
|
20
20
|
METADATA_KEYS = %i[].freeze
|
|
21
21
|
|
|
22
|
+
# Content types whose libvips loaders are marked untrusted / unfuzzed
|
|
23
|
+
# (VIPS_OPERATION_UNTRUSTED). After Rails calls `Vips.block_untrusted(true)`,
|
|
24
|
+
# analysis of these types returns no width/height (empty metadata here),
|
|
25
|
+
# which is not the same as a corrupt / unprocessable file.
|
|
26
|
+
#
|
|
27
|
+
# Matches the formats called out in the Active Storage security release
|
|
28
|
+
# changelogs (7.2.3.2 / 8.0.5.1 / 8.1.3.1):
|
|
29
|
+
# https://github.com/rails/rails/blob/v8.1.3.1/activestorage/CHANGELOG.md
|
|
30
|
+
# https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm
|
|
31
|
+
VIPS_UNTRUSTED_CONTENT_TYPES = %w[
|
|
32
|
+
image/bmp
|
|
33
|
+
image/vnd.microsoft.icon
|
|
34
|
+
image/vnd.adobe.photoshop
|
|
35
|
+
image/svg+xml
|
|
36
|
+
image/jxl
|
|
37
|
+
image/jp2
|
|
38
|
+
image/x-portable-anymap
|
|
39
|
+
image/x-portable-bitmap
|
|
40
|
+
image/x-portable-graymap
|
|
41
|
+
image/x-portable-pixmap
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
22
44
|
def validate_each(record, attribute, _value)
|
|
23
45
|
return if no_attachments?(record, attribute)
|
|
24
46
|
|
|
@@ -28,10 +50,47 @@ module ActiveStorageValidations
|
|
|
28
50
|
private
|
|
29
51
|
|
|
30
52
|
def is_valid?(record, attribute, attachable, metadata)
|
|
31
|
-
return if
|
|
53
|
+
return if metadata.present?
|
|
54
|
+
return if vips_unanalyzable_by_policy?(attachable)
|
|
32
55
|
|
|
33
56
|
errors_options = initialize_error_options(options, attachable)
|
|
34
57
|
add_error(record, attribute, ERROR_TYPES.first, **errors_options)
|
|
35
58
|
end
|
|
59
|
+
|
|
60
|
+
def vips_unanalyzable_by_policy?(attachable)
|
|
61
|
+
image_processor == :vips &&
|
|
62
|
+
self.class.vips_untrusted_operations_blocked? &&
|
|
63
|
+
VIPS_UNTRUSTED_CONTENT_TYPES.include?(attachable_content_type(attachable))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class << self
|
|
67
|
+
# Detect whether libvips is currently blocking untrusted operations.
|
|
68
|
+
# There is no public getter for `Vips.block_untrusted`, so we probe whether
|
|
69
|
+
# an untrusted loader (svgload) is available via `vips_foreign_find_load`.
|
|
70
|
+
def vips_untrusted_operations_blocked?
|
|
71
|
+
return @vips_untrusted_operations_blocked if defined?(@vips_untrusted_operations_blocked)
|
|
72
|
+
|
|
73
|
+
@vips_untrusted_operations_blocked = detect_vips_untrusted_operations_blocked?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def reset_vips_untrusted_operations_blocked_cache!
|
|
77
|
+
remove_instance_variable(:@vips_untrusted_operations_blocked) if defined?(@vips_untrusted_operations_blocked)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def detect_vips_untrusted_operations_blocked?
|
|
83
|
+
require "vips"
|
|
84
|
+
require "tempfile"
|
|
85
|
+
|
|
86
|
+
Tempfile.create([ "asv_vips_untrusted_probe", ".svg" ]) do |file|
|
|
87
|
+
file.write('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
|
|
88
|
+
file.flush
|
|
89
|
+
::Vips.vips_foreign_find_load(file.path).nil?
|
|
90
|
+
end
|
|
91
|
+
rescue LoadError, StandardError
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
end
|
|
36
95
|
end
|
|
37
96
|
end
|
|
@@ -11,6 +11,12 @@ module ActiveStorageValidations
|
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
initializer "active_storage_validations.form_builder" do
|
|
15
|
+
ActiveSupport.on_load(:action_view) do
|
|
16
|
+
ActionView::Helpers::FormBuilder.prepend(ActiveStorageValidations::FormBuilder)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
14
20
|
initializer "active_storage_validations.extend_active_storage_blob" do
|
|
15
21
|
ActiveSupport.on_load(:active_storage_blob) do
|
|
16
22
|
include ActiveStorageValidations::ASVBlobMetadatable
|
|
@@ -17,15 +17,62 @@ module ActiveStorageValidations
|
|
|
17
17
|
# attachable with the corresponding analyzer and set the metadata in the
|
|
18
18
|
# blob.
|
|
19
19
|
def metadata_for(blob, attachable, metadata_keys)
|
|
20
|
+
if content_type_metadata_keys?(metadata_keys)
|
|
21
|
+
return content_type_metadata_for(blob, attachable)
|
|
22
|
+
end
|
|
23
|
+
|
|
20
24
|
return blob.active_storage_validations_metadata if blob_has_asv_metadata?(blob, metadata_keys)
|
|
21
25
|
|
|
26
|
+
new_metadata = generate_metadata_for(attachable, metadata_keys) || {}
|
|
27
|
+
blob.merge_into_active_storage_validations_metadata(new_metadata)
|
|
28
|
+
blob.save!
|
|
29
|
+
|
|
30
|
+
blob.active_storage_validations_metadata
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def content_type_metadata_keys?(metadata_keys)
|
|
34
|
+
metadata_keys == ActiveStorageValidations::ContentTypeValidator::METADATA_KEYS
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Same cache semantics as +metadata_for+, plus backend matching.
|
|
38
|
+
# Legacy blobs that only have +asv_content_type+ (no backend key) are treated
|
|
39
|
+
# as +:file+ — so existing file-backend analyses keep hitting the cache and
|
|
40
|
+
# are not re-analyzed / rewritten.
|
|
41
|
+
def content_type_metadata_for(blob, attachable)
|
|
42
|
+
backend = spoofing_protection_backend
|
|
43
|
+
cached = blob.active_storage_validations_metadata
|
|
44
|
+
return cached if content_type_cache_hit?(cached, backend)
|
|
45
|
+
|
|
46
|
+
metadata_keys = ActiveStorageValidations::ContentTypeValidator::METADATA_KEYS
|
|
22
47
|
new_metadata = generate_metadata_for(attachable, metadata_keys)
|
|
48
|
+
return failed_content_type_metadata(backend) if content_type_analysis_failed?(new_metadata)
|
|
49
|
+
|
|
23
50
|
blob.merge_into_active_storage_validations_metadata(new_metadata)
|
|
24
51
|
blob.save!
|
|
25
52
|
|
|
26
53
|
blob.active_storage_validations_metadata
|
|
27
54
|
end
|
|
28
55
|
|
|
56
|
+
def content_type_analysis_failed?(new_metadata)
|
|
57
|
+
new_metadata.blank? || new_metadata[:content_type].blank?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Do not return / persist another backend's cached type after a failed sniff
|
|
61
|
+
# (timeout, unsupported file, invalid Magika JSON, …). Fail closed for this
|
|
62
|
+
# request; leave blob metadata unchanged so a later attempt can retry.
|
|
63
|
+
def failed_content_type_metadata(backend)
|
|
64
|
+
{ content_type: nil, content_type_backend: backend.to_s }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def content_type_cache_hit?(cached, backend)
|
|
68
|
+
return false unless cached.present? && cached[:content_type].present?
|
|
69
|
+
|
|
70
|
+
# Missing / blank backend => legacy file analysis (pre-Magika support).
|
|
71
|
+
# Those blobs keep hitting the cache for +:file+ and are not rewritten.
|
|
72
|
+
stored_backend = (cached[:content_type_backend].presence || "file").to_s
|
|
73
|
+
stored_backend == backend.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
29
76
|
def blob_has_asv_metadata?(blob, metadata_keys)
|
|
30
77
|
return false unless blob.active_storage_validations_metadata.present?
|
|
31
78
|
|
|
@@ -33,7 +80,7 @@ module ActiveStorageValidations
|
|
|
33
80
|
end
|
|
34
81
|
|
|
35
82
|
def generate_metadata_for(attachable, metadata_keys)
|
|
36
|
-
if metadata_keys
|
|
83
|
+
if content_type_metadata_keys?(metadata_keys)
|
|
37
84
|
content_type_analyzer_for(attachable).content_type
|
|
38
85
|
else
|
|
39
86
|
metadata_analyzer_for(attachable).metadata
|
|
@@ -52,15 +99,15 @@ module ActiveStorageValidations
|
|
|
52
99
|
end
|
|
53
100
|
|
|
54
101
|
def pdf_analyzer_for(attachable)
|
|
55
|
-
ActiveStorageValidations::Analyzer::PdfAnalyzer.new(attachable)
|
|
102
|
+
ActiveStorageValidations::Analyzer::PdfAnalyzer.new(attachable, **analyzer_timeout_options)
|
|
56
103
|
end
|
|
57
104
|
|
|
58
105
|
def image_analyzer_for(attachable)
|
|
59
106
|
case image_processor
|
|
60
107
|
when :mini_magick
|
|
61
|
-
ActiveStorageValidations::Analyzer::ImageAnalyzer::ImageMagick.new(attachable)
|
|
108
|
+
ActiveStorageValidations::Analyzer::ImageAnalyzer::ImageMagick.new(attachable, **analyzer_timeout_options)
|
|
62
109
|
when :vips
|
|
63
|
-
ActiveStorageValidations::Analyzer::ImageAnalyzer::Vips.new(attachable)
|
|
110
|
+
ActiveStorageValidations::Analyzer::ImageAnalyzer::Vips.new(attachable, **analyzer_timeout_options)
|
|
64
111
|
end
|
|
65
112
|
end
|
|
66
113
|
|
|
@@ -71,19 +118,30 @@ module ActiveStorageValidations
|
|
|
71
118
|
end
|
|
72
119
|
|
|
73
120
|
def video_analyzer_for(attachable)
|
|
74
|
-
ActiveStorageValidations::Analyzer::VideoAnalyzer.new(attachable)
|
|
121
|
+
ActiveStorageValidations::Analyzer::VideoAnalyzer.new(attachable, **analyzer_timeout_options)
|
|
75
122
|
end
|
|
76
123
|
|
|
77
124
|
def audio_analyzer_for(attachable)
|
|
78
|
-
ActiveStorageValidations::Analyzer::AudioAnalyzer.new(attachable)
|
|
125
|
+
ActiveStorageValidations::Analyzer::AudioAnalyzer.new(attachable, **analyzer_timeout_options)
|
|
79
126
|
end
|
|
80
127
|
|
|
81
128
|
def fallback_analyzer_for(attachable)
|
|
82
|
-
ActiveStorageValidations::Analyzer::NullAnalyzer.new(attachable)
|
|
129
|
+
ActiveStorageValidations::Analyzer::NullAnalyzer.new(attachable, **analyzer_timeout_options)
|
|
83
130
|
end
|
|
84
131
|
|
|
85
132
|
def content_type_analyzer_for(attachable)
|
|
86
|
-
|
|
133
|
+
case spoofing_protection_backend
|
|
134
|
+
when :magika
|
|
135
|
+
ActiveStorageValidations::Analyzer::ContentTypeAnalyzer::Magika.new(attachable, **analyzer_timeout_options)
|
|
136
|
+
else
|
|
137
|
+
ActiveStorageValidations::Analyzer::ContentTypeAnalyzer::File.new(attachable, **analyzer_timeout_options)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Passes raw validator +timeout:+ through to analyzers. Kept out of
|
|
142
|
+
# AVAILABLE_CHECKS so ASVOptionable does not flatten it as a comparison bound.
|
|
143
|
+
def analyzer_timeout_options
|
|
144
|
+
options.key?(:timeout) ? { timeout: options[:timeout] } : {}
|
|
87
145
|
end
|
|
88
146
|
end
|
|
89
147
|
end
|
|
@@ -44,7 +44,7 @@ module ActiveStorageValidations
|
|
|
44
44
|
return to_enum(:attachables_and_blobs, record, attribute) if changes.blank? || !block_given?
|
|
45
45
|
|
|
46
46
|
if changes.is_a?(ActiveStorage::Attached::Changes::CreateMany)
|
|
47
|
-
changes.attachables.zip(changes.blobs).uniq { |
|
|
47
|
+
changes.attachables.zip(changes.blobs).uniq { |attachable, blob| attachable_blob_key(attachable, blob) }.each do |attachable, blob|
|
|
48
48
|
yield attachable, blob
|
|
49
49
|
end
|
|
50
50
|
else
|
|
@@ -52,6 +52,12 @@ module ActiveStorageValidations
|
|
|
52
52
|
end
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
def attachable_blob_key(attachable, blob)
|
|
56
|
+
return [ :blob_id, blob.id ] if blob&.id
|
|
57
|
+
|
|
58
|
+
[ :attachable, attachable.object_id ]
|
|
59
|
+
end
|
|
60
|
+
|
|
55
61
|
def changes_for(record, attribute)
|
|
56
62
|
if record.public_send(attribute).is_a?(ActiveStorage::Attached::One)
|
|
57
63
|
record.attachment_changes[attribute.to_s].presence || record.public_send(attribute)
|
|
@@ -120,14 +126,14 @@ module ActiveStorageValidations
|
|
|
120
126
|
def attachable_io(attachable, max_byte_size: nil)
|
|
121
127
|
io = case attachable
|
|
122
128
|
when ActiveStorage::Blob
|
|
123
|
-
|
|
129
|
+
max_byte_size ? attachable.download_chunk(0...max_byte_size) : attachable.download
|
|
124
130
|
when ActionDispatch::Http::UploadedFile
|
|
125
131
|
max_byte_size ? attachable.read(max_byte_size) : attachable.read
|
|
126
132
|
when Rack::Test::UploadedFile
|
|
127
133
|
max_byte_size ? attachable.read(max_byte_size) : attachable.read
|
|
128
134
|
when String
|
|
129
135
|
blob = ActiveStorage::Blob.find_signed!(attachable)
|
|
130
|
-
|
|
136
|
+
max_byte_size ? blob.download_chunk(0...max_byte_size) : blob.download
|
|
131
137
|
when Hash
|
|
132
138
|
max_byte_size ? attachable[:io].read(max_byte_size) : attachable[:io].read
|
|
133
139
|
when File
|
|
@@ -208,13 +214,6 @@ module ActiveStorageValidations
|
|
|
208
214
|
end
|
|
209
215
|
alias :supports_pathname_attachment? :supports_file_attachment?
|
|
210
216
|
|
|
211
|
-
# Check if the current Rails version supports ActiveStorage::Blob#download_chunk
|
|
212
|
-
#
|
|
213
|
-
# https://github.com/rails/rails/blob/7-0-stable/activestorage/CHANGELOG.md#rails-700alpha1-september-15-2021
|
|
214
|
-
def supports_blob_download_chunk?
|
|
215
|
-
Rails.gem_version >= Gem::Version.new("7.0.0.alpha1")
|
|
216
|
-
end
|
|
217
|
-
|
|
218
217
|
# Retrieve the content_type from the file name only
|
|
219
218
|
def marcel_content_type_from_filename(attachable)
|
|
220
219
|
Marcel::MimeType.for(name: attachable_filename(attachable).to_s)
|