396 lines
14 KiB
Go
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
|
|
}
|