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/SECURITY.md ADDED
@@ -0,0 +1,48 @@
1
+ # Security Policy
2
+
3
+ Safe Image is a hardened image-processing boundary for untrusted uploads, not a proof that hostile image bytes are harmless.
4
+
5
+ ## Supported versions
6
+
7
+ Security fixes are expected to land on `main` until the gem has tagged releases. Once releases exist, report against the latest released version unless you can reproduce on `main` as well.
8
+
9
+ ## Threat model
10
+
11
+ Safe Image assumes image input may be attacker-controlled. The library is designed to reduce the number of places an application touches those bytes and to remove common image-processing foot-guns:
12
+
13
+ - shell-free external command execution using argv arrays
14
+ - allowlisted command environment
15
+ - bounded command output and process-group timeout cleanup
16
+ - explicit libvips loader selection for supported raster formats
17
+ - no silent fallback from libvips to generic ImageMagick decoding
18
+ - restrictive ImageMagick policy disabling delegates, filters, `@file`, remote URL coders, Ghostscript-backed formats, and dangerous pseudo-formats
19
+ - symlink rejection for untrusted local input/output paths
20
+ - remote fetch SSRF hardening: scheme/port restrictions, special-use IP blocking, DNS pinning, redirect limits, HTTPS-to-HTTP rejection, header allowlists, content-type/extension agreement, and probe-before-yield
21
+ - bounded SVG metadata parsing and conservative SVG sanitising without handing SVG to ImageMagick for probing
22
+ - optional Linux Landlock/seccomp subprocess sandboxing
23
+
24
+ ## Non-goals
25
+
26
+ Safe Image does not claim that parsing hostile images in-process is memory-safe. Raster decoders such as libjpeg, libpng, libwebp, libheif, libvips loaders, and ImageMagick coders still parse attacker-controlled bytes. A decoder memory-corruption bug or pathological resource-consumption bug is still possible.
27
+
28
+ The honest claim is defense-in-depth:
29
+
30
+ - without Landlock: centralized and hardened image processing with major delegate/protocol/policy foot-guns removed
31
+ - with Landlock: the same hardening plus a kernel containment boundary around subprocess-based public operations
32
+
33
+ If your deployment needs a hard isolation boundary, enable sandbox execution and run image processing away from your main web worker process.
34
+
35
+ ## Reporting vulnerabilities
36
+
37
+ Please report suspected security issues privately to `sam@discourse.org`.
38
+
39
+ Include:
40
+
41
+ - affected version or commit
42
+ - input file or minimized reproducer, if shareable
43
+ - operation/API called
44
+ - expected vs actual result
45
+ - whether Landlock sandboxing was enabled
46
+ - host OS, kernel, libvips, ImageMagick, and optimizer tool versions
47
+
48
+ Do not open a public issue for an exploitable crash, sandbox escape, SSRF bypass, arbitrary file read/write, command execution bug, or denial-of-service vector until there has been time to patch.
@@ -0,0 +1,8 @@
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")
@@ -0,0 +1,392 @@
1
+ #include <ruby.h>
2
+ #include <vips/vips.h>
3
+ #include <sys/stat.h>
4
+ #include <string.h>
5
+ #include <time.h>
6
+ #include <math.h>
7
+
8
+ static VALUE mSafeImage;
9
+ static VALUE mNative;
10
+ static VALUE eError;
11
+ static VALUE eUnsupported;
12
+ static VALUE eInvalid;
13
+ static VALUE eLimit;
14
+
15
+ /* Default decompression-bomb ceiling applied when the caller does not pass an
16
+ * explicit max_pixels. Mirrors SafeImage::DEFAULT_MAX_PIXELS and the 128MP area
17
+ * limit used on the ImageMagick path, so the libvips fast path is not unbounded
18
+ * by default. Callers that legitimately need larger images pass max_pixels. */
19
+ #define SAFE_IMAGE_DEFAULT_MAX_PIXELS (128LL * 1024 * 1024)
20
+
21
+ static double now_ms(void) {
22
+ struct timespec ts;
23
+ clock_gettime(CLOCK_MONOTONIC, &ts);
24
+ return (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1000000.0;
25
+ }
26
+
27
+ static const char *extname(const char *path) {
28
+ const char *dot = strrchr(path, '.');
29
+ return dot ? dot + 1 : "";
30
+ }
31
+
32
+ static int streq_ci(const char *a, const char *b) {
33
+ #ifdef _WIN32
34
+ return _stricmp(a, b) == 0;
35
+ #else
36
+ return strcasecmp(a, b) == 0;
37
+ #endif
38
+ }
39
+
40
+ static const char *normalized_format(const char *fmt) {
41
+ if (streq_ci(fmt, "jpg") || streq_ci(fmt, "jpeg")) return "jpg";
42
+ if (streq_ci(fmt, "png")) return "png";
43
+ if (streq_ci(fmt, "webp")) return "webp";
44
+ if (streq_ci(fmt, "heic") || streq_ci(fmt, "heif")) return "heic";
45
+ if (streq_ci(fmt, "avif")) return "avif";
46
+ return NULL;
47
+ }
48
+
49
+ static void raise_vips(void) {
50
+ const char *msg = vips_error_buffer();
51
+ VALUE rb_msg = rb_str_new_cstr(msg && *msg ? msg : "libvips error");
52
+ vips_error_clear();
53
+ rb_exc_raise(rb_exc_new3(eInvalid, rb_msg));
54
+ }
55
+
56
+ static void init_vips_once(void) {
57
+ static int initialized = 0;
58
+ if (initialized) return;
59
+ if (VIPS_INIT("safe_image") != 0) raise_vips();
60
+
61
+ /* Avoid libvips operations that are explicitly tagged as unsafe for
62
+ * untrusted input. Also block ImageMagick-backed loaders by class name;
63
+ * this gem uses explicit native libvips loaders and should never fall back
64
+ * to ImageMagick delegates. */
65
+ vips_block_untrusted_set(TRUE);
66
+ vips_operation_block_set("VipsForeignLoadMagick", TRUE);
67
+ vips_operation_block_set("VipsForeignLoadMagick6", TRUE);
68
+ vips_operation_block_set("VipsForeignLoadMagick7", TRUE);
69
+
70
+ /* Keep the embedded path predictable and bounded. Callers that want harder
71
+ * isolation should run this gem inside a sandboxed worker process. */
72
+ vips_concurrency_set(1);
73
+ vips_cache_set_max(0);
74
+ vips_cache_set_max_mem(0);
75
+ vips_cache_set_max_files(0);
76
+ initialized = 1;
77
+ }
78
+
79
+ static VipsImage *load_explicit(const char *path, const char **fmt_out) {
80
+ init_vips_once();
81
+ const char *fmt = normalized_format(extname(path));
82
+ if (!fmt) rb_raise(eUnsupported, "unsupported input format");
83
+
84
+ VipsImage *image = NULL;
85
+ int rc = -1;
86
+ if (strcmp(fmt, "jpg") == 0) {
87
+ rc = vips_jpegload(path, &image,
88
+ "access", VIPS_ACCESS_SEQUENTIAL,
89
+ "fail_on", VIPS_FAIL_ON_ERROR,
90
+ NULL);
91
+ } else if (strcmp(fmt, "png") == 0) {
92
+ rc = vips_pngload(path, &image,
93
+ "access", VIPS_ACCESS_SEQUENTIAL,
94
+ "fail_on", VIPS_FAIL_ON_ERROR,
95
+ NULL);
96
+ } else if (strcmp(fmt, "webp") == 0) {
97
+ rc = vips_webpload(path, &image,
98
+ "access", VIPS_ACCESS_SEQUENTIAL,
99
+ "fail_on", VIPS_FAIL_ON_ERROR,
100
+ NULL);
101
+ } else if (strcmp(fmt, "heic") == 0 || strcmp(fmt, "avif") == 0) {
102
+ rc = vips_heifload(path, &image,
103
+ "access", VIPS_ACCESS_SEQUENTIAL,
104
+ "fail_on", VIPS_FAIL_ON_ERROR,
105
+ NULL);
106
+ }
107
+
108
+ if (rc != 0 || image == NULL) raise_vips();
109
+ *fmt_out = fmt;
110
+ return image;
111
+ }
112
+
113
+ static void validate_quality_or_raise(int quality) {
114
+ if (quality < 1 || quality > 100) rb_raise(rb_eArgError, "quality must be 1..100");
115
+ }
116
+
117
+ static void validate_dimensions_or_raise(int width, int height) {
118
+ if (width <= 0 || height <= 0) rb_raise(rb_eArgError, "width and height must be positive");
119
+ }
120
+
121
+ static void validate_scale_or_raise(double scale) {
122
+ if (!isfinite(scale) || scale <= 0.0 || scale > 100.0) rb_raise(rb_eArgError, "scale must be finite and in 0..100");
123
+ }
124
+
125
+ static int pixels_exceed_limit(VipsImage *image, VALUE max_pixels_val, long long *pixels_out, long long *max_out) {
126
+ long long max_pixels;
127
+ if (NIL_P(max_pixels_val)) {
128
+ max_pixels = SAFE_IMAGE_DEFAULT_MAX_PIXELS;
129
+ } else {
130
+ max_pixels = NUM2LL(max_pixels_val);
131
+ if (max_pixels <= 0) rb_raise(rb_eArgError, "max_pixels must be positive");
132
+ }
133
+ if (image->Xsize <= 0 || image->Ysize <= 0) rb_raise(eInvalid, "image dimensions are invalid");
134
+ long long pixels = (long long)image->Xsize * (long long)image->Ysize;
135
+ if (pixels_out) *pixels_out = pixels;
136
+ if (max_out) *max_out = max_pixels;
137
+ return pixels > max_pixels;
138
+ }
139
+
140
+ static void raise_pixels_limit(long long pixels, long long max_pixels) {
141
+ rb_raise(eLimit, "image has %lld pixels, exceeds %lld", pixels, max_pixels);
142
+ }
143
+
144
+ static int save_explicit(VipsImage *image, const char *path, const char *fmt, int quality) {
145
+ if (strcmp(fmt, "jpg") == 0) {
146
+ return vips_jpegsave(image, path,
147
+ "Q", quality,
148
+ "interlace", FALSE,
149
+ "keep", VIPS_FOREIGN_KEEP_NONE,
150
+ NULL);
151
+ } else if (strcmp(fmt, "png") == 0) {
152
+ return vips_pngsave(image, path,
153
+ "compression", 6,
154
+ "keep", VIPS_FOREIGN_KEEP_NONE,
155
+ NULL);
156
+ } else if (strcmp(fmt, "webp") == 0) {
157
+ return vips_webpsave(image, path,
158
+ "Q", quality,
159
+ "keep", VIPS_FOREIGN_KEEP_NONE,
160
+ NULL);
161
+ } else if (strcmp(fmt, "avif") == 0) {
162
+ return vips_heifsave(image, path,
163
+ "Q", quality,
164
+ "compression", VIPS_FOREIGN_HEIF_COMPRESSION_AV1,
165
+ "keep", VIPS_FOREIGN_KEEP_NONE,
166
+ NULL);
167
+ }
168
+ rb_raise(eUnsupported, "unsupported output format");
169
+ }
170
+
171
+ static VALUE rb_probe(VALUE self, VALUE path_val) {
172
+ Check_Type(path_val, T_STRING);
173
+ double start = now_ms();
174
+ const char *fmt = NULL;
175
+ VipsImage *image = load_explicit(StringValueCStr(path_val), &fmt);
176
+ int width = image->Xsize;
177
+ int height = image->Ysize;
178
+ double duration_ms = now_ms() - start;
179
+ g_object_unref(image);
180
+
181
+ VALUE hash = rb_hash_new();
182
+ rb_hash_aset(hash, ID2SYM(rb_intern("format")), rb_str_new_cstr(fmt));
183
+ rb_hash_aset(hash, ID2SYM(rb_intern("width")), INT2NUM(width));
184
+ rb_hash_aset(hash, ID2SYM(rb_intern("height")), INT2NUM(height));
185
+ rb_hash_aset(hash, ID2SYM(rb_intern("duration_ms")), DBL2NUM(duration_ms));
186
+ return hash;
187
+ }
188
+
189
+ static VALUE rb_thumbnail(VALUE self, VALUE input_val, VALUE output_val, VALUE width_val, VALUE height_val, VALUE format_val, VALUE quality_val, VALUE max_pixels_val) {
190
+ Check_Type(input_val, T_STRING);
191
+ Check_Type(output_val, T_STRING);
192
+ Check_Type(format_val, T_STRING);
193
+ int width = NUM2INT(width_val);
194
+ int height = NUM2INT(height_val);
195
+ int quality = NUM2INT(quality_val);
196
+ validate_dimensions_or_raise(width, height);
197
+ validate_quality_or_raise(quality);
198
+ const char *out_fmt = normalized_format(StringValueCStr(format_val));
199
+ if (!out_fmt || strcmp(out_fmt, "heic") == 0) rb_raise(eUnsupported, "unsupported output format");
200
+
201
+ const char *input_path = StringValueCStr(input_val);
202
+ double start = now_ms();
203
+
204
+ /* Read the header through an explicit allowlisted loader. This validates the
205
+ * input format (the loader fails on mismatched bytes) and lets us enforce the
206
+ * pixel-count limit before any full decode happens. */
207
+ const char *input_fmt = NULL;
208
+ VipsImage *header = load_explicit(input_path, &input_fmt);
209
+ long long pixels = 0, max_pixels = 0;
210
+ if (pixels_exceed_limit(header, max_pixels_val, &pixels, &max_pixels)) {
211
+ g_object_unref(header);
212
+ raise_pixels_limit(pixels, max_pixels);
213
+ }
214
+ g_object_unref(header);
215
+
216
+ /* Thumbnail straight from the file so libvips can shrink on load (e.g.
217
+ * libjpeg DCT downscaling) instead of decoding the source at full
218
+ * resolution. vips_thumbnail auto-rotates from the orientation tag by
219
+ * default. ImageMagick loader classes are blocked globally in
220
+ * init_vips_once, so this still cannot reach an ImageMagick delegate. */
221
+ VipsImage *thumb = NULL;
222
+ if (vips_thumbnail(input_path, &thumb, width,
223
+ "height", height,
224
+ "size", VIPS_SIZE_BOTH,
225
+ "crop", VIPS_INTERESTING_CENTRE,
226
+ "fail_on", VIPS_FAIL_ON_ERROR,
227
+ NULL) != 0) {
228
+ raise_vips();
229
+ }
230
+
231
+ if (save_explicit(thumb, StringValueCStr(output_val), out_fmt, quality) != 0) {
232
+ g_object_unref(thumb);
233
+ raise_vips();
234
+ }
235
+
236
+ int out_width = thumb->Xsize;
237
+ int out_height = thumb->Ysize;
238
+ double duration_ms = now_ms() - start;
239
+ g_object_unref(thumb);
240
+
241
+ VALUE hash = rb_hash_new();
242
+ rb_hash_aset(hash, ID2SYM(rb_intern("input_format")), rb_str_new_cstr(input_fmt));
243
+ rb_hash_aset(hash, ID2SYM(rb_intern("output_format")), rb_str_new_cstr(out_fmt));
244
+ rb_hash_aset(hash, ID2SYM(rb_intern("width")), INT2NUM(out_width));
245
+ rb_hash_aset(hash, ID2SYM(rb_intern("height")), INT2NUM(out_height));
246
+ rb_hash_aset(hash, ID2SYM(rb_intern("duration_ms")), DBL2NUM(duration_ms));
247
+ return hash;
248
+ }
249
+
250
+ static VALUE rb_resize(VALUE self, VALUE input_val, VALUE output_val, VALUE scale_val, VALUE format_val, VALUE quality_val, VALUE max_pixels_val) {
251
+ Check_Type(input_val, T_STRING);
252
+ Check_Type(output_val, T_STRING);
253
+ Check_Type(format_val, T_STRING);
254
+ double scale = NUM2DBL(scale_val);
255
+ int quality = NUM2INT(quality_val);
256
+ validate_scale_or_raise(scale);
257
+ validate_quality_or_raise(quality);
258
+ const char *out_fmt = normalized_format(StringValueCStr(format_val));
259
+ if (!out_fmt || strcmp(out_fmt, "heic") == 0) rb_raise(eUnsupported, "unsupported output format");
260
+
261
+ double start = now_ms();
262
+ const char *input_fmt = NULL;
263
+ VipsImage *in = load_explicit(StringValueCStr(input_val), &input_fmt);
264
+ long long pixels = 0, max_pixels = 0;
265
+ if (pixels_exceed_limit(in, max_pixels_val, &pixels, &max_pixels)) {
266
+ g_object_unref(in);
267
+ raise_pixels_limit(pixels, max_pixels);
268
+ }
269
+
270
+ VipsImage *rot = NULL;
271
+ if (vips_autorot(in, &rot, NULL) != 0) {
272
+ g_object_unref(in);
273
+ raise_vips();
274
+ }
275
+
276
+ VipsImage *out = NULL;
277
+ if (vips_resize(rot, &out, scale, NULL) != 0) {
278
+ g_object_unref(rot);
279
+ g_object_unref(in);
280
+ raise_vips();
281
+ }
282
+
283
+ if (save_explicit(out, StringValueCStr(output_val), out_fmt, quality) != 0) {
284
+ g_object_unref(out);
285
+ g_object_unref(rot);
286
+ g_object_unref(in);
287
+ raise_vips();
288
+ }
289
+
290
+ int out_width = out->Xsize;
291
+ int out_height = out->Ysize;
292
+ double duration_ms = now_ms() - start;
293
+ g_object_unref(out);
294
+ g_object_unref(rot);
295
+ g_object_unref(in);
296
+
297
+ VALUE hash = rb_hash_new();
298
+ rb_hash_aset(hash, ID2SYM(rb_intern("input_format")), rb_str_new_cstr(input_fmt));
299
+ rb_hash_aset(hash, ID2SYM(rb_intern("output_format")), rb_str_new_cstr(out_fmt));
300
+ rb_hash_aset(hash, ID2SYM(rb_intern("width")), INT2NUM(out_width));
301
+ rb_hash_aset(hash, ID2SYM(rb_intern("height")), INT2NUM(out_height));
302
+ rb_hash_aset(hash, ID2SYM(rb_intern("duration_ms")), DBL2NUM(duration_ms));
303
+ return hash;
304
+ }
305
+
306
+ static VALUE rb_crop_north(VALUE self, VALUE input_val, VALUE output_val, VALUE width_val, VALUE height_val, VALUE format_val, VALUE quality_val, VALUE max_pixels_val) {
307
+ Check_Type(input_val, T_STRING);
308
+ Check_Type(output_val, T_STRING);
309
+ Check_Type(format_val, T_STRING);
310
+ int width = NUM2INT(width_val);
311
+ int height = NUM2INT(height_val);
312
+ int quality = NUM2INT(quality_val);
313
+ validate_dimensions_or_raise(width, height);
314
+ validate_quality_or_raise(quality);
315
+ const char *out_fmt = normalized_format(StringValueCStr(format_val));
316
+ if (!out_fmt || strcmp(out_fmt, "heic") == 0) rb_raise(eUnsupported, "unsupported output format");
317
+
318
+ double start = now_ms();
319
+ const char *input_fmt = NULL;
320
+ VipsImage *in = load_explicit(StringValueCStr(input_val), &input_fmt);
321
+ long long pixels = 0, max_pixels = 0;
322
+ if (pixels_exceed_limit(in, max_pixels_val, &pixels, &max_pixels)) {
323
+ g_object_unref(in);
324
+ raise_pixels_limit(pixels, max_pixels);
325
+ }
326
+
327
+ VipsImage *rot = NULL;
328
+ if (vips_autorot(in, &rot, NULL) != 0) {
329
+ g_object_unref(in);
330
+ raise_vips();
331
+ }
332
+
333
+ double sx = (double)width / (double)rot->Xsize;
334
+ double sy = (double)height / (double)rot->Ysize;
335
+ double scale = sx > sy ? sx : sy;
336
+ scale *= 1.0000001;
337
+
338
+ VipsImage *resized = NULL;
339
+ if (vips_resize(rot, &resized, scale, NULL) != 0) {
340
+ g_object_unref(rot);
341
+ g_object_unref(in);
342
+ raise_vips();
343
+ }
344
+
345
+ int left = (resized->Xsize - width) / 2;
346
+ if (left < 0) left = 0;
347
+
348
+ VipsImage *crop = NULL;
349
+ if (vips_extract_area(resized, &crop, left, 0, width, height, NULL) != 0) {
350
+ g_object_unref(resized);
351
+ g_object_unref(rot);
352
+ g_object_unref(in);
353
+ raise_vips();
354
+ }
355
+
356
+ if (save_explicit(crop, StringValueCStr(output_val), out_fmt, quality) != 0) {
357
+ g_object_unref(crop);
358
+ g_object_unref(resized);
359
+ g_object_unref(rot);
360
+ g_object_unref(in);
361
+ raise_vips();
362
+ }
363
+
364
+ int out_width = crop->Xsize;
365
+ int out_height = crop->Ysize;
366
+ double duration_ms = now_ms() - start;
367
+ g_object_unref(crop);
368
+ g_object_unref(resized);
369
+ g_object_unref(rot);
370
+ g_object_unref(in);
371
+
372
+ VALUE hash = rb_hash_new();
373
+ rb_hash_aset(hash, ID2SYM(rb_intern("input_format")), rb_str_new_cstr(input_fmt));
374
+ rb_hash_aset(hash, ID2SYM(rb_intern("output_format")), rb_str_new_cstr(out_fmt));
375
+ rb_hash_aset(hash, ID2SYM(rb_intern("width")), INT2NUM(out_width));
376
+ rb_hash_aset(hash, ID2SYM(rb_intern("height")), INT2NUM(out_height));
377
+ rb_hash_aset(hash, ID2SYM(rb_intern("duration_ms")), DBL2NUM(duration_ms));
378
+ return hash;
379
+ }
380
+
381
+ void Init_safe_image_native(void) {
382
+ mSafeImage = rb_define_module("SafeImage");
383
+ eError = rb_const_get(mSafeImage, rb_intern("Error"));
384
+ eUnsupported = rb_const_get(mSafeImage, rb_intern("UnsupportedFormatError"));
385
+ eInvalid = rb_const_get(mSafeImage, rb_intern("InvalidImageError"));
386
+ eLimit = rb_const_get(mSafeImage, rb_intern("LimitError"));
387
+ mNative = rb_define_module_under(mSafeImage, "Native");
388
+ rb_define_singleton_method(mNative, "probe", rb_probe, 1);
389
+ rb_define_singleton_method(mNative, "thumbnail", rb_thumbnail, 7);
390
+ rb_define_singleton_method(mNative, "resize", rb_resize, 6);
391
+ rb_define_singleton_method(mNative, "crop_north", rb_crop_north, 7);
392
+ }
Binary file