Files
opencloud/services/thumbnails/pkg/preprocessor/rawtiff.go
T
Dominik Schmidt 61ce147205 fix(thumbnails): harden the raw tiff walker against crafted files
Cap the IFD queue at maxIFDs so a file packed with huge SubIFD counts can no
longer grow it without bound, and walk the embedded JPEG by a bounded number
of header segments so a preview whose SOF sits past 64KB of leading metadata
is still found instead of being scanned only within a fixed byte window.
2026-08-18 16:48:32 +02:00

239 lines
6.9 KiB
Go

package preprocessor
import (
"bytes"
"encoding/binary"
"io"
thumbnailerErrors "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/errors"
)
// RawTiffDecoder serves the embedded JPEG preview of TIFF-based camera raw
// files (NEF, CR2, PEF, ARW, SR2, DNG) instead of decoding raw sensor data.
type RawTiffDecoder struct{}
// Convert extracts the largest renderable embedded JPEG preview and decodes it
func (RawTiffDecoder) Convert(r io.Reader) (any, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
preview, orientation, err := extractEmbeddedJPEG(data)
if err != nil {
return nil, err
}
// the rotation lives in the container, the preview carries no EXIF
if orientation > 1 {
preview = spliceJPEGOrientation(preview, orientation)
}
converter := ForType("image/jpeg", nil)
if converter == nil {
return nil, thumbnailerErrors.ErrNoImageFromRawFile
}
return converter.Convert(bytes.NewReader(preview))
}
const (
tiffTagStripOffsets = 0x0111
tiffTagOrientation = 0x0112
tiffTagStripByteCounts = 0x0117
tiffTagSubIFDs = 0x014a
tiffTagJPEGOffset = 0x0201 // JPEGInterchangeFormat
tiffTagJPEGLength = 0x0202 // JPEGInterchangeFormatLength
tiffTypeShort = 3
tiffTypeLong = 4
// bound work on untrusted input: cap processed IFDs (and the queue) and
// the JPEG header segments walked before the SOF marker
maxIFDs = 64
maxJPEGSegments = 32
)
// extractEmbeddedJPEG walks the IFD chain incl. SubIFDs and returns the
// largest renderable embedded JPEG plus the container orientation.
func extractEmbeddedJPEG(data []byte) ([]byte, uint16, error) {
if len(data) < 8 {
return nil, 0, thumbnailerErrors.ErrNoImageFromRawFile
}
var order binary.ByteOrder
switch {
case data[0] == 'I' && data[1] == 'I':
order = binary.LittleEndian
case data[0] == 'M' && data[1] == 'M':
order = binary.BigEndian
default:
return nil, 0, thumbnailerErrors.ErrNoImageFromRawFile
}
if order.Uint16(data[2:4]) != 42 {
return nil, 0, thumbnailerErrors.ErrNoImageFromRawFile
}
type candidate struct{ offset, length uint32 }
var candidates []candidate
var orientation uint16
queue := []uint32{order.Uint32(data[4:8])}
seen := map[uint32]struct{}{}
// push queues an IFD offset unless the queue is already full; a crafted
// file with millions of SubIFD pointers must not grow it without bound
push := func(offset uint32) {
if len(queue) < maxIFDs {
queue = append(queue, offset)
}
}
first := true
for len(queue) > 0 && len(seen) < maxIFDs {
ifdOffset := queue[0]
queue = queue[1:]
if _, ok := seen[ifdOffset]; ok {
continue
}
seen[ifdOffset] = struct{}{}
if ifdOffset == 0 || int(ifdOffset)+2 > len(data) {
continue
}
entryCount := int(order.Uint16(data[ifdOffset : ifdOffset+2]))
entriesEnd := int(ifdOffset) + 2 + entryCount*12
if entriesEnd+4 > len(data) {
continue
}
var jpegOffset, jpegLength, stripOffset, stripLength uint32
for i := 0; i < entryCount; i++ {
entry := data[int(ifdOffset)+2+i*12:]
tag := order.Uint16(entry[0:2])
typ := order.Uint16(entry[2:4])
count := order.Uint32(entry[4:8])
switch tag {
case tiffTagOrientation:
// only IFD0's orientation applies
if first && typ == tiffTypeShort && count == 1 {
orientation = order.Uint16(entry[8:10])
}
case tiffTagJPEGOffset:
if typ == tiffTypeLong && count == 1 {
jpegOffset = order.Uint32(entry[8:12])
}
case tiffTagJPEGLength:
if typ == tiffTypeLong && count == 1 {
jpegLength = order.Uint32(entry[8:12])
}
case tiffTagStripOffsets:
// only single-strip images can be a contiguous JPEG stream
if typ == tiffTypeLong && count == 1 {
stripOffset = order.Uint32(entry[8:12])
}
case tiffTagStripByteCounts:
if typ == tiffTypeLong && count == 1 {
stripLength = order.Uint32(entry[8:12])
}
case tiffTagSubIFDs:
if typ != tiffTypeLong {
continue
}
if count == 1 {
push(order.Uint32(entry[8:12]))
continue
}
arrayOffset := order.Uint32(entry[8:12])
for j := uint32(0); j < count && j < maxIFDs; j++ {
pos := int(arrayOffset) + int(j)*4
if pos+4 > len(data) || len(queue) >= maxIFDs {
break
}
push(order.Uint32(data[pos : pos+4]))
}
}
}
if jpegOffset > 0 && jpegLength > 0 {
candidates = append(candidates, candidate{jpegOffset, jpegLength})
}
if stripOffset > 0 && stripLength > 0 {
candidates = append(candidates, candidate{stripOffset, stripLength})
}
push(order.Uint32(data[entriesEnd : entriesEnd+4]))
first = false
}
var best []byte
for _, c := range candidates {
end := int64(c.offset) + int64(c.length)
if end > int64(len(data)) || int(c.length) <= len(best) {
continue
}
jpg := data[c.offset:end]
if !isRenderableJPEG(jpg) {
continue
}
best = jpg
}
if best == nil {
return nil, 0, thumbnailerErrors.ErrNoImageFromRawFile
}
return best, orientation, nil
}
// isRenderableJPEG accepts only DCT processes common decoders render; DNG
// raw payloads are lossless JPEG (SOF3) behind a regular SOI marker.
func isRenderableJPEG(buf []byte) bool {
if len(buf) < 4 || buf[0] != 0xff || buf[1] != 0xd8 {
return false
}
i := 2
for segments := 0; i+4 <= len(buf) && segments < maxJPEGSegments; segments++ {
if buf[i] != 0xff {
return false
}
marker := buf[i+1]
switch {
case marker == 0xff: // fill byte
i++
continue
case marker >= 0xd0 && marker <= 0xd7: // RST, no length field
i += 2
continue
}
switch marker {
case 0xc0, 0xc1, 0xc2: // baseline, extended sequential, progressive
return true
case 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf:
// lossless, differential and arithmetic processes
return false
case 0xd9, 0xda: // EOI or scan start without a SOF
return false
}
segLen := int(buf[i+2])<<8 | int(buf[i+3])
if segLen < 2 {
return false
}
i += 2 + segLen
}
return false
}
// spliceJPEGOrientation inserts a minimal EXIF APP1 segment carrying only the
// orientation tag right after the SOI marker
func spliceJPEGOrientation(jpg []byte, orientation uint16) []byte {
if len(jpg) < 2 || jpg[0] != 0xff || jpg[1] != 0xd8 {
return jpg
}
tiff := make([]byte, 0, 26)
le := binary.LittleEndian
tiff = append(tiff, 'I', 'I')
tiff = le.AppendUint16(tiff, 42)
tiff = le.AppendUint32(tiff, 8) // IFD0 offset
tiff = le.AppendUint16(tiff, 1) // one entry
tiff = le.AppendUint16(tiff, tiffTagOrientation)
tiff = le.AppendUint16(tiff, tiffTypeShort)
tiff = le.AppendUint32(tiff, 1)
tiff = le.AppendUint16(tiff, orientation)
tiff = le.AppendUint16(tiff, 0) // value padding
tiff = le.AppendUint32(tiff, 0) // no next IFD
payload := append([]byte("Exif\x00\x00"), tiff...)
out := make([]byte, 0, len(jpg)+4+len(payload))
out = append(out, jpg[:2]...)
out = append(out, 0xff, 0xe1, byte((len(payload)+2)>>8), byte(len(payload)+2))
out = append(out, payload...)
return append(out, jpg[2:]...)
}