2026-07-22 19:21:15 +08:00

387 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package mediaimage 对 gateway 收到的完整图片字节做不解码像素的结构校验。
// GIF/WebP 只扫描容器和帧边界,避免攻击者用巨大画布或高帧数触发解压内存放大。
package mediaimage
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"image/jpeg"
"image/png"
)
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 只以完整文件字节为图片格式事实来源,并拒绝 APNG 及畸形动画容器。
// multipart 文件名和 Content-Type 都由客户端提供Android 相册缓存尤其可能保留错误后缀,
// 因而调用方必须使用这里返回的规范后缀与 Content-Type 保存对象,不能再信任上传元数据。
func Inspect(content []byte) (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
}
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
}