imgprobe

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 11, 2026 License: MIT Imports: 11 Imported by: 0

README

imgprobe

Go Reference Go Report Card CI

Pure-Go (zero cgo) image preflight for uploads: sniff the real format from magic bytes, read dimensions from the header only, apply EXIF orientation, and reject decompression bombs before any pixels are allocated.

res, err := imgprobe.Config(uploadBytes)
switch {
case errors.Is(err, imgprobe.ErrTooLarge):    // → 413 Payload Too Large
    http.Error(w, "image too large", http.StatusRequestEntityTooLarge)
case errors.Is(err, imgprobe.ErrUnsupported): // → 415 Unsupported Media Type
    http.Error(w, "unsupported image", http.StatusUnsupportedMediaType)
case err != nil:
    http.Error(w, "bad upload", http.StatusBadRequest)
default:
    // res.Format == "jpeg", res.ContentType, res.Width, res.Height
}

The problem it solves

Reading an image's dimensions by fully decoding it is a denial-of-service vector: a 15000×15000 PNG is a tiny file on disk but decodes to ~900 MB of pixels. A handful of concurrent uploads like that will OOM a service.

imgprobe reads only the header (each format's DecodeConfig, never a full decode) and rejects the image when its declared dimensions exceed a total-area cap — so the bomb is refused before anything is allocated.

The load-bearing detail: a per-side cap alone is not enough — 15000×15000 passes any reasonable per-side check yet is a bomb. The total-pixel cap (WithMaxPixels, default 50 MP) is the real guard. Both ship, with safe defaults.

Why not just call image.DecodeConfig yourself?

You can, and you should keep doing so if that is all you need. imgprobe adds the parts that are easy to get wrong or tedious to repeat:

  • The total-area bomb cap as a first-class, tested guarantee.
  • Typed sentinels (ErrTooLarge → 413, ErrUnsupported → 415) so an HTTP handler maps failures to the right status without string-matching.
  • Magic-byte format detection that ignores the claimed Content-Type/extension.
  • A per-call format allowlist — accept exactly the formats you want.
  • EXIF-orientation-aware dimensions (you supply the orientation).

Formats

Enabled by default: JPEG, PNG, WebP. Opt-in via WithFormats: GIF, BMP, TIFF. Register your own with NewFormat. WithFormats sets the exact allowlist (replace semantics), so you can also restrict to a single format:

// defaults plus GIF:
imgprobe.Config(b, imgprobe.WithFormats(imgprobe.JPEG, imgprobe.PNG, imgprobe.WEBP, imgprobe.GIF))

// PNG only:
imgprobe.Config(b, imgprobe.WithFormats(imgprobe.PNG))

Security

imgprobe protects the "read dimensions" step and nothing more. It is not a substitute for bounding the raw upload size before you buffer the bytes, and any later full decode (e.g. resizing) must apply its own limits. See SECURITY.md and the package "Security" section on pkg.go.dev.

Install

go get github.com/gumeniukcom/imgprobe

Requires Go 1.25+. The only non-stdlib dependency is golang.org/x/image (for WebP/BMP/TIFF header decoding). No cgo.

License

MIT — see LICENSE.

Documentation

Overview

Package imgprobe inspects image bytes using only pure-Go decoders (no cgo): it sniffs the real format from magic bytes, reads dimensions cheaply (header only, never a full decode), applies EXIF orientation, and rejects decompression bombs via a total-pixel cap BEFORE any pixels are allocated.

Why

Reading an image's dimensions by fully decoding it is a denial-of-service vector: a 15000×15000 PNG is a tiny file on disk but decodes to ~900 MB of pixels. imgprobe reads only the header via each format's DecodeConfig and rejects the image when its DECLARED dimensions exceed a total-area cap, so the bomb is refused before anything is allocated.

Usage

res, err := imgprobe.Config(data)
switch {
case errors.Is(err, imgprobe.ErrTooLarge):   // → 413 Payload Too Large
case errors.Is(err, imgprobe.ErrUnsupported): // → 415 Unsupported Media Type
case err != nil:
default:
    // res.Format == "jpeg", res.Width, res.Height ...
}

The enabled format set is an allowlist (default {JPEG, PNG, WEBP}); pass WithFormats to change it, WithOrientation to supply the EXIF orientation, and WithMaxPixels/WithMaxSide to tune the caps.

Security

imgprobe protects the "read dimensions" step and nothing more:

  • It reads only headers (DecodeConfig), so a hostile image cannot force a full-frame allocation here.
  • The total-area cap (WithMaxPixels, default 50 MP) is the real bomb guard; a per-side cap alone is insufficient because a square image passes it.
  • Format detection is by magic bytes, ignoring the claimed Content-Type or file extension.

It is NOT a substitute for bounding the raw upload size before you buffer the bytes, and any later full decode (e.g. resizing) must apply its own limits.

Index

Examples

Constants

View Source
const (
	DefaultMaxPixels = 50_000_000 // 50 MP, matching real camera output
	DefaultMaxSide   = 15000
)

Default caps. MaxSide is a per-side sanity bound; MaxPixels is the total-area cap that actually guards against decompression bombs — a 15000x15000 image passes a per-side check but decodes to ~900 MB.

Variables

View Source
var (
	// ErrUnsupported means the bytes are not one of the enabled formats, or the
	// header could not be decoded. Maps naturally to 415 Unsupported Media Type.
	ErrUnsupported = errors.New("imgprobe: unsupported or invalid image")
	// ErrTooLarge means the declared dimensions exceed the configured caps. Maps
	// naturally to 413 Payload Too Large.
	ErrTooLarge = errors.New("imgprobe: image exceeds size limits")
)

Sentinels — HTTP handlers can map these to distinct status codes (415 vs 413). Both are wrapped in the returned error, so errors.Is works.

View Source
var (
	JPEG = Format{"jpeg", "image/jpeg", prefixMatcher([]byte{0xFF, 0xD8, 0xFF}), decodeVia(jpeg.DecodeConfig)}
	PNG  = Format{"png", "image/png", prefixMatcher([]byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}), decodeVia(png.DecodeConfig)}
	WEBP = Format{"webp", "image/webp", matchWebP, decodeVia(webp.DecodeConfig)}
	GIF  = Format{"gif", "image/gif", matchGIF, decodeVia(gif.DecodeConfig)}
	BMP  = Format{"bmp", "image/bmp", prefixMatcher([]byte("BM")), decodeVia(bmp.DecodeConfig)}
	TIFF = Format{"tiff", "image/tiff", matchTIFF, decodeVia(tiff.DecodeConfig)}
)

Built-in format descriptors. JPEG, PNG and WEBP are enabled by default; GIF, BMP and TIFF are opt-in via WithFormats.

Functions

func Sniff

func Sniff(head []byte, opts ...Option) (format, contentType string, err error)

Sniff determines the real format from leading magic bytes, ignoring any claimed Content-Type or file extension. It considers only the enabled set (default {JPEG, PNG, WEBP}) and returns ErrUnsupported otherwise.

Types

type DecodeConfigFunc

type DecodeConfigFunc func(data []byte) (width, height int, err error)

DecodeConfigFunc reads image dimensions from a header without a full decode.

type Format

type Format struct {
	// contains filtered or unexported fields
}

Format describes a recognized image format: its name, content type, magic-byte matcher, and pure-Go header decoder. Config dispatches on each enabled format's own decoder rather than Go's global image registry, so enabling is truly per-call: a disabled format is ErrUnsupported even though its decoder is linked into the binary.

func NewFormat

func NewFormat(name, contentType string, magic []byte, dc DecodeConfigFunc) Format

NewFormat registers a custom format. magic is matched against the leading bytes of the input; dc reads its dimensions.

func (Format) ContentType

func (f Format) ContentType() string

ContentType returns the canonical MIME type (e.g. "image/jpeg").

func (Format) Name

func (f Format) Name() string

Name returns the neutral format name (e.g. "jpeg").

type Option

type Option func(*config)

Option configures Sniff and Config. Safe defaults apply when omitted.

func WithFormats

func WithFormats(formats ...Format) Option

WithFormats sets the EXACT enabled set (an allowlist), replacing the default {JPEG, PNG, WEBP}. "Defaults plus GIF" is spelled explicitly: WithFormats(imgprobe.JPEG, imgprobe.PNG, imgprobe.WEBP, imgprobe.GIF). An empty argument list is ignored (the default set stays in effect).

Example

Accept an explicit allowlist that adds GIF to the defaults.

gif := mustGIF(16, 16)

res, err := imgprobe.Config(gif, imgprobe.WithFormats(
	imgprobe.JPEG, imgprobe.PNG, imgprobe.WEBP, imgprobe.GIF,
))
if err != nil {
	fmt.Println("rejected:", err)
	return
}
fmt.Printf("%s %dx%d\n", res.Format, res.Width, res.Height)
Output:
gif 16x16

func WithMaxPixels

func WithMaxPixels(n int64) Option

WithMaxPixels overrides the total-area cap (default DefaultMaxPixels). Non-positive values are ignored.

func WithMaxSide

func WithMaxSide(n int) Option

WithMaxSide overrides the per-side cap (default DefaultMaxSide). Non-positive values are ignored.

func WithOrientation

func WithOrientation(orientation int) Option

WithOrientation supplies the EXIF orientation (1..8); values 5..8 swap width and height. The default is 1 (no adjustment). imgprobe does not parse EXIF — the caller extracts orientation separately.

type Result

type Result struct {
	Format      string
	ContentType string
	Width       int // post-orientation
	Height      int // post-orientation
}

Result is what a probe reveals about the bytes. Format is a neutral name string ("jpeg", "png", "webp", "gif", "bmp", "tiff").

func Config

func Config(data []byte, opts ...Option) (Result, error)

Config reads format + dimensions cheaply (header only, never a full decode), applies the EXIF orientation, and enforces the caps. It returns ErrTooLarge for a cap violation and ErrUnsupported for a bad or disabled format.

Example

Probe an upload's header: format and post-orientation dimensions, without decoding the full image.

data := mustPNG(640, 480) // stand-in for the uploaded bytes

res, err := imgprobe.Config(data)
if err != nil {
	fmt.Println("rejected:", err)
	return
}
fmt.Printf("%s %dx%d\n", res.Format, res.Width, res.Height)
Output:
png 640x480
Example (DecompressionBomb)

Reject a decompression bomb before allocating any pixels: only the header is read, and the declared dimensions blow the total-area cap.

bomb := mustPNG(8000, 7000) // 56 MP > the 50 MP default cap

_, err := imgprobe.Config(bomb)
fmt.Println(errors.Is(err, imgprobe.ErrTooLarge))
Output:
true

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL