fix: security & privacy (rate limiter fix, csrf session cookie, logout on server, drafts stale token passw, login timing, password over 72b, JWT_SECRET complexity, SMTP TLS, CSV export neutralised, fonts bundled, upload metadata, hourly cleanup job, privacy.md report)
This commit is contained in:
1 parent
e2812ee0b9
commit
ea98c5ec05
118 files changed
+3828
-130
No files matched your search
@@ -0,0 +1,727 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
domainImage "RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/infrastructure/imaging"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gen2brain/webp"
|
||||
xwebp "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
const metadataSecret = "GPS-48.8584N-2.2945E"
|
||||
|
||||
func metadataTestImage() *image.NRGBA {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 8, 6))
|
||||
for y := 0; y < 6; y++ {
|
||||
for x := 0; x < 8; x++ {
|
||||
img.Set(x, y, color.NRGBA{R: uint8(x * 30), G: uint8(y * 40), B: 90, A: 255})
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
type exifByteOrder interface {
|
||||
binary.ByteOrder
|
||||
binary.AppendByteOrder
|
||||
}
|
||||
|
||||
func exifTIFF(order exifByteOrder, orientation uint16) []byte {
|
||||
marker := []byte("II")
|
||||
if order.String() == binary.BigEndian.String() {
|
||||
marker = []byte("MM")
|
||||
}
|
||||
text := append([]byte(metadataSecret), 0)
|
||||
if len(text)%2 == 1 {
|
||||
text = append(text, 0)
|
||||
}
|
||||
ifd := uint32(8 + len(text))
|
||||
tiff := append(marker, 0, 0)
|
||||
order.PutUint16(tiff[2:], 42)
|
||||
tiff = order.AppendUint32(tiff, ifd)
|
||||
tiff = append(tiff, text...)
|
||||
tiff = order.AppendUint16(tiff, 2)
|
||||
tiff = order.AppendUint16(tiff, 0x010F)
|
||||
tiff = order.AppendUint16(tiff, 2)
|
||||
tiff = order.AppendUint32(tiff, uint32(len(metadataSecret)+1))
|
||||
tiff = order.AppendUint32(tiff, 8)
|
||||
tiff = order.AppendUint16(tiff, 0x0112)
|
||||
tiff = order.AppendUint16(tiff, 3)
|
||||
tiff = order.AppendUint32(tiff, 1)
|
||||
tiff = order.AppendUint16(tiff, orientation)
|
||||
tiff = order.AppendUint16(tiff, 0)
|
||||
return order.AppendUint32(tiff, 0)
|
||||
}
|
||||
|
||||
func jpegSegmentBytes(marker byte, payload []byte) []byte {
|
||||
segment := []byte{0xFF, marker}
|
||||
segment = binary.BigEndian.AppendUint16(segment, uint16(len(payload)+2))
|
||||
return append(segment, payload...)
|
||||
}
|
||||
|
||||
func encodedJPEG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, metadataTestImage(), &jpeg.Options{Quality: 90}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func jpegWithMetadata(t *testing.T, orientation uint16, extra ...[]byte) []byte {
|
||||
t.Helper()
|
||||
plain := encodedJPEG(t)
|
||||
out := append([]byte{}, plain[:2]...)
|
||||
for _, segment := range extra {
|
||||
out = append(out, segment...)
|
||||
}
|
||||
out = append(out, jpegSegmentBytes(0xE1, append([]byte("Exif\x00\x00"), exifTIFF(binary.LittleEndian, orientation)...))...)
|
||||
out = append(out, jpegSegmentBytes(0xE1, []byte("http://ns.adobe.com/xap/1.0/\x00<x:xmpmeta>"+metadataSecret+"</x:xmpmeta>"))...)
|
||||
out = append(out, jpegSegmentBytes(0xED, []byte("Photoshop 3.0\x008BIM"+metadataSecret))...)
|
||||
out = append(out, jpegSegmentBytes(0xFE, []byte("comment "+metadataSecret))...)
|
||||
out = append(out, jpegSegmentBytes(0xE2, []byte("MPF\x00"+metadataSecret))...)
|
||||
out = append(out, jpegSegmentBytes(0xE2, []byte("ICC_PROFILE\x00\x01\x01profile-bytes"))...)
|
||||
out = append(out, plain[2:]...)
|
||||
return append(out, []byte("\xFF\xD8trailing image "+metadataSecret+"\xFF\xD9")...)
|
||||
}
|
||||
|
||||
func pngChunkBytes(kind string, payload []byte) []byte {
|
||||
chunk := binary.BigEndian.AppendUint32(nil, uint32(len(payload)))
|
||||
chunk = append(chunk, kind...)
|
||||
chunk = append(chunk, payload...)
|
||||
return binary.BigEndian.AppendUint32(chunk, crc32.ChecksumIEEE(chunk[4:]))
|
||||
}
|
||||
|
||||
func encodedPNG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, metadataTestImage()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func pngWithMetadata(t *testing.T, orientation uint16) []byte {
|
||||
t.Helper()
|
||||
plain := encodedPNG(t)
|
||||
afterIHDR := 8 + 8 + 13 + 4
|
||||
out := append([]byte{}, plain[:afterIHDR]...)
|
||||
out = append(out, pngChunkBytes("eXIf", exifTIFF(binary.BigEndian, orientation))...)
|
||||
out = append(out, pngChunkBytes("tEXt", []byte("Comment\x00"+metadataSecret))...)
|
||||
out = append(out, pngChunkBytes("iTXt", []byte("XML:com.adobe.xmp\x00\x00\x00\x00\x00"+metadataSecret))...)
|
||||
out = append(out, pngChunkBytes("zTXt", []byte("Author\x00\x00"+metadataSecret))...)
|
||||
out = append(out, pngChunkBytes("tIME", []byte{0x07, 0xEA, 10, 22, 9, 0, 0})...)
|
||||
out = append(out, plain[afterIHDR:]...)
|
||||
return append(out, []byte("trailing "+metadataSecret)...)
|
||||
}
|
||||
|
||||
func riffChunkBytes(kind string, payload []byte) []byte {
|
||||
chunk := append([]byte(kind), binary.LittleEndian.AppendUint32(nil, uint32(len(payload)))...)
|
||||
chunk = append(chunk, payload...)
|
||||
if len(payload)%2 == 1 {
|
||||
chunk = append(chunk, 0)
|
||||
}
|
||||
return chunk
|
||||
}
|
||||
|
||||
func webpWithMetadata(t *testing.T, orientation uint16) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := webp.Encode(&buf, metadataTestImage(), webp.Options{Lossless: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
simple := buf.Bytes()
|
||||
vp8x := []byte{0x08 | 0x04, 0, 0, 0}
|
||||
vp8x = append(vp8x, 7, 0, 0, 5, 0, 0)
|
||||
body := []byte("WEBP")
|
||||
body = append(body, riffChunkBytes("VP8X", vp8x)...)
|
||||
body = append(body, simple[12:]...)
|
||||
body = append(body, riffChunkBytes("EXIF", exifTIFF(binary.LittleEndian, orientation))...)
|
||||
body = append(body, riffChunkBytes("XMP ", []byte("<x:xmpmeta>"+metadataSecret+"</x:xmpmeta>!"))...)
|
||||
out := append([]byte("RIFF"), binary.LittleEndian.AppendUint32(nil, uint32(len(body)))...)
|
||||
out = append(out, body...)
|
||||
return append(out, []byte("trailing "+metadataSecret)...)
|
||||
}
|
||||
|
||||
func assertSamePixels(t *testing.T, want image.Image, got image.Image) {
|
||||
t.Helper()
|
||||
if want.Bounds() != got.Bounds() {
|
||||
t.Fatalf("bounds changed: %v vs %v", want.Bounds(), got.Bounds())
|
||||
}
|
||||
for y := want.Bounds().Min.Y; y < want.Bounds().Max.Y; y++ {
|
||||
for x := want.Bounds().Min.X; x < want.Bounds().Max.X; x++ {
|
||||
if want.At(x, y) != got.At(x, y) {
|
||||
t.Fatalf("pixel (%d,%d) changed: %v vs %v", x, y, want.At(x, y), got.At(x, y))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoSecret(t *testing.T, data []byte) {
|
||||
t.Helper()
|
||||
if bytes.Contains(data, []byte(metadataSecret)) {
|
||||
t.Errorf("the metadata secret survived stripping")
|
||||
}
|
||||
}
|
||||
|
||||
func orientationOnlyEntry(orientation uint16) []byte {
|
||||
entry := []byte{0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01}
|
||||
return binary.BigEndian.AppendUint16(entry, orientation)
|
||||
}
|
||||
|
||||
func TestMetadataStripperJPEG(t *testing.T) {
|
||||
stripper := imaging.NewMetadataStripper()
|
||||
|
||||
t.Run("Drops EXIF, XMP, IPTC, comments, MPF and trailing images but keeps the pixels and the ICC profile", func(t *testing.T) {
|
||||
original := jpegWithMetadata(t, 1)
|
||||
|
||||
stripped, err := stripper.Strip("image/jpeg", original)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertNoSecret(t, stripped)
|
||||
if !bytes.Contains(stripped, []byte("ICC_PROFILE\x00")) {
|
||||
t.Error("the ICC profile must be kept")
|
||||
}
|
||||
if bytes.Contains(stripped, []byte("Exif\x00\x00")) {
|
||||
t.Error("an upright image needs no EXIF at all")
|
||||
}
|
||||
if !bytes.HasSuffix(stripped, []byte{0xFF, 0xD9}) || bytes.Count(stripped, []byte{0xFF, 0xD8}) != 1 {
|
||||
t.Error("the output must end at the primary image's EOI")
|
||||
}
|
||||
want, _ := jpeg.Decode(bytes.NewReader(encodedJPEG(t)))
|
||||
got, err := jpeg.Decode(bytes.NewReader(stripped))
|
||||
if err != nil {
|
||||
t.Fatalf("the stripped JPEG does not decode: %v", err)
|
||||
}
|
||||
assertSamePixels(t, want, got)
|
||||
})
|
||||
|
||||
t.Run("Keeps only the orientation of a rotated photo, right after SOI", func(t *testing.T) {
|
||||
stripped, err := stripper.Strip("image/jpeg", jpegWithMetadata(t, 6))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertNoSecret(t, stripped)
|
||||
if !bytes.Equal(stripped[2:4], []byte{0xFF, 0xE1}) || !bytes.Contains(stripped[:64], orientationOnlyEntry(6)) {
|
||||
t.Errorf("expected an orientation-only APP1 after SOI, got % x", stripped[:48])
|
||||
}
|
||||
if _, err := jpeg.Decode(bytes.NewReader(stripped)); err != nil {
|
||||
t.Errorf("the stripped JPEG does not decode: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Puts the orientation after a leading JFIF header", func(t *testing.T) {
|
||||
jfif := jpegSegmentBytes(0xE0, []byte("JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"))
|
||||
|
||||
stripped, err := stripper.Strip("image/jpeg", jpegWithMetadata(t, 8, jfif))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := 2 + len(jfif)
|
||||
if !bytes.Equal(stripped[2:next], jfif) || !bytes.Equal(stripped[next:next+2], []byte{0xFF, 0xE1}) {
|
||||
t.Errorf("expected JFIF then the orientation APP1, got % x", stripped[:next+4])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A JPEG cut off inside its scan keeps what it has", func(t *testing.T) {
|
||||
plain := encodedJPEG(t)
|
||||
truncated := plain[:len(plain)-20]
|
||||
|
||||
stripped, err := stripper.Strip("image/jpeg", truncated)
|
||||
|
||||
if err != nil || !bytes.Equal(stripped, truncated) {
|
||||
t.Errorf("expected the truncated scan kept as is, got %d bytes, %v", len(stripped), err)
|
||||
}
|
||||
})
|
||||
|
||||
malformed := map[string][]byte{
|
||||
"not a JPEG": []byte("GIF89a"),
|
||||
"a segment longer than the file": {0xFF, 0xD8, 0xFF, 0xE1, 0x10, 0x00, 'E'},
|
||||
"a segment length below two": {0xFF, 0xD8, 0xFF, 0xE1, 0x00, 0x01},
|
||||
"a missing marker": {0xFF, 0xD8, 0x00, 0x00},
|
||||
"a header that stops early": {0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0, 0},
|
||||
}
|
||||
for name, data := range malformed {
|
||||
t.Run("Refuses "+name, func(t *testing.T) {
|
||||
if _, err := stripper.Strip("image/jpeg", data); !errors.Is(err, domainImage.ErrImageUnreadable) {
|
||||
t.Errorf("expected ErrImageUnreadable, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataStripperPNG(t *testing.T) {
|
||||
stripper := imaging.NewMetadataStripper()
|
||||
|
||||
t.Run("Drops text, time and EXIF chunks and trailing data but keeps the pixels", func(t *testing.T) {
|
||||
stripped, err := stripper.Strip("image/png", pngWithMetadata(t, 1))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertNoSecret(t, stripped)
|
||||
for _, kind := range []string{"eXIf", "tEXt", "iTXt", "zTXt", "tIME"} {
|
||||
if bytes.Contains(stripped, []byte(kind)) {
|
||||
t.Errorf("%s survived", kind)
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(stripped, encodedPNG(t)) {
|
||||
t.Error("expected exactly the plain PNG back")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Keeps only the orientation in an eXIf chunk right after IHDR", func(t *testing.T) {
|
||||
stripped, err := stripper.Strip("image/png", pngWithMetadata(t, 3))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertNoSecret(t, stripped)
|
||||
afterIHDR := 8 + 8 + 13 + 4
|
||||
if string(stripped[afterIHDR+4:afterIHDR+8]) != "eXIf" || !bytes.Contains(stripped, orientationOnlyEntry(3)) {
|
||||
t.Errorf("expected an orientation-only eXIf after IHDR")
|
||||
}
|
||||
want, _ := png.Decode(bytes.NewReader(encodedPNG(t)))
|
||||
got, err := png.Decode(bytes.NewReader(stripped))
|
||||
if err != nil {
|
||||
t.Fatalf("the stripped PNG does not decode: %v", err)
|
||||
}
|
||||
assertSamePixels(t, want, got)
|
||||
})
|
||||
|
||||
plain := func(t *testing.T) []byte { return encodedPNG(t) }
|
||||
malformed := map[string]func(t *testing.T) []byte{
|
||||
"not a PNG": func(*testing.T) []byte { return []byte("GIF89a") },
|
||||
"a PNG without IEND": func(t *testing.T) []byte { p := plain(t); return p[:len(p)-12] },
|
||||
"a chunk longer than the file": func(t *testing.T) []byte { p := plain(t); binary.BigEndian.PutUint32(p[8:], 1<<30); return p },
|
||||
"a chunk header that stops early": func(t *testing.T) []byte { return append(plain(t)[:8], 0, 0, 0) },
|
||||
}
|
||||
for name, build := range malformed {
|
||||
t.Run("Refuses "+name, func(t *testing.T) {
|
||||
if _, err := stripper.Strip("image/png", build(t)); !errors.Is(err, domainImage.ErrImageUnreadable) {
|
||||
t.Errorf("expected ErrImageUnreadable, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataStripperWebP(t *testing.T) {
|
||||
stripper := imaging.NewMetadataStripper()
|
||||
|
||||
t.Run("Drops XMP and EXIF, clears their flags and keeps the pixels", func(t *testing.T) {
|
||||
original := webpWithMetadata(t, 1)
|
||||
|
||||
stripped, err := stripper.Strip("image/webp", original)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertNoSecret(t, stripped)
|
||||
if bytes.Contains(stripped, []byte("EXIF")) || bytes.Contains(stripped, []byte("XMP ")) {
|
||||
t.Error("metadata chunks survived")
|
||||
}
|
||||
if flags := stripped[20]; flags&0x0C != 0 {
|
||||
t.Errorf("expected the EXIF and XMP flags cleared, got %#x", flags)
|
||||
}
|
||||
if size := binary.LittleEndian.Uint32(stripped[4:]); int(size) != len(stripped)-8 {
|
||||
t.Errorf("RIFF size %d does not match %d bytes", size, len(stripped)-8)
|
||||
}
|
||||
want, _ := xwebp.Decode(bytes.NewReader(original))
|
||||
got, err := xwebp.Decode(bytes.NewReader(stripped))
|
||||
if err != nil {
|
||||
t.Fatalf("the stripped WebP does not decode: %v", err)
|
||||
}
|
||||
assertSamePixels(t, want, got)
|
||||
})
|
||||
|
||||
t.Run("Keeps only the orientation of a rotated image", func(t *testing.T) {
|
||||
stripped, err := stripper.Strip("image/webp", webpWithMetadata(t, 8))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertNoSecret(t, stripped)
|
||||
if flags := stripped[20]; flags&0x0C != 0x08 {
|
||||
t.Errorf("expected only the EXIF flag set, got %#x", flags)
|
||||
}
|
||||
if !bytes.Contains(stripped, orientationOnlyEntry(8)) {
|
||||
t.Error("expected an orientation-only EXIF chunk")
|
||||
}
|
||||
if _, err := xwebp.Decode(bytes.NewReader(stripped)); err != nil {
|
||||
t.Errorf("the stripped WebP does not decode: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A simple WebP without extended header comes back unchanged", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := webp.Encode(&buf, metadataTestImage(), webp.Options{Lossless: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stripped, err := stripper.Strip("image/webp", buf.Bytes())
|
||||
|
||||
if err != nil || !bytes.Equal(stripped, buf.Bytes()) {
|
||||
t.Errorf("expected the same bytes back, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
malformed := map[string][]byte{
|
||||
"not a WebP": []byte("RIFF\x04\x00\x00\x00WAVE"),
|
||||
"a RIFF size beyond the file": []byte("RIFF\xff\x00\x00\x00WEBP"),
|
||||
"a chunk longer than the file": append([]byte("RIFF\x10\x00\x00\x00WEBPVP8L"), 0xff, 0, 0, 0),
|
||||
"a container with no chunks": []byte("RIFF\x04\x00\x00\x00WEBP"),
|
||||
"an empty extended header": append([]byte("RIFF\x0c\x00\x00\x00WEBPVP8X"), 0, 0, 0, 0),
|
||||
"a chunk header that stops early": []byte("RIFF\x08\x00\x00\x00WEBPVP8X"),
|
||||
}
|
||||
for name, data := range malformed {
|
||||
t.Run("Refuses "+name, func(t *testing.T) {
|
||||
if _, err := stripper.Strip("image/webp", data); !errors.Is(err, domainImage.ErrImageUnreadable) {
|
||||
t.Errorf("expected ErrImageUnreadable, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataStripperLeavesSVGAlone(t *testing.T) {
|
||||
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><metadata>keep</metadata></svg>`)
|
||||
|
||||
stripped, err := imaging.NewMetadataStripper().Strip("image/svg+xml", svg)
|
||||
|
||||
if err != nil || !bytes.Equal(stripped, svg) {
|
||||
t.Errorf("expected the SVG untouched, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadStoresTheStrippedImage(t *testing.T) {
|
||||
repo := newMockImageRepo()
|
||||
store := newMockImageStorage()
|
||||
uc := useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper())
|
||||
original := jpegWithMetadata(t, 6)
|
||||
|
||||
created, err := uc.CreateImage(testCtx, domainImage.ImageInput{
|
||||
Content: bytes.NewReader(original),
|
||||
Filename: "holiday.jpg",
|
||||
Size: int64(len(original)),
|
||||
ContentType: "image/jpeg",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored := store.lastContent
|
||||
assertNoSecret(t, stored)
|
||||
if created.Size != int64(len(stored)) {
|
||||
t.Errorf("expected the recorded size %d to match the stored %d bytes", created.Size, len(stored))
|
||||
}
|
||||
sum := sha256.Sum256(stored)
|
||||
if created.SHA256 != hex.EncodeToString(sum[:]) {
|
||||
t.Error("expected the checksum of the stored bytes")
|
||||
}
|
||||
if created.Width != 8 || created.Height != 6 {
|
||||
t.Errorf("expected 8x6, got %dx%d", created.Width, created.Height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRefusesAnImageWhoseContainerIsBroken(t *testing.T) {
|
||||
store := newMockImageStorage()
|
||||
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), store, discardRecorder, imaging.NewMetadataStripper())
|
||||
broken := encodedPNG(t)
|
||||
broken = broken[:len(broken)-12]
|
||||
|
||||
_, err := uc.CreateImage(testCtx, domainImage.ImageInput{Content: bytes.NewReader(broken), Filename: "broken.png", ContentType: "image/png"})
|
||||
|
||||
if !errors.Is(err, domainImage.ErrImageUnreadable) {
|
||||
t.Errorf("expected ErrImageUnreadable, got %v", err)
|
||||
}
|
||||
if store.putCalls != 0 {
|
||||
t.Error("nothing may be stored")
|
||||
}
|
||||
}
|
||||
|
||||
func hexBytes(t *testing.T, value string) []byte {
|
||||
t.Helper()
|
||||
decoded, err := hex.DecodeString(strings.ReplaceAll(value, " ", ""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func jpegWithExif(tiff []byte) []byte {
|
||||
out := []byte{0xFF, 0xD8}
|
||||
out = append(out, jpegSegmentBytes(0xE1, append([]byte("Exif\x00\x00"), tiff...))...)
|
||||
return append(out, 0xFF, 0xD9)
|
||||
}
|
||||
|
||||
func TestMetadataStripperJPEGStructure(t *testing.T) {
|
||||
stripper := imaging.NewMetadataStripper()
|
||||
|
||||
kept := map[string]string{
|
||||
"the smallest JPEG, SOI then EOI": "FFD8 FFD9",
|
||||
"standalone TEM and RST markers between segments": "FFD8 FF01 FFD0 FFD7 FFD9",
|
||||
"restart markers and stuffed bytes inside a scan": "FFD8 FFDA 0002 11 FF00 22 FFD0 33 FFD7 44 FFD9",
|
||||
"a scan cut off right after a 0xFF byte": "FFD8 FFDA 0002 11 22 FF",
|
||||
"a scan cut off right after a stuffed byte": "FFD8 FFDA 0002 11 FF00",
|
||||
"an Adobe APP14 segment": "FFD8 FFEE 0007 41646F6265 FFD9",
|
||||
"an ICC profile in APP2": "FFD8 FFE2 000E 4943435F50524F46494C4500 FFD9",
|
||||
"a quantisation table, which is not metadata at all": "FFD8 FFDB 0003 00 FFD9",
|
||||
}
|
||||
for name, value := range kept {
|
||||
t.Run("Keeps "+name, func(t *testing.T) {
|
||||
data := hexBytes(t, value)
|
||||
|
||||
stripped, err := stripper.Strip("image/jpeg", data)
|
||||
|
||||
if err != nil || !bytes.Equal(stripped, data) {
|
||||
t.Errorf("expected the bytes back unchanged, got % X, %v", stripped, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
removed := map[string]string{
|
||||
"fill bytes before a marker": "FFD8 FFFFFF FFD9",
|
||||
"an empty comment": "FFD8 FFFE 0002 FFD9",
|
||||
"an APP3 segment": "FFD8 FFE3 0003 00 FFD9",
|
||||
"an APP12 segment": "FFD8 FFEC 0003 00 FFD9",
|
||||
"an APP15 segment": "FFD8 FFEF 0003 00 FFD9",
|
||||
"a FlashPix APP2 segment": "FFD8 FFE2 0006 46505852 FFD9",
|
||||
"an XMP APP1 segment": "FFD8 FFE1 0006 68747470 FFD9",
|
||||
}
|
||||
for name, value := range removed {
|
||||
t.Run("Drops "+name, func(t *testing.T) {
|
||||
stripped, err := stripper.Strip("image/jpeg", hexBytes(t, value))
|
||||
|
||||
if err != nil || !bytes.Equal(stripped, []byte{0xFF, 0xD8, 0xFF, 0xD9}) {
|
||||
t.Errorf("expected only SOI and EOI, got % X, %v", stripped, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
refused := map[string]string{
|
||||
"only three bytes": "FFD8 FF",
|
||||
"fill bytes up to the end": "FFD8 FFFF",
|
||||
"a segment length cut in half": "FFD8 FFE1 00",
|
||||
"a segment whose length runs past EOF": "FFD8 FFE1 0004 00",
|
||||
"no EOI after the header": "FFD8 FFDB 0003 00",
|
||||
}
|
||||
for name, value := range refused {
|
||||
t.Run("Refuses "+name, func(t *testing.T) {
|
||||
if _, err := stripper.Strip("image/jpeg", hexBytes(t, value)); !errors.Is(err, domainImage.ErrImageUnreadable) {
|
||||
t.Errorf("expected ErrImageUnreadable, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataStripperReadsOnlyAWellFormedOrientation(t *testing.T) {
|
||||
stripper := imaging.NewMetadataStripper()
|
||||
valid := exifTIFF(binary.BigEndian, 8)
|
||||
ifd := int(binary.BigEndian.Uint32(valid[4:]))
|
||||
withValue := func(value uint16) []byte {
|
||||
tiff := bytes.Clone(valid)
|
||||
binary.BigEndian.PutUint16(tiff[ifd+2+12+8:], value)
|
||||
return tiff
|
||||
}
|
||||
withType := func(kind uint16) []byte {
|
||||
tiff := bytes.Clone(valid)
|
||||
binary.BigEndian.PutUint16(tiff[ifd+2+12+2:], kind)
|
||||
return tiff
|
||||
}
|
||||
withIFD := func(offset uint32) []byte {
|
||||
tiff := bytes.Clone(valid)
|
||||
binary.BigEndian.PutUint32(tiff[4:], offset)
|
||||
return tiff
|
||||
}
|
||||
withCount := func(count uint16) []byte {
|
||||
tiff := bytes.Clone(valid)
|
||||
binary.BigEndian.PutUint16(tiff[ifd:], count)
|
||||
return tiff[:ifd+2+12]
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
tiff []byte
|
||||
want uint16
|
||||
}{
|
||||
{"a valid big-endian orientation", valid, 8},
|
||||
{"a valid little-endian orientation", exifTIFF(binary.LittleEndian, 5), 5},
|
||||
{"the smallest orientation that needs rotating", withValue(2), 2},
|
||||
{"an orientation of 0", withValue(0), 0},
|
||||
{"an orientation of 9", withValue(9), 0},
|
||||
{"an orientation stored as a LONG", withType(4), 0},
|
||||
{"a TIFF shorter than its header", valid[:7], 0},
|
||||
{"an unknown byte order", append([]byte("XX"), valid[2:]...), 0},
|
||||
{"a wrong magic number", append([]byte("MM\x00\x2B"), valid[4:]...), 0},
|
||||
{"an IFD offset inside the header", withIFD(4), 0},
|
||||
{"an IFD offset on the last byte", withIFD(uint32(len(valid) - 1)), 0},
|
||||
{"an IFD offset two bytes before the end", withIFD(uint32(len(valid) - 2)), 0},
|
||||
{"an entry count larger than the entries present", withCount(3), 0},
|
||||
{"a TIFF cut inside the orientation entry", valid[:ifd+2+12+11], 0},
|
||||
{"a TIFF that ends right after the orientation entry", valid[:ifd+2+24], 8},
|
||||
{"an IFD that starts right after the header", hexBytes(t, "4D4D002A 00000008 0001 0112 0003 00000001 0003 0000 00000000"), 3},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stripped, err := stripper.Strip("image/jpeg", jpegWithExif(tc.tiff))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hasOrientation := bytes.Contains(stripped, []byte("Exif\x00\x00"))
|
||||
if tc.want == 0 && hasOrientation {
|
||||
t.Errorf("expected no orientation kept, got % X", stripped)
|
||||
}
|
||||
if tc.want != 0 && !bytes.Contains(stripped, orientationOnlyEntry(tc.want)) {
|
||||
t.Errorf("expected orientation %d kept, got % X", tc.want, stripped)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataStripperPNGStructure(t *testing.T) {
|
||||
stripper := imaging.NewMetadataStripper()
|
||||
plain := encodedPNG(t)
|
||||
iend := plain[len(plain)-12:]
|
||||
body := plain[:len(plain)-12]
|
||||
|
||||
t.Run("Keeps an empty ancillary chunk it does not know", func(t *testing.T) {
|
||||
data := append(append(bytes.Clone(body), pngChunkBytes("abCd", nil)...), iend...)
|
||||
|
||||
stripped, err := stripper.Strip("image/png", data)
|
||||
|
||||
if err != nil || !bytes.Equal(stripped, data) {
|
||||
t.Errorf("expected the bytes back unchanged, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Refuses a chunk one byte longer than the file", func(t *testing.T) {
|
||||
data := bytes.Clone(plain)
|
||||
binary.BigEndian.PutUint32(data[len(data)-12:], 1)
|
||||
|
||||
if _, err := stripper.Strip("image/png", data); !errors.Is(err, domainImage.ErrImageUnreadable) {
|
||||
t.Errorf("expected ErrImageUnreadable, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Refuses an IEND missing its last CRC byte", func(t *testing.T) {
|
||||
if _, err := stripper.Strip("image/png", plain[:len(plain)-1]); !errors.Is(err, domainImage.ErrImageUnreadable) {
|
||||
t.Errorf("expected ErrImageUnreadable, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Puts a kept orientation right after IHDR even when IHDR is followed by other chunks", func(t *testing.T) {
|
||||
afterIHDR := 8 + 8 + 13 + 4
|
||||
data := append(bytes.Clone(plain[:afterIHDR]), pngChunkBytes("gAMA", []byte{0, 0, 0xB1, 0x8F})...)
|
||||
data = append(data, pngChunkBytes("eXIf", exifTIFF(binary.LittleEndian, 6))...)
|
||||
data = append(data, plain[afterIHDR:]...)
|
||||
|
||||
stripped, err := stripper.Strip("image/png", data)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(stripped[afterIHDR+4:afterIHDR+8]) != "eXIf" || string(stripped[afterIHDR+12+26+4:afterIHDR+12+26+8]) != "gAMA" {
|
||||
t.Errorf("expected eXIf then gAMA after IHDR")
|
||||
}
|
||||
if _, err := png.Decode(bytes.NewReader(stripped)); err != nil {
|
||||
t.Errorf("the stripped PNG does not decode: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Refuses a rotated PNG with no IHDR to put the orientation after", func(t *testing.T) {
|
||||
data := append(bytes.Clone(pngSignatureBytes), pngChunkBytes("eXIf", exifTIFF(binary.BigEndian, 6))...)
|
||||
data = append(data, pngChunkBytes("IEND", nil)...)
|
||||
|
||||
if _, err := stripper.Strip("image/png", data); !errors.Is(err, domainImage.ErrImageUnreadable) {
|
||||
t.Errorf("expected ErrImageUnreadable, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var pngSignatureBytes = []byte("\x89PNG\r\n\x1a\n")
|
||||
|
||||
func riffChunkKinds(t *testing.T, data []byte) []string {
|
||||
t.Helper()
|
||||
if int(binary.LittleEndian.Uint32(data[4:]))+8 != len(data) {
|
||||
t.Fatalf("RIFF size %d does not match %d bytes", binary.LittleEndian.Uint32(data[4:]), len(data)-8)
|
||||
}
|
||||
var kinds []string
|
||||
for pos := 12; pos < len(data); {
|
||||
size := int(binary.LittleEndian.Uint32(data[pos+4:]))
|
||||
kinds = append(kinds, string(data[pos:pos+4]))
|
||||
pos += 8 + size + size%2
|
||||
if pos > len(data) {
|
||||
t.Fatalf("chunk %s runs past the end", kinds[len(kinds)-1])
|
||||
}
|
||||
}
|
||||
return kinds
|
||||
}
|
||||
|
||||
func TestMetadataStripperWebPStructure(t *testing.T) {
|
||||
stripper := imaging.NewMetadataStripper()
|
||||
|
||||
t.Run("The rotated output is a well-formed chain of chunks", func(t *testing.T) {
|
||||
stripped, err := stripper.Strip("image/webp", webpWithMetadata(t, 6))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(riffChunkKinds(t, stripped), ","); got != "VP8X,VP8L,EXIF" {
|
||||
t.Errorf("expected VP8X,VP8L,EXIF, got %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An upright output keeps only the image chunks", func(t *testing.T) {
|
||||
stripped, err := stripper.Strip("image/webp", webpWithMetadata(t, 1))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(riffChunkKinds(t, stripped), ","); got != "VP8X,VP8L" {
|
||||
t.Errorf("expected VP8X,VP8L, got %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Keeps an empty chunk it does not know at the very end", func(t *testing.T) {
|
||||
original := webpWithMetadata(t, 1)
|
||||
declared := int(binary.LittleEndian.Uint32(original[4:])) + 8
|
||||
data := append(bytes.Clone(original[:declared]), riffChunkBytes("ABCD", nil)...)
|
||||
binary.LittleEndian.PutUint32(data[4:], uint32(len(data)-8))
|
||||
|
||||
stripped, err := stripper.Strip("image/webp", data)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(riffChunkKinds(t, stripped), ","); got != "VP8X,VP8L,ABCD" {
|
||||
t.Errorf("expected VP8X,VP8L,ABCD, got %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Refuses an odd-sized chunk missing its padding byte", func(t *testing.T) {
|
||||
original := webpWithMetadata(t, 1)
|
||||
declared := int(binary.LittleEndian.Uint32(original[4:])) + 8
|
||||
data := append(bytes.Clone(original[:declared]), "ABCD\x01\x00\x00\x00X"...)
|
||||
binary.LittleEndian.PutUint32(data[4:], uint32(len(data)-8))
|
||||
|
||||
if _, err := stripper.Strip("image/webp", data); !errors.Is(err, domainImage.ErrImageUnreadable) {
|
||||
t.Errorf("expected ErrImageUnreadable, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user