mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-09-13 06:09:21 -04:00
The imaging build decodes the full pixel buffer from the header-declared dimensions before the existing MaxInputWidth/MaxInputHeight guard runs, so a tiny crafted file whose header declares huge dimensions forces a multi-GB allocation and can OOM the worker. Read the header with DecodeConfig and reject oversized sources before the decode allocates, in both the imaging and vips builds, and thread the limit through the audio cover-art and geogebra decoders that decode a second attacker-controlled image.
415 lines
11 KiB
Go
415 lines
11 KiB
Go
package preprocessor
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"image"
|
|
"image/draw"
|
|
"image/gif"
|
|
"io"
|
|
"math"
|
|
"mime"
|
|
"strings"
|
|
|
|
"github.com/pkg/errors"
|
|
"golang.org/x/image/font"
|
|
"golang.org/x/image/font/opentype"
|
|
"golang.org/x/image/math/fixed"
|
|
|
|
"github.com/dhowden/tag"
|
|
|
|
thumbnailerErrors "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/errors"
|
|
)
|
|
|
|
// FileConverter is the interface for the file converter
|
|
type FileConverter interface {
|
|
Convert(r io.Reader) (any, error)
|
|
}
|
|
|
|
// GifDecoder is a converter for the gif file
|
|
type GifDecoder struct{ limit decodeLimit }
|
|
|
|
// Convert reads the gif file and returns the thumbnail image
|
|
func (i GifDecoder) Convert(r io.Reader) (any, error) {
|
|
r, err := i.limit.guardDimensions(r, gif.DecodeConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
img, err := gif.DecodeAll(r)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, `could not decode the image`)
|
|
}
|
|
return img, nil
|
|
}
|
|
|
|
// GgsDecoder is a converter for the geogebra slides file
|
|
type GgsDecoder struct {
|
|
thumbnailpath string
|
|
limit decodeLimit
|
|
}
|
|
|
|
// Convert reads the ggs file and returns the thumbnail image
|
|
func (g GgsDecoder) Convert(r io.Reader) (any, error) {
|
|
var buf bytes.Buffer
|
|
_, err := io.Copy(&buf, r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
zipReader, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, file := range zipReader.File {
|
|
if file.Name == g.thumbnailpath {
|
|
thumbnail, err := file.Open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
converter := ForType("image/png", g.limit.opts())
|
|
if converter == nil {
|
|
return nil, thumbnailerErrors.ErrNoConverterForExtractedImageFromGgsFile
|
|
}
|
|
img, err := converter.Convert(thumbnail)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, `could not decode the image`)
|
|
}
|
|
return img, nil
|
|
}
|
|
}
|
|
return nil, errors.Errorf("%s not found", g.thumbnailpath)
|
|
}
|
|
|
|
// AudioDecoder is a converter for the audio file
|
|
type AudioDecoder struct{ limit decodeLimit }
|
|
|
|
// Convert reads the audio file and extracts the thumbnail image from the id3 tag
|
|
func (i AudioDecoder) Convert(r io.Reader) (any, error) {
|
|
b, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m, err := tag.ReadFrom(bytes.NewReader(b))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
picture := m.Picture()
|
|
if picture == nil {
|
|
return nil, thumbnailerErrors.ErrNoImageFromAudioFile
|
|
}
|
|
|
|
converter := ForType(picture.MIMEType, i.limit.opts())
|
|
if converter == nil {
|
|
return nil, thumbnailerErrors.ErrNoConverterForExtractedImageFromAudioFile
|
|
}
|
|
|
|
return converter.Convert(bytes.NewReader(picture.Data))
|
|
}
|
|
|
|
// TxtToImageConverter is a converter for the text file
|
|
type TxtToImageConverter struct {
|
|
fontLoader *FontLoader
|
|
}
|
|
|
|
// Convert reads the text file and renders it into a thumbnail image
|
|
func (t TxtToImageConverter) Convert(r io.Reader) (any, error) {
|
|
img := image.NewRGBA(image.Rect(0, 0, 640, 480))
|
|
|
|
imgBounds := img.Bounds()
|
|
draw.Draw(img, imgBounds, image.White, image.Point{}, draw.Src)
|
|
|
|
fontSizeAsInt := int(math.Ceil(t.fontLoader.GetFaceOptSize()))
|
|
margin := 10
|
|
minX := fixed.I(imgBounds.Min.X + margin)
|
|
maxX := fixed.I(imgBounds.Max.X - margin)
|
|
maxY := fixed.I(imgBounds.Max.Y - margin)
|
|
initialPoint := fixed.P(imgBounds.Min.X+margin, imgBounds.Min.Y+margin+fontSizeAsInt)
|
|
canvas := &font.Drawer{
|
|
Dst: img,
|
|
Src: image.Black,
|
|
Dot: initialPoint,
|
|
}
|
|
|
|
scriptList := t.fontLoader.GetScriptList()
|
|
textAnalyzer := NewTextAnalyzer(scriptList)
|
|
taOpts := AnalysisOpts{
|
|
UseMergeMap: true,
|
|
MergeMap: DefaultMergeMap,
|
|
}
|
|
|
|
scanner := bufio.NewScanner(r)
|
|
Scan: // Label for the scanner loop, so we can break it easily
|
|
for scanner.Scan() {
|
|
txt := scanner.Text()
|
|
height := fixed.I(fontSizeAsInt) // reset to default height
|
|
|
|
textResult := textAnalyzer.AnalyzeString(txt, taOpts)
|
|
textResult.MergeCommon(DefaultMergeMap)
|
|
|
|
for _, sRange := range textResult.ScriptRanges {
|
|
targetFontFace, err := t.fontLoader.LoadFaceForScript(sRange.TargetScript)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// if the target script is "_unknown" it's expected that the loaded face
|
|
// uses the default font
|
|
faceHeight := targetFontFace.Face.Metrics().Height
|
|
if faceHeight > height {
|
|
height = faceHeight
|
|
}
|
|
|
|
canvas.Face = targetFontFace.Face
|
|
initialByte := sRange.Low
|
|
for _, sRangeSpace := range sRange.Spaces {
|
|
if canvas.Dot.Y > maxY {
|
|
break Scan
|
|
}
|
|
|
|
drawWord(canvas, textResult.Text[initialByte:sRangeSpace], minX, maxX, height, maxY)
|
|
initialByte = sRangeSpace
|
|
}
|
|
|
|
if initialByte <= sRange.High {
|
|
// some bytes left to be written
|
|
if canvas.Dot.Y > maxY {
|
|
break Scan
|
|
}
|
|
|
|
drawWord(canvas, textResult.Text[initialByte:sRange.High+1], minX, maxX, height, maxY)
|
|
}
|
|
}
|
|
|
|
canvas.Dot.X = minX
|
|
canvas.Dot.Y += height.Mul(fixed.Int26_6(1<<6 + 1<<5)) // height * 1.5
|
|
|
|
if canvas.Dot.Y > maxY {
|
|
break
|
|
}
|
|
}
|
|
return img, scanner.Err()
|
|
}
|
|
|
|
// GGPStruct is the layout of a ggp file (which is basically json)
|
|
type GGPStruct struct {
|
|
Sections []struct {
|
|
Cards []struct {
|
|
Element struct {
|
|
Image struct {
|
|
Base64Image string
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// GgpDecoder is a converter for the geogebra pinboard file
|
|
type GgpDecoder struct{ limit decodeLimit }
|
|
|
|
// Convert reads the ggp file and returns the first thumbnail image
|
|
func (j GgpDecoder) Convert(r io.Reader) (any, error) {
|
|
ggp := &GGPStruct{}
|
|
err := json.NewDecoder(r).Decode(ggp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
elem, err := extractBase64ImageFromGGP(ggp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
b, err := base64.StdEncoding.DecodeString(elem)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
r2, err := j.limit.guardDimensions(bytes.NewReader(b), func(rr io.Reader) (image.Config, error) {
|
|
cfg, _, err := image.DecodeConfig(rr)
|
|
return cfg, err
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
img, _, err := image.Decode(r2)
|
|
return img, err
|
|
}
|
|
|
|
func extractBase64ImageFromGGP(ggp *GGPStruct) (string, error) {
|
|
if len(ggp.Sections) < 1 || len(ggp.Sections[0].Cards) < 1 {
|
|
return "", errors.New("cant find thumbnail in ggp file")
|
|
}
|
|
|
|
raw := strings.Split(ggp.Sections[0].Cards[0].Element.Image.Base64Image, "base64,")
|
|
if len(raw) < 2 {
|
|
return "", errors.New("cant decode ggp thumbnail")
|
|
}
|
|
|
|
return raw[1], nil
|
|
}
|
|
|
|
// Draw the word in the canvas. The mixX and maxX defines the drawable range
|
|
// (X axis) where the word can be drawn (in case the word is too big and doesn't
|
|
// fit in the canvas), and the incY defines the increment in the Y axis if we
|
|
// need to draw the word in a new line
|
|
//
|
|
// Note that the word will likely start with a white space char
|
|
func drawWord(canvas *font.Drawer, word string, minX, maxX, incY, maxY fixed.Int26_6) {
|
|
// calculate the actual measurement of the string at a given X position
|
|
measure := func(s string, dotX fixed.Int26_6) (min, max fixed.Int26_6) {
|
|
bbox, _ := canvas.BoundString(s)
|
|
return dotX + bbox.Min.X, dotX + bbox.Max.X
|
|
}
|
|
|
|
// first try to draw the whole word
|
|
absMin, absMax := measure(word, canvas.Dot.X)
|
|
if absMin >= minX && absMax <= maxX {
|
|
canvas.DrawString(word)
|
|
return
|
|
}
|
|
|
|
// try to draw the trimmed word in a new line
|
|
trimmed := strings.TrimSpace(word)
|
|
oldDot := canvas.Dot
|
|
canvas.Dot.X = minX
|
|
canvas.Dot.Y += incY
|
|
|
|
if canvas.Dot.Y <= maxY {
|
|
tMin, tMax := measure(trimmed, canvas.Dot.X)
|
|
if tMin >= minX && tMax <= maxX {
|
|
canvas.DrawString(trimmed)
|
|
return
|
|
}
|
|
}
|
|
|
|
// if the trimmed word is still too big, draw it char by char
|
|
canvas.Dot = oldDot
|
|
for _, char := range trimmed {
|
|
s := string(char)
|
|
_, cMax := measure(s, canvas.Dot.X)
|
|
|
|
if cMax > maxX {
|
|
canvas.Dot.X = minX
|
|
canvas.Dot.Y += incY
|
|
}
|
|
|
|
// stop drawing if we exceed maxY
|
|
if canvas.Dot.Y > maxY {
|
|
return
|
|
}
|
|
|
|
// ensure that we don't start drawing before minX
|
|
cMin, _ := measure(s, canvas.Dot.X)
|
|
if cMin < minX {
|
|
canvas.Dot.X += minX - cMin
|
|
}
|
|
|
|
canvas.DrawString(s)
|
|
}
|
|
}
|
|
|
|
// decodeLimit bounds the source image dimensions a decoder accepts. A zero
|
|
// value on an axis disables the limit for that axis.
|
|
type decodeLimit struct {
|
|
maxWidth int
|
|
maxHeight int
|
|
}
|
|
|
|
func decodeLimitFromOpts(opts map[string]any) decodeLimit {
|
|
l := decodeLimit{}
|
|
if v, ok := opts["maxInputWidth"].(int); ok {
|
|
l.maxWidth = v
|
|
}
|
|
if v, ok := opts["maxInputHeight"].(int); ok {
|
|
l.maxHeight = v
|
|
}
|
|
return l
|
|
}
|
|
|
|
func (l decodeLimit) exceeded(width, height int) bool {
|
|
return (l.maxWidth > 0 && width > l.maxWidth) || (l.maxHeight > 0 && height > l.maxHeight)
|
|
}
|
|
|
|
// opts renders the limit back into an options map so decoders that recurse
|
|
// into ForType can forward it to the nested image decoder.
|
|
func (l decodeLimit) opts() map[string]any {
|
|
return map[string]any{"maxInputWidth": l.maxWidth, "maxInputHeight": l.maxHeight}
|
|
}
|
|
|
|
// guardDimensions reads only the image header (no pixel allocation) and
|
|
// rejects a source whose declared dimensions exceed the limit, before the
|
|
// full bitmap is decoded. It returns a reader that replays the consumed
|
|
// header so the caller can still decode from the start. A header that cannot
|
|
// be parsed is passed through unchecked, letting the real decoder report it.
|
|
func (l decodeLimit) guardDimensions(r io.Reader, config func(io.Reader) (image.Config, error)) (io.Reader, error) {
|
|
if l.maxWidth <= 0 && l.maxHeight <= 0 {
|
|
return r, nil
|
|
}
|
|
var head bytes.Buffer
|
|
cfg, err := config(io.TeeReader(r, &head))
|
|
replay := io.MultiReader(&head, r)
|
|
if err != nil {
|
|
return replay, nil
|
|
}
|
|
if l.exceeded(cfg.Width, cfg.Height) {
|
|
return nil, thumbnailerErrors.ErrImageTooLarge
|
|
}
|
|
return replay, nil
|
|
}
|
|
|
|
// ForType returns the converter for the specified mimeType
|
|
func ForType(mimeType string, opts map[string]any) FileConverter {
|
|
// We can ignore the error here because we parse it in IsMimeTypeSupported before and if it fails
|
|
// return the service call. So we should only get here when the mimeType parses fine.
|
|
mimeType, _, _ = mime.ParseMediaType(mimeType)
|
|
limit := decodeLimitFromOpts(opts)
|
|
switch mimeType {
|
|
case "text/plain":
|
|
fontFileMap := ""
|
|
fontFaceOpts := &opentype.FaceOptions{
|
|
Size: 12,
|
|
DPI: 72,
|
|
Hinting: font.HintingNone,
|
|
}
|
|
|
|
if optedFontFileMap, ok := opts["fontFileMap"]; ok {
|
|
if stringFontFileMap, ok := optedFontFileMap.(string); ok {
|
|
fontFileMap = stringFontFileMap
|
|
}
|
|
}
|
|
|
|
if optedFontFaceOpts, ok := opts["fontFaceOpts"]; ok {
|
|
if typedFontFaceOpts, ok := optedFontFaceOpts.(*opentype.FaceOptions); ok {
|
|
fontFaceOpts = typedFontFaceOpts
|
|
}
|
|
}
|
|
|
|
fontLoader, err := NewFontLoader(fontFileMap, fontFaceOpts)
|
|
if err != nil {
|
|
// if it couldn't create the FontLoader with the specified fontFileMap,
|
|
// try to use the default font
|
|
fontLoader, _ = NewFontLoader("", fontFaceOpts)
|
|
}
|
|
return TxtToImageConverter{
|
|
fontLoader: fontLoader,
|
|
}
|
|
case "application/vnd.geogebra.slides":
|
|
return GgsDecoder{thumbnailpath: "_slide0/geogebra_thumbnail.png", limit: limit}
|
|
case "application/vnd.geogebra.pinboard":
|
|
return GgpDecoder{limit: limit}
|
|
case "image/gif":
|
|
return GifDecoder{limit: limit}
|
|
case "audio/flac":
|
|
fallthrough
|
|
case "audio/mpeg":
|
|
fallthrough
|
|
case "audio/ogg":
|
|
return AudioDecoder{limit: limit}
|
|
default:
|
|
return ImageDecoder{limit: limit}
|
|
}
|
|
}
|