safe_image 0.1.0 → 0.2.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/CHANGELOG.md +158 -0
- data/README.md +475 -281
- data/SECURITY.md +27 -7
- data/lib/safe_image/discourse_compat.rb +267 -98
- data/lib/safe_image/fonts/DEJAVU-LICENSE +187 -0
- data/lib/safe_image/fonts/DejaVuSans.ttf +0 -0
- data/lib/safe_image/ico.rb +286 -0
- data/lib/safe_image/image_magick_backend.rb +39 -3
- data/lib/safe_image/imagemagick_policy/policy.xml +8 -1
- data/lib/safe_image/jpegli_backend.rb +3 -1
- data/lib/safe_image/native.rb +371 -1
- data/lib/safe_image/processor.rb +23 -69
- data/lib/safe_image/remote.rb +15 -3
- data/lib/safe_image/runner.rb +1 -1
- data/lib/safe_image/sandbox.rb +42 -16
- data/lib/safe_image/svg_metadata.rb +59 -29
- data/lib/safe_image/version.rb +1 -1
- data/lib/safe_image/vips_backend.rb +57 -0
- data/lib/safe_image/vips_glue.rb +361 -0
- data/lib/safe_image.rb +143 -36
- metadata +30 -14
- data/ext/safe_image_native/extconf.rb +0 -8
- data/ext/safe_image_native/safe_image_native.c +0 -392
data/lib/safe_image.rb
CHANGED
|
@@ -4,16 +4,31 @@ require_relative "safe_image/version"
|
|
|
4
4
|
|
|
5
5
|
module SafeImage
|
|
6
6
|
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when any operation is attempted before SafeImage.configure!.
|
|
9
|
+
class NotConfiguredError < Error; end
|
|
10
|
+
|
|
7
11
|
class UnsupportedFormatError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when libvips cannot be loaded at runtime. configure!(backend: :vips)
|
|
14
|
+
# surfaces this at boot; operations never fall back to ImageMagick.
|
|
15
|
+
class VipsUnavailableError < UnsupportedFormatError; end
|
|
8
16
|
class UnsafePathError < Error; end
|
|
9
17
|
class InvalidImageError < Error; end
|
|
10
18
|
class LimitError < Error; end
|
|
11
19
|
|
|
12
|
-
# Default decompression-bomb ceiling
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
20
|
+
# Default decompression-bomb ceiling when configure! is not given an explicit
|
|
21
|
+
# max_pixels. Mirrored in the native binding (SAFE_IMAGE_DEFAULT_MAX_PIXELS)
|
|
22
|
+
# and aligned with the 128MP area limit on the ImageMagick path. Per-call
|
|
23
|
+
# max_pixels: overrides the configured value.
|
|
16
24
|
DEFAULT_MAX_PIXELS = 128 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
BACKENDS = %i[vips imagemagick].freeze
|
|
27
|
+
|
|
28
|
+
# Process-wide configuration. configure! builds a frozen instance and swaps
|
|
29
|
+
# it in with a single assignment, so readers never observe a half-applied
|
|
30
|
+
# config.
|
|
31
|
+
Config = Data.define(:backend, :landlock, :max_pixels)
|
|
17
32
|
end
|
|
18
33
|
|
|
19
34
|
require_relative "safe_image/native"
|
|
@@ -25,6 +40,7 @@ require_relative "safe_image/optimizer"
|
|
|
25
40
|
require_relative "safe_image/svg_metadata"
|
|
26
41
|
require_relative "safe_image/svg_sanitizer"
|
|
27
42
|
require_relative "safe_image/remote"
|
|
43
|
+
require_relative "safe_image/ico"
|
|
28
44
|
require_relative "safe_image/image_magick_backend"
|
|
29
45
|
require_relative "safe_image/jpegli_backend"
|
|
30
46
|
require_relative "safe_image/vips_backend"
|
|
@@ -34,46 +50,79 @@ require_relative "safe_image/discourse_compat"
|
|
|
34
50
|
module SafeImage
|
|
35
51
|
module_function
|
|
36
52
|
|
|
37
|
-
@
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
53
|
+
@config = nil
|
|
54
|
+
|
|
55
|
+
# Decides, in one place, everything that varies by host: which backend
|
|
56
|
+
# decodes untrusted bytes, whether operations run inside the Landlock
|
|
57
|
+
# sandbox, and the default decompression-bomb ceiling. Must be called before
|
|
58
|
+
# any operation; calling it again replaces the configuration.
|
|
59
|
+
#
|
|
60
|
+
# Validation is eager so a misconfigured host fails at boot rather than on
|
|
61
|
+
# the first request.
|
|
62
|
+
def configure!(backend:, landlock:, max_pixels: DEFAULT_MAX_PIXELS)
|
|
63
|
+
backend = backend.to_sym
|
|
64
|
+
unless BACKENDS.include?(backend)
|
|
65
|
+
raise ArgumentError, "unknown backend: #{backend.inspect} (expected :vips or :imagemagick)"
|
|
66
|
+
end
|
|
67
|
+
unless [true, false].include?(landlock)
|
|
68
|
+
raise ArgumentError, "landlock must be true or false, got: #{landlock.inspect}"
|
|
69
|
+
end
|
|
70
|
+
max_pixels = Integer(max_pixels)
|
|
71
|
+
raise ArgumentError, "max_pixels must be positive" if max_pixels <= 0
|
|
72
|
+
|
|
73
|
+
case backend
|
|
74
|
+
when :vips
|
|
75
|
+
begin
|
|
76
|
+
VipsGlue.init!
|
|
77
|
+
rescue VipsUnavailableError => e
|
|
78
|
+
raise Error, "backend: :vips requested but libvips is unavailable: #{e.message}"
|
|
79
|
+
end
|
|
80
|
+
when :imagemagick
|
|
81
|
+
unless Runner.available?("magick") || Runner.available?("convert")
|
|
82
|
+
raise Error, "backend: :imagemagick requested but no magick/convert executable was found"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
if landlock && !Sandbox.available?
|
|
86
|
+
raise Error, "landlock: true requested but the Landlock sandbox is unavailable on this host"
|
|
87
|
+
end
|
|
43
88
|
|
|
44
|
-
|
|
45
|
-
@sandbox_enabled = false
|
|
89
|
+
@config = Config.new(backend: backend, landlock: landlock, max_pixels: max_pixels)
|
|
46
90
|
end
|
|
47
91
|
|
|
48
|
-
def
|
|
49
|
-
@
|
|
92
|
+
def config
|
|
93
|
+
@config || raise(NotConfiguredError, "call SafeImage.configure!(backend: :vips | :imagemagick, landlock: true | false) before using SafeImage")
|
|
50
94
|
end
|
|
51
95
|
|
|
52
|
-
def
|
|
53
|
-
previous = @sandbox_enabled
|
|
54
|
-
@sandbox_enabled = false
|
|
55
|
-
yield
|
|
56
|
-
ensure
|
|
57
|
-
@sandbox_enabled = previous
|
|
58
|
-
end
|
|
96
|
+
def configured? = !@config.nil?
|
|
59
97
|
|
|
60
98
|
def sandbox_available? = Sandbox.available?
|
|
61
99
|
|
|
62
|
-
|
|
63
|
-
|
|
100
|
+
# Internal: whether operations must route through the sandbox worker. False
|
|
101
|
+
# before configure! (so configure!'s own availability probes can run
|
|
102
|
+
# commands) and inside worker children (so sandboxed operations never nest).
|
|
103
|
+
def sandbox?
|
|
104
|
+
!!@config&.landlock && ENV["SAFE_IMAGE_SANDBOX_CHILD"] != "1"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Internal: per-call max_pixels overrides the configured default.
|
|
108
|
+
def resolved_max_pixels(max_pixels)
|
|
109
|
+
max_pixels.nil? ? config.max_pixels : max_pixels
|
|
64
110
|
end
|
|
65
111
|
|
|
66
112
|
def maybe_sandbox(operation, args: [], kwargs: {})
|
|
67
|
-
|
|
113
|
+
config
|
|
114
|
+
return yield unless sandbox?
|
|
68
115
|
|
|
69
|
-
|
|
116
|
+
Sandbox.public_call!(operation, args: args, kwargs: kwargs)
|
|
70
117
|
end
|
|
71
118
|
|
|
72
119
|
def probe(path, max_pixels: nil)
|
|
73
120
|
maybe_sandbox(:probe, args: [path], kwargs: { max_pixels: max_pixels }) do
|
|
74
121
|
path = PathSafety.local_path(path)
|
|
122
|
+
max_pixels = resolved_max_pixels(max_pixels)
|
|
75
123
|
|
|
76
|
-
|
|
124
|
+
case File.extname(path).downcase
|
|
125
|
+
when ".svg"
|
|
77
126
|
info = SvgMetadata.probe(path, max_pixels: max_pixels)
|
|
78
127
|
Result.new(
|
|
79
128
|
input: File.expand_path(path),
|
|
@@ -87,10 +136,26 @@ module SafeImage
|
|
|
87
136
|
duration_ms: info.fetch(:duration_ms),
|
|
88
137
|
optimizer: nil
|
|
89
138
|
)
|
|
139
|
+
when ".ico"
|
|
140
|
+
# Pure-Ruby directory parse; reports the largest entry's dimensions.
|
|
141
|
+
info = Ico.probe(path, max_pixels: max_pixels)
|
|
142
|
+
Result.new(
|
|
143
|
+
input: File.expand_path(path),
|
|
144
|
+
output: nil,
|
|
145
|
+
input_format: "ico",
|
|
146
|
+
output_format: nil,
|
|
147
|
+
width: info.fetch(:width),
|
|
148
|
+
height: info.fetch(:height),
|
|
149
|
+
filesize: File.size(path),
|
|
150
|
+
backend: "ico-metadata",
|
|
151
|
+
duration_ms: info.fetch(:duration_ms),
|
|
152
|
+
optimizer: nil
|
|
153
|
+
)
|
|
90
154
|
else
|
|
91
|
-
|
|
155
|
+
case config.backend
|
|
156
|
+
when :vips
|
|
92
157
|
Processor.new(max_pixels: max_pixels).probe(path)
|
|
93
|
-
|
|
158
|
+
when :imagemagick
|
|
94
159
|
info = ImageMagickBackend.probe(path, max_pixels: max_pixels)
|
|
95
160
|
Result.new(
|
|
96
161
|
input: File.expand_path(path),
|
|
@@ -144,24 +209,60 @@ module SafeImage
|
|
|
144
209
|
|
|
145
210
|
def orientation(path, max_pixels: nil)
|
|
146
211
|
maybe_sandbox(:orientation, args: [path], kwargs: { max_pixels: max_pixels }) do
|
|
147
|
-
|
|
212
|
+
case File.extname(PathSafety.local_path(path)).downcase
|
|
213
|
+
when ".svg", ".ico"
|
|
214
|
+
# No EXIF orientation in either format; upright by definition.
|
|
148
215
|
1
|
|
149
216
|
else
|
|
150
|
-
|
|
151
|
-
|
|
217
|
+
max_pixels = resolved_max_pixels(max_pixels)
|
|
218
|
+
case config.backend
|
|
219
|
+
when :vips
|
|
220
|
+
# Header-only native read.
|
|
221
|
+
VipsBackend.orientation(path, max_pixels: max_pixels)
|
|
222
|
+
when :imagemagick
|
|
223
|
+
# Probe first: rejects undecodable files and enforces the pixel cap.
|
|
224
|
+
ImageMagickBackend.probe(path, max_pixels: max_pixels)
|
|
225
|
+
ImageMagickBackend.orientation(path)
|
|
226
|
+
end
|
|
152
227
|
end
|
|
153
228
|
end
|
|
154
229
|
end
|
|
155
230
|
|
|
231
|
+
def dominant_color(path, max_pixels: nil)
|
|
232
|
+
maybe_sandbox(:dominant_color, args: [path], kwargs: { max_pixels: max_pixels }) do
|
|
233
|
+
max_pixels = resolved_max_pixels(max_pixels)
|
|
234
|
+
case config.backend
|
|
235
|
+
when :vips
|
|
236
|
+
if File.extname(PathSafety.local_path(path)).downcase == ".ico"
|
|
237
|
+
# Pure-Ruby ICO decode; vips only averages the decoded pixels.
|
|
238
|
+
Ico.dominant_color(path, max_pixels: max_pixels)
|
|
239
|
+
else
|
|
240
|
+
VipsBackend.dominant_color(path, max_pixels: max_pixels)
|
|
241
|
+
end
|
|
242
|
+
when :imagemagick
|
|
243
|
+
imagemagick_dominant_color(path, max_pixels: max_pixels)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def imagemagick_dominant_color(path, max_pixels:)
|
|
249
|
+
# Probe first: rejects undecodable files and enforces the pixel cap
|
|
250
|
+
# before ImageMagick fully decodes the image to average it.
|
|
251
|
+
probe(path, max_pixels: max_pixels)
|
|
252
|
+
ImageMagickBackend.dominant_color(path)
|
|
253
|
+
end
|
|
254
|
+
|
|
156
255
|
def fastimage_type(format)
|
|
157
256
|
format.to_s == "jpg" ? :jpeg : format.to_s.to_sym
|
|
158
257
|
end
|
|
159
258
|
|
|
160
259
|
def remote_info(url, **kwargs)
|
|
260
|
+
config
|
|
161
261
|
Remote.info(url, **kwargs)
|
|
162
262
|
end
|
|
163
263
|
|
|
164
264
|
def remote_size(url, **kwargs)
|
|
265
|
+
config
|
|
165
266
|
Remote.size(url, **kwargs)
|
|
166
267
|
end
|
|
167
268
|
|
|
@@ -170,18 +271,26 @@ module SafeImage
|
|
|
170
271
|
end
|
|
171
272
|
|
|
172
273
|
def remote_type(url, **kwargs)
|
|
274
|
+
config
|
|
173
275
|
Remote.type(url, **kwargs)
|
|
174
276
|
end
|
|
175
277
|
|
|
176
278
|
def remote_animated?(url, **kwargs)
|
|
279
|
+
config
|
|
177
280
|
Remote.animated?(url, **kwargs)
|
|
178
281
|
end
|
|
179
282
|
|
|
283
|
+
def remote_dominant_color(url, **kwargs)
|
|
284
|
+
config
|
|
285
|
+
Remote.dominant_color(url, **kwargs)
|
|
286
|
+
end
|
|
287
|
+
|
|
180
288
|
def fetch_remote(url, **kwargs, &block)
|
|
289
|
+
config
|
|
181
290
|
Remote.fetch(url, **kwargs, &block)
|
|
182
291
|
end
|
|
183
292
|
|
|
184
|
-
def thumbnail(input:, output:, width:, height:, format: nil, quality: 85, max_pixels: nil,
|
|
293
|
+
def thumbnail(input:, output:, width:, height:, format: nil, quality: 85, max_pixels: nil, optimize: false, optimize_mode: :lossless, chroma_subsampling: :auto)
|
|
185
294
|
maybe_sandbox(
|
|
186
295
|
:thumbnail,
|
|
187
296
|
kwargs: {
|
|
@@ -192,15 +301,12 @@ module SafeImage
|
|
|
192
301
|
format: format,
|
|
193
302
|
quality: quality,
|
|
194
303
|
max_pixels: max_pixels,
|
|
195
|
-
backend: backend,
|
|
196
304
|
optimize: optimize,
|
|
197
305
|
optimize_mode: optimize_mode,
|
|
198
|
-
execution: :inline,
|
|
199
|
-
encoder: encoder,
|
|
200
306
|
chroma_subsampling: chroma_subsampling
|
|
201
307
|
}
|
|
202
308
|
) do
|
|
203
|
-
Processor.new(max_pixels: max_pixels,
|
|
309
|
+
Processor.new(max_pixels: resolved_max_pixels(max_pixels), chroma_subsampling: chroma_subsampling).thumbnail(
|
|
204
310
|
input: input,
|
|
205
311
|
output: output,
|
|
206
312
|
width: width,
|
|
@@ -252,6 +358,7 @@ module SafeImage
|
|
|
252
358
|
end
|
|
253
359
|
|
|
254
360
|
def animated?(*args, **kwargs)
|
|
361
|
+
config
|
|
255
362
|
path = args.first
|
|
256
363
|
return false if path && File.extname(PathSafety.local_path(path)).downcase == ".svg"
|
|
257
364
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: safe_image
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sam Saffron
|
|
@@ -10,6 +10,20 @@ bindir: bin
|
|
|
10
10
|
cert_chain: []
|
|
11
11
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: fiddle
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.0'
|
|
13
27
|
- !ruby/object:Gem::Dependency
|
|
14
28
|
name: rexml
|
|
15
29
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -53,51 +67,52 @@ dependencies:
|
|
|
53
67
|
- !ruby/object:Gem::Version
|
|
54
68
|
version: '13.0'
|
|
55
69
|
- !ruby/object:Gem::Dependency
|
|
56
|
-
name:
|
|
70
|
+
name: rubocop-discourse
|
|
57
71
|
requirement: !ruby/object:Gem::Requirement
|
|
58
72
|
requirements:
|
|
59
73
|
- - "~>"
|
|
60
74
|
- !ruby/object:Gem::Version
|
|
61
|
-
version: '
|
|
75
|
+
version: '3.18'
|
|
62
76
|
type: :development
|
|
63
77
|
prerelease: false
|
|
64
78
|
version_requirements: !ruby/object:Gem::Requirement
|
|
65
79
|
requirements:
|
|
66
80
|
- - "~>"
|
|
67
81
|
- !ruby/object:Gem::Version
|
|
68
|
-
version: '
|
|
82
|
+
version: '3.18'
|
|
69
83
|
- !ruby/object:Gem::Dependency
|
|
70
|
-
name:
|
|
84
|
+
name: landlock
|
|
71
85
|
requirement: !ruby/object:Gem::Requirement
|
|
72
86
|
requirements:
|
|
73
|
-
- - "
|
|
87
|
+
- - ">="
|
|
74
88
|
- !ruby/object:Gem::Version
|
|
75
|
-
version: '
|
|
89
|
+
version: '0'
|
|
76
90
|
type: :development
|
|
77
91
|
prerelease: false
|
|
78
92
|
version_requirements: !ruby/object:Gem::Requirement
|
|
79
93
|
requirements:
|
|
80
|
-
- - "
|
|
94
|
+
- - ">="
|
|
81
95
|
- !ruby/object:Gem::Version
|
|
82
|
-
version: '
|
|
96
|
+
version: '0'
|
|
83
97
|
description: 'Safe Image is a small Ruby image-processing boundary for untrusted uploads:
|
|
84
98
|
direct libvips thumbnails/probing, hardened ImageMagick compatibility operations,
|
|
85
99
|
optimisation, SVG sanitising, and optional atomic Landlock sandbox execution.'
|
|
86
100
|
email:
|
|
87
101
|
- sam@discourse.org
|
|
88
102
|
executables: []
|
|
89
|
-
extensions:
|
|
90
|
-
- ext/safe_image_native/extconf.rb
|
|
103
|
+
extensions: []
|
|
91
104
|
extra_rdoc_files: []
|
|
92
105
|
files:
|
|
106
|
+
- CHANGELOG.md
|
|
93
107
|
- LICENSE
|
|
94
108
|
- README.md
|
|
95
109
|
- SECURITY.md
|
|
96
|
-
- ext/safe_image_native/extconf.rb
|
|
97
|
-
- ext/safe_image_native/safe_image_native.c
|
|
98
110
|
- lib/safe_image.rb
|
|
99
111
|
- lib/safe_image/RT_sRGB.icm
|
|
100
112
|
- lib/safe_image/discourse_compat.rb
|
|
113
|
+
- lib/safe_image/fonts/DEJAVU-LICENSE
|
|
114
|
+
- lib/safe_image/fonts/DejaVuSans.ttf
|
|
115
|
+
- lib/safe_image/ico.rb
|
|
101
116
|
- lib/safe_image/image_magick_backend.rb
|
|
102
117
|
- lib/safe_image/imagemagick_policy/policy.xml
|
|
103
118
|
- lib/safe_image/jpegli_backend.rb
|
|
@@ -113,6 +128,7 @@ files:
|
|
|
113
128
|
- lib/safe_image/svg_sanitizer.rb
|
|
114
129
|
- lib/safe_image/version.rb
|
|
115
130
|
- lib/safe_image/vips_backend.rb
|
|
131
|
+
- lib/safe_image/vips_glue.rb
|
|
116
132
|
homepage: https://github.com/sam-saffron-jarvis/safe-image
|
|
117
133
|
licenses:
|
|
118
134
|
- MIT
|
|
@@ -134,7 +150,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
134
150
|
- !ruby/object:Gem::Version
|
|
135
151
|
version: '0'
|
|
136
152
|
requirements: []
|
|
137
|
-
rubygems_version:
|
|
153
|
+
rubygems_version: 3.6.9
|
|
138
154
|
specification_version: 4
|
|
139
155
|
summary: Hardened image processing boundary for untrusted uploads
|
|
140
156
|
test_files: []
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "mkmf"
|
|
4
|
-
|
|
5
|
-
pkg_config("vips") or abort "libvips development files are required (pkg-config vips failed)"
|
|
6
|
-
have_header("vips/vips.h") or abort "missing vips/vips.h"
|
|
7
|
-
have_library("vips") or abort "missing libvips"
|
|
8
|
-
create_makefile("safe_image_native")
|