safe_image 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.
data/lib/safe_image.rb ADDED
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "safe_image/version"
4
+
5
+ module SafeImage
6
+ class Error < StandardError; end
7
+ class UnsupportedFormatError < Error; end
8
+ class UnsafePathError < Error; end
9
+ class InvalidImageError < Error; end
10
+ class LimitError < Error; end
11
+
12
+ # Default decompression-bomb ceiling for the libvips processing path when the
13
+ # caller does not pass an explicit max_pixels. Mirrored in the native
14
+ # extension (SAFE_IMAGE_DEFAULT_MAX_PIXELS) and aligned with the 128MP area
15
+ # limit on the ImageMagick path. Pass max_pixels to raise or lower it.
16
+ DEFAULT_MAX_PIXELS = 128 * 1024 * 1024
17
+ end
18
+
19
+ require_relative "safe_image/native"
20
+ require_relative "safe_image/result"
21
+ require_relative "safe_image/runner"
22
+ require_relative "safe_image/sandbox"
23
+ require_relative "safe_image/path_safety"
24
+ require_relative "safe_image/optimizer"
25
+ require_relative "safe_image/svg_metadata"
26
+ require_relative "safe_image/svg_sanitizer"
27
+ require_relative "safe_image/remote"
28
+ require_relative "safe_image/image_magick_backend"
29
+ require_relative "safe_image/jpegli_backend"
30
+ require_relative "safe_image/vips_backend"
31
+ require_relative "safe_image/processor"
32
+ require_relative "safe_image/discourse_compat"
33
+
34
+ module SafeImage
35
+ module_function
36
+
37
+ @sandbox_enabled = false
38
+
39
+ def enable_sandbox!
40
+ raise Error, "landlock sandbox requested but unavailable" unless Sandbox.available?
41
+ @sandbox_enabled = true
42
+ end
43
+
44
+ def disable_sandbox!
45
+ @sandbox_enabled = false
46
+ end
47
+
48
+ def sandbox_enabled?
49
+ @sandbox_enabled && ENV["SAFE_IMAGE_SANDBOX_CHILD"] != "1"
50
+ end
51
+
52
+ def with_sandbox_disabled
53
+ previous = @sandbox_enabled
54
+ @sandbox_enabled = false
55
+ yield
56
+ ensure
57
+ @sandbox_enabled = previous
58
+ end
59
+
60
+ def sandbox_available? = Sandbox.available?
61
+
62
+ def sandbox_call(operation, args: [], kwargs: {})
63
+ Sandbox.public_call!(operation, args: args, kwargs: kwargs)
64
+ end
65
+
66
+ def maybe_sandbox(operation, args: [], kwargs: {})
67
+ return yield unless sandbox_enabled?
68
+
69
+ sandbox_call(operation, args: args, kwargs: kwargs)
70
+ end
71
+
72
+ def probe(path, max_pixels: nil)
73
+ maybe_sandbox(:probe, args: [path], kwargs: { max_pixels: max_pixels }) do
74
+ path = PathSafety.local_path(path)
75
+
76
+ if File.extname(path).downcase == ".svg"
77
+ info = SvgMetadata.probe(path, max_pixels: max_pixels)
78
+ Result.new(
79
+ input: File.expand_path(path),
80
+ output: nil,
81
+ input_format: "svg",
82
+ output_format: nil,
83
+ width: info.fetch(:width),
84
+ height: info.fetch(:height),
85
+ filesize: File.size(path),
86
+ backend: "svg-metadata",
87
+ duration_ms: info.fetch(:duration_ms),
88
+ optimizer: nil
89
+ )
90
+ else
91
+ begin
92
+ Processor.new(max_pixels: max_pixels).probe(path)
93
+ rescue UnsupportedFormatError
94
+ info = ImageMagickBackend.probe(path, max_pixels: max_pixels)
95
+ Result.new(
96
+ input: File.expand_path(path),
97
+ output: nil,
98
+ input_format: info.fetch(:input_format),
99
+ output_format: nil,
100
+ width: info.fetch(:width),
101
+ height: info.fetch(:height),
102
+ filesize: File.size(path),
103
+ backend: "imagemagick",
104
+ duration_ms: info.fetch(:duration_ms),
105
+ optimizer: nil
106
+ )
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ def type(path, max_pixels: nil)
113
+ maybe_sandbox(:type, args: [path], kwargs: { max_pixels: max_pixels }) do
114
+ fastimage_type(probe(path, max_pixels: max_pixels).input_format)
115
+ end
116
+ end
117
+
118
+ def size(path, max_pixels: nil)
119
+ maybe_sandbox(:size, args: [path], kwargs: { max_pixels: max_pixels }) do
120
+ result = probe(path, max_pixels: max_pixels)
121
+ [result.width, result.height]
122
+ end
123
+ end
124
+
125
+ def dimensions(path, max_pixels: nil)
126
+ size(path, max_pixels: max_pixels)
127
+ end
128
+
129
+ def info(path, max_pixels: nil, animated: false, orientation: false)
130
+ maybe_sandbox(:info, args: [path], kwargs: { max_pixels: max_pixels, animated: animated, orientation: orientation }) do
131
+ result = probe(path, max_pixels: max_pixels)
132
+ type = fastimage_type(result.input_format)
133
+ Info.new(
134
+ path: result.input,
135
+ type: type,
136
+ width: result.width,
137
+ height: result.height,
138
+ size: [result.width, result.height],
139
+ animated: animated ? animated?(path, max_pixels: max_pixels) : nil,
140
+ orientation: orientation ? orientation(path, max_pixels: max_pixels) : nil
141
+ )
142
+ end
143
+ end
144
+
145
+ def orientation(path, max_pixels: nil)
146
+ maybe_sandbox(:orientation, args: [path], kwargs: { max_pixels: max_pixels }) do
147
+ if File.extname(PathSafety.local_path(path)).downcase == ".svg"
148
+ 1
149
+ else
150
+ probe(path, max_pixels: max_pixels) if max_pixels
151
+ ImageMagickBackend.orientation(path)
152
+ end
153
+ end
154
+ end
155
+
156
+ def fastimage_type(format)
157
+ format.to_s == "jpg" ? :jpeg : format.to_s.to_sym
158
+ end
159
+
160
+ def remote_info(url, **kwargs)
161
+ Remote.info(url, **kwargs)
162
+ end
163
+
164
+ def remote_size(url, **kwargs)
165
+ Remote.size(url, **kwargs)
166
+ end
167
+
168
+ def remote_dimensions(url, **kwargs)
169
+ remote_size(url, **kwargs)
170
+ end
171
+
172
+ def remote_type(url, **kwargs)
173
+ Remote.type(url, **kwargs)
174
+ end
175
+
176
+ def remote_animated?(url, **kwargs)
177
+ Remote.animated?(url, **kwargs)
178
+ end
179
+
180
+ def fetch_remote(url, **kwargs, &block)
181
+ Remote.fetch(url, **kwargs, &block)
182
+ end
183
+
184
+ def thumbnail(input:, output:, width:, height:, format: nil, quality: 85, max_pixels: nil, backend: :vips, optimize: false, optimize_mode: :lossless, execution: :inline, encoder: :auto, chroma_subsampling: :auto)
185
+ maybe_sandbox(
186
+ :thumbnail,
187
+ kwargs: {
188
+ input: input,
189
+ output: output,
190
+ width: width,
191
+ height: height,
192
+ format: format,
193
+ quality: quality,
194
+ max_pixels: max_pixels,
195
+ backend: backend,
196
+ optimize: optimize,
197
+ optimize_mode: optimize_mode,
198
+ execution: :inline,
199
+ encoder: encoder,
200
+ chroma_subsampling: chroma_subsampling
201
+ }
202
+ ) do
203
+ Processor.new(max_pixels: max_pixels, backend: backend, execution: execution, encoder: encoder, chroma_subsampling: chroma_subsampling).thumbnail(
204
+ input: input,
205
+ output: output,
206
+ width: width,
207
+ height: height,
208
+ format: format,
209
+ quality: quality,
210
+ optimize: optimize,
211
+ optimize_mode: optimize_mode
212
+ )
213
+ end
214
+ end
215
+
216
+ def optimize(path, mode: :lossless, strip_metadata: true, quality: nil, strict: true)
217
+ maybe_sandbox(:optimize, args: [path], kwargs: { mode: mode, strip_metadata: strip_metadata, quality: quality, strict: strict }) do
218
+ Optimizer.optimize(path, mode: mode, strip_metadata: strip_metadata, quality: quality, strict: strict)
219
+ end
220
+ end
221
+
222
+ def resize(*args, **kwargs)
223
+ maybe_sandbox(:resize, args: args, kwargs: kwargs) { DiscourseCompat.resize(*args, **kwargs) }
224
+ end
225
+
226
+ def crop(*args, **kwargs)
227
+ maybe_sandbox(:crop, args: args, kwargs: kwargs) { DiscourseCompat.crop(*args, **kwargs) }
228
+ end
229
+
230
+ def downsize(*args, **kwargs)
231
+ maybe_sandbox(:downsize, args: args, kwargs: kwargs) { DiscourseCompat.downsize(*args, **kwargs) }
232
+ end
233
+
234
+ def convert(*args, **kwargs)
235
+ maybe_sandbox(:convert, args: args, kwargs: kwargs) { DiscourseCompat.convert(*args, **kwargs) }
236
+ end
237
+
238
+ def convert_to_jpeg(*args, **kwargs)
239
+ maybe_sandbox(:convert_to_jpeg, args: args, kwargs: kwargs) { DiscourseCompat.convert_to_jpeg(*args, **kwargs) }
240
+ end
241
+
242
+ def fix_orientation(*args, **kwargs)
243
+ maybe_sandbox(:fix_orientation, args: args, kwargs: kwargs) { DiscourseCompat.fix_orientation(*args, **kwargs) }
244
+ end
245
+
246
+ def convert_favicon_to_png(*args, **kwargs)
247
+ maybe_sandbox(:convert_favicon_to_png, args: args, kwargs: kwargs) { DiscourseCompat.convert_favicon_to_png(*args, **kwargs) }
248
+ end
249
+
250
+ def frame_count(*args, **kwargs)
251
+ maybe_sandbox(:frame_count, args: args, kwargs: kwargs) { DiscourseCompat.frame_count(*args, **kwargs) }
252
+ end
253
+
254
+ def animated?(*args, **kwargs)
255
+ path = args.first
256
+ return false if path && File.extname(PathSafety.local_path(path)).downcase == ".svg"
257
+
258
+ maybe_sandbox(:animated?, args: args, kwargs: kwargs) { DiscourseCompat.animated?(*args, **kwargs) }
259
+ end
260
+
261
+ def letter_avatar(*args, **kwargs)
262
+ maybe_sandbox(:letter_avatar, args: args, kwargs: kwargs) { DiscourseCompat.letter_avatar(*args, **kwargs) }
263
+ end
264
+
265
+ def optimize_image!(*args, **kwargs)
266
+ maybe_sandbox(:optimize_image!, args: args, kwargs: kwargs) { DiscourseCompat.optimize_image!(*args, **kwargs) }
267
+ end
268
+
269
+ def sanitize_svg!(*args, **kwargs)
270
+ maybe_sandbox(:sanitize_svg!, args: args, kwargs: kwargs) { SvgSanitizer.sanitize!(*args, **kwargs) }
271
+ end
272
+ end
metadata ADDED
@@ -0,0 +1,140 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: safe_image
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sam Saffron
8
+ - Jarvis
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 1980-01-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rexml
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.4'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.4'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.25'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.25'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake-compiler
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.2'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.2'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop-discourse
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.18'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.18'
83
+ description: 'Safe Image is a small Ruby image-processing boundary for untrusted uploads:
84
+ direct libvips thumbnails/probing, hardened ImageMagick compatibility operations,
85
+ optimisation, SVG sanitising, and optional atomic Landlock sandbox execution.'
86
+ email:
87
+ - sam@discourse.org
88
+ executables: []
89
+ extensions:
90
+ - ext/safe_image_native/extconf.rb
91
+ extra_rdoc_files: []
92
+ files:
93
+ - LICENSE
94
+ - README.md
95
+ - SECURITY.md
96
+ - ext/safe_image_native/extconf.rb
97
+ - ext/safe_image_native/safe_image_native.c
98
+ - lib/safe_image.rb
99
+ - lib/safe_image/RT_sRGB.icm
100
+ - lib/safe_image/discourse_compat.rb
101
+ - lib/safe_image/image_magick_backend.rb
102
+ - lib/safe_image/imagemagick_policy/policy.xml
103
+ - lib/safe_image/jpegli_backend.rb
104
+ - lib/safe_image/native.rb
105
+ - lib/safe_image/optimizer.rb
106
+ - lib/safe_image/path_safety.rb
107
+ - lib/safe_image/processor.rb
108
+ - lib/safe_image/remote.rb
109
+ - lib/safe_image/result.rb
110
+ - lib/safe_image/runner.rb
111
+ - lib/safe_image/sandbox.rb
112
+ - lib/safe_image/svg_metadata.rb
113
+ - lib/safe_image/svg_sanitizer.rb
114
+ - lib/safe_image/version.rb
115
+ - lib/safe_image/vips_backend.rb
116
+ homepage: https://github.com/sam-saffron-jarvis/safe-image
117
+ licenses:
118
+ - MIT
119
+ metadata:
120
+ homepage_uri: https://github.com/sam-saffron-jarvis/safe-image
121
+ source_code_uri: https://github.com/sam-saffron-jarvis/safe-image
122
+ rubygems_mfa_required: 'true'
123
+ rdoc_options: []
124
+ require_paths:
125
+ - lib
126
+ required_ruby_version: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '3.1'
131
+ required_rubygems_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: '0'
136
+ requirements: []
137
+ rubygems_version: 4.0.6
138
+ specification_version: 4
139
+ summary: Hardened image processing boundary for untrusted uploads
140
+ test_files: []