396 lines
14 KiB
Go

// Package mediaimage 对 gateway 收到的完整图片字节做不解码像素的结构校验。
// GIF/WebP 只扫描容器和帧边界,避免攻击者用巨大画布或高帧数触发解压内存放大。
package mediaimage
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"image/jpeg"
"image/png"
"mime"
"path/filepath"
"strings"
)
const (
MaxFrames = int32(120)
MaxDurationMS = int64(15_000)
MaxDimension = int32(4096)
MaxPixels = int64(16_777_216)
)
// Info 是从真实文件内容解析出的规范化事实;调用方必须原样保存这些字节,不能转码后复用本结果。
type Info struct {
ContentType string
Format string
SizeBytes int64
Width int32
Height int32
Animated bool
FrameCount int32
DurationMS int64
SHA256 string
}
// Inspect 严格要求魔数、扩展名和 multipart Content-Type 三者一致,并拒绝 APNG 及畸形动画容器。
func Inspect(content []byte, filename string, partContentType string) (Info, string, error) {
var info Info
var extension string
switch {
case len(content) >= 3 && content[0] == 0xff && content[1] == 0xd8 && content[2] == 0xff:
cfg, err := jpeg.DecodeConfig(bytes.NewReader(content))
if err != nil {
return info, "", fmt.Errorf("invalid jpeg")
}
info.Format, info.ContentType, info.Width, info.Height, info.FrameCount = "jpeg", "image/jpeg", int32(cfg.Width), int32(cfg.Height), 1
extension = "jpg"
case len(content) >= 8 && bytes.Equal(content[:8], []byte{137, 80, 78, 71, 13, 10, 26, 10}):
if pngHasAnimationChunk(content) {
return info, "", fmt.Errorf("animated png is not supported")
}
cfg, err := png.DecodeConfig(bytes.NewReader(content))
if err != nil {
return info, "", fmt.Errorf("invalid png")
}
info.Format, info.ContentType, info.Width, info.Height, info.FrameCount = "png", "image/png", int32(cfg.Width), int32(cfg.Height), 1
extension = "png"
case len(content) >= 6 && (string(content[:6]) == "GIF87a" || string(content[:6]) == "GIF89a"):
width, height, frames, durationMS, err := inspectGIF(content)
if err != nil {
return info, "", err
}
info.Format, info.ContentType, info.Width, info.Height = "gif", "image/gif", width, height
info.FrameCount, info.Animated, info.DurationMS = frames, frames > 1, durationMS
if !info.Animated {
info.DurationMS = 0
}
extension = "gif"
case len(content) >= 12 && string(content[:4]) == "RIFF" && string(content[8:12]) == "WEBP":
width, height, frames, durationMS, animated, err := inspectWebP(content)
if err != nil {
return info, "", err
}
info.Format, info.ContentType, info.Width, info.Height = "webp", "image/webp", width, height
info.FrameCount, info.DurationMS, info.Animated = frames, durationMS, animated
extension = "webp"
default:
return info, "", fmt.Errorf("only jpeg, png, gif and webp are supported")
}
if err := validateGeometry(info); err != nil {
return Info{}, "", err
}
declared, _, _ := mime.ParseMediaType(strings.TrimSpace(partContentType))
if !strings.EqualFold(declared, info.ContentType) {
return Info{}, "", fmt.Errorf("file content_type does not match signature")
}
fileExtension := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
if fileExtension != extension && !(info.Format == "jpeg" && fileExtension == "jpeg") {
return Info{}, "", fmt.Errorf("filename extension does not match signature")
}
sum := sha256.Sum256(content)
info.SizeBytes, info.SHA256 = int64(len(content)), hex.EncodeToString(sum[:])
return info, extension, nil
}
func validateGeometry(info Info) error {
if info.Width <= 0 || info.Height <= 0 || info.Width > MaxDimension || info.Height > MaxDimension ||
int64(info.Width)*int64(info.Height) > MaxPixels || info.FrameCount <= 0 || info.FrameCount > MaxFrames {
return fmt.Errorf("image dimensions or frame count exceed limit")
}
if info.Animated {
if info.FrameCount < 2 || info.DurationMS <= 0 || info.DurationMS > MaxDurationMS {
return fmt.Errorf("animation exceeds frame or duration limit")
}
} else if info.FrameCount != 1 || info.DurationMS != 0 {
return fmt.Errorf("static image properties are invalid")
}
return nil
}
func pngHasAnimationChunk(content []byte) bool {
for offset := 8; offset+12 <= len(content); {
size := int(binary.BigEndian.Uint32(content[offset : offset+4]))
if size < 0 || offset+12+size > len(content) {
return false
}
if string(content[offset+4:offset+8]) == "acTL" {
return true
}
offset += 12 + size
}
return false
}
func inspectGIF(content []byte) (int32, int32, int32, int64, error) {
if len(content) < 13 {
return 0, 0, 0, 0, fmt.Errorf("invalid gif")
}
width := int32(binary.LittleEndian.Uint16(content[6:8]))
height := int32(binary.LittleEndian.Uint16(content[8:10]))
if width <= 0 || height <= 0 || width > MaxDimension || height > MaxDimension || int64(width)*int64(height) > MaxPixels {
return 0, 0, 0, 0, fmt.Errorf("gif dimensions exceed limit")
}
offset := 13
if content[10]&0x80 != 0 {
colorTableBytes := 3 * (1 << (int(content[10]&0x07) + 1))
if offset+colorTableBytes > len(content) {
return 0, 0, 0, 0, fmt.Errorf("invalid gif color table")
}
offset += colorTableBytes
}
var frames int32
var durationMS, pendingDelayMS int64
for offset < len(content) {
marker := content[offset]
offset++
switch marker {
case 0x3b:
if frames <= 0 {
return 0, 0, 0, 0, fmt.Errorf("gif has no image frame")
}
if frames == 1 {
durationMS = 0
}
return width, height, frames, durationMS, nil
case 0x21:
if offset >= len(content) {
return 0, 0, 0, 0, fmt.Errorf("invalid gif extension")
}
label := content[offset]
offset++
if label == 0xf9 {
if offset+6 > len(content) || content[offset] != 4 || content[offset+5] != 0 {
return 0, 0, 0, 0, fmt.Errorf("invalid gif graphic control extension")
}
pendingDelayMS = int64(binary.LittleEndian.Uint16(content[offset+2:offset+4])) * 10
offset += 6
continue
}
var err error
offset, _, err = skipGIFSubBlocks(content, offset)
if err != nil {
return 0, 0, 0, 0, err
}
case 0x2c:
if offset+9 > len(content) {
return 0, 0, 0, 0, fmt.Errorf("invalid gif image descriptor")
}
left := int(binary.LittleEndian.Uint16(content[offset : offset+2]))
top := int(binary.LittleEndian.Uint16(content[offset+2 : offset+4]))
frameWidth := int(binary.LittleEndian.Uint16(content[offset+4 : offset+6]))
frameHeight := int(binary.LittleEndian.Uint16(content[offset+6 : offset+8]))
packed := content[offset+8]
if frameWidth <= 0 || frameHeight <= 0 || left+frameWidth > int(width) || top+frameHeight > int(height) {
return 0, 0, 0, 0, fmt.Errorf("invalid gif frame dimensions")
}
offset += 9
if packed&0x80 != 0 {
colorTableBytes := 3 * (1 << (int(packed&0x07) + 1))
if offset+colorTableBytes > len(content) {
return 0, 0, 0, 0, fmt.Errorf("invalid gif local color table")
}
offset += colorTableBytes
}
if offset >= len(content) || content[offset] < 2 || content[offset] > 8 {
return 0, 0, 0, 0, fmt.Errorf("invalid gif lzw code size")
}
offset++
var hasData bool
var err error
offset, hasData, err = skipGIFSubBlocks(content, offset)
if err != nil || !hasData {
return 0, 0, 0, 0, fmt.Errorf("invalid gif image data")
}
frames++
durationMS += pendingDelayMS
pendingDelayMS = 0
if frames > MaxFrames || durationMS > MaxDurationMS {
return 0, 0, 0, 0, fmt.Errorf("gif animation exceeds frame or duration limit")
}
default:
return 0, 0, 0, 0, fmt.Errorf("invalid gif block marker")
}
}
return 0, 0, 0, 0, fmt.Errorf("gif trailer is missing")
}
func skipGIFSubBlocks(content []byte, offset int) (int, bool, error) {
hasData := false
for {
if offset >= len(content) {
return 0, false, fmt.Errorf("invalid gif sub-block")
}
blockSize := int(content[offset])
offset++
if blockSize == 0 {
return offset, hasData, nil
}
if offset+blockSize > len(content) {
return 0, false, fmt.Errorf("invalid gif sub-block")
}
hasData = true
offset += blockSize
}
}
func inspectWebP(content []byte) (int32, int32, int32, int64, bool, error) {
if len(content) < 20 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp header")
}
if uint64(binary.LittleEndian.Uint32(content[4:8]))+8 != uint64(len(content)) {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp riff size")
}
var width, height int32
var frames int32
var durationMS int64
var animatedFlag, hasVP8X, hasANIM, hasStaticImage bool
for offset := 12; offset < len(content); {
if offset+8 > len(content) {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp trailing chunk")
}
chunkType := string(content[offset : offset+4])
size := uint64(binary.LittleEndian.Uint32(content[offset+4 : offset+8]))
start, end := uint64(offset+8), uint64(offset+8)+size
paddedEnd := end + size%2
if end < start || paddedEnd > uint64(len(content)) {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp chunk")
}
if size%2 == 1 && content[end] != 0 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp chunk padding")
}
chunk := content[int(start):int(end)]
switch chunkType {
case "VP8X":
if hasVP8X || offset != 12 || len(chunk) != 10 || chunk[0]&0xc1 != 0 || chunk[1] != 0 || chunk[2] != 0 || chunk[3] != 0 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp header")
}
hasVP8X, animatedFlag = true, chunk[0]&0x02 != 0
width, height = int32(readUint24LE(chunk[4:7]))+1, int32(readUint24LE(chunk[7:10]))+1
case "VP8 ", "VP8L":
if hasStaticImage || (!hasVP8X && offset != 12) {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp image payload order")
}
imageWidth, imageHeight, err := inspectWebPImagePayload(chunkType, chunk)
if err != nil {
return 0, 0, 0, 0, false, err
}
hasStaticImage = true
if width == 0 {
width, height = imageWidth, imageHeight
} else if imageWidth > width || imageHeight > height {
return 0, 0, 0, 0, false, fmt.Errorf("webp image exceeds canvas")
}
case "ANIM":
if !hasVP8X || !animatedFlag || hasANIM || len(chunk) != 6 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp animation header")
}
hasANIM = true
case "ANMF":
if !hasVP8X || !animatedFlag || !hasANIM || width <= 0 || height <= 0 || len(chunk) < 16 || chunk[15]&0xfc != 0 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp animation frame")
}
frameX, frameY := int64(readUint24LE(chunk[0:3]))*2, int64(readUint24LE(chunk[3:6]))*2
frameWidth, frameHeight := int32(readUint24LE(chunk[6:9]))+1, int32(readUint24LE(chunk[9:12]))+1
if frameWidth <= 0 || frameHeight <= 0 || frameX+int64(frameWidth) > int64(width) || frameY+int64(frameHeight) > int64(height) {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp animation frame geometry")
}
if err := inspectWebPFramePayload(chunk[16:], frameWidth, frameHeight); err != nil {
return 0, 0, 0, 0, false, err
}
frames++
durationMS += int64(readUint24LE(chunk[12:15]))
if frames > MaxFrames || durationMS > MaxDurationMS {
return 0, 0, 0, 0, false, fmt.Errorf("webp animation exceeds frame or duration limit")
}
}
offset = int(paddedEnd)
}
if width <= 0 || height <= 0 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp dimensions")
}
if animatedFlag {
if !hasANIM || hasStaticImage || frames < 2 || durationMS <= 0 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid animated webp")
}
return width, height, frames, durationMS, true, nil
}
if hasANIM || frames != 0 || !hasStaticImage {
return 0, 0, 0, 0, false, fmt.Errorf("invalid static webp")
}
return width, height, 1, 0, false, nil
}
func inspectWebPImagePayload(chunkType string, chunk []byte) (int32, int32, error) {
switch chunkType {
case "VP8 ":
if len(chunk) <= 10 || chunk[0]&0x01 != 0 || !bytes.Equal(chunk[3:6], []byte{0x9d, 0x01, 0x2a}) {
return 0, 0, fmt.Errorf("invalid webp vp8 payload")
}
width, height := int32(binary.LittleEndian.Uint16(chunk[6:8])&0x3fff), int32(binary.LittleEndian.Uint16(chunk[8:10])&0x3fff)
if width <= 0 || height <= 0 {
return 0, 0, fmt.Errorf("invalid webp vp8 dimensions")
}
return width, height, nil
case "VP8L":
if len(chunk) <= 5 || chunk[0] != 0x2f {
return 0, 0, fmt.Errorf("invalid webp vp8l payload")
}
bits := binary.LittleEndian.Uint32(chunk[1:5])
if bits>>29 != 0 {
return 0, 0, fmt.Errorf("invalid webp vp8l version")
}
return int32(bits&0x3fff) + 1, int32((bits>>14)&0x3fff) + 1, nil
default:
return 0, 0, fmt.Errorf("unsupported webp image payload")
}
}
func inspectWebPFramePayload(content []byte, expectedWidth int32, expectedHeight int32) error {
if len(content) < 8 {
return fmt.Errorf("webp animation frame has no image payload")
}
foundImage := false
for offset := 0; offset < len(content); {
if offset+8 > len(content) {
return fmt.Errorf("invalid webp animation frame chunk")
}
chunkType := string(content[offset : offset+4])
size := uint64(binary.LittleEndian.Uint32(content[offset+4 : offset+8]))
start, end := uint64(offset+8), uint64(offset+8)+size
paddedEnd := end + size%2
if end < start || paddedEnd > uint64(len(content)) || (size%2 == 1 && content[end] != 0) {
return fmt.Errorf("invalid webp animation frame chunk")
}
chunk := content[int(start):int(end)]
switch chunkType {
case "ALPH":
if foundImage || len(chunk) == 0 {
return fmt.Errorf("invalid webp animation alpha payload")
}
case "VP8 ", "VP8L":
if foundImage {
return fmt.Errorf("webp animation frame has multiple image payloads")
}
width, height, err := inspectWebPImagePayload(chunkType, chunk)
if err != nil || width != expectedWidth || height != expectedHeight {
return fmt.Errorf("webp animation image dimensions mismatch")
}
foundImage = true
default:
return fmt.Errorf("unsupported webp animation frame chunk")
}
offset = int(paddedEnd)
}
if !foundImage {
return fmt.Errorf("webp animation frame has no image payload")
}
return nil
}
func readUint24LE(value []byte) uint32 {
return uint32(value[0]) | uint32(value[1])<<8 | uint32(value[2])<<16
}