package org import ( "bytes" "encoding/base64" "image" "image/color" "image/jpeg" "image/png" "strings" "testing" ) // pngOf builds a real PNG of the requested size so the tests exercise the actual decoder rather than a // fixture that might drift from what the decoder accepts. func pngOf(t *testing.T, w, h int) []byte { t.Helper() img := image.NewRGBA(image.Rect(0, 0, w, h)) img.Set(0, 0, color.RGBA{R: 1, G: 2, B: 3, A: 255}) var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { t.Fatalf("encode png: %v", err) } return buf.Bytes() } func jpegOf(t *testing.T, w, h int) []byte { t.Helper() img := image.NewRGBA(image.Rect(0, 0, w, h)) var buf bytes.Buffer if err := jpeg.Encode(&buf, img, nil); err != nil { t.Fatalf("encode jpeg: %v", err) } return buf.Bytes() } // validate mirrors exactly what SetLogo does before it touches the database, so these tests can prove the // validation without a pool. If SetLogo changes, this must change with it. func validate(raw []byte) error { if len(raw) == 0 { return ErrLogoFormat } if len(raw) > MaxLogoBytes { return ErrLogoTooLarge } cfg, format, err := image.DecodeConfig(bytes.NewReader(raw)) if err != nil { return ErrLogoFormat } if _, ok := formatMIME[format]; !ok { return ErrLogoFormat } if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > MaxLogoDimension || cfg.Height > MaxLogoDimension { return ErrLogoTooBig } return nil } // TestRealImagesAreAccepted. Both permitted formats must round trip. func TestRealImagesAreAccepted(t *testing.T) { if err := validate(pngOf(t, 64, 64)); err != nil { t.Errorf("a real png was rejected: %v", err) } if err := validate(jpegOf(t, 64, 64)); err != nil { t.Errorf("a real jpeg was rejected: %v", err) } } // TestHostileUploadsAreRejected is the security proof. Every case here is a real attack shape: a payload // whose declared type or file extension claims one thing while the bytes are something else. The server // never trusts the declared type, so all of them must fail on content. func TestHostileUploadsAreRejected(t *testing.T) { cases := []struct { name string body []byte }{ { // The important one. An SVG served from the application origin can carry a script element and // become stored cross site scripting against every user in the organization. name: "svg with a script payload", body: []byte(``), }, { name: "html document renamed to an image", body: []byte(`
`), }, { // A polyglot: valid PNG magic bytes followed by markup, betting the server checks only the // first eight bytes. DecodeConfig parses the header structure, so a truncated PNG fails. name: "png magic bytes then html", body: append([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}, []byte(``)...), }, {name: "empty body", body: []byte{}}, {name: "plain text", body: []byte("not an image at all")}, { name: "gif is a real image but is not permitted", body: mustDecodeB64(t, "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"), }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if err := validate(c.body); err == nil { t.Fatalf("%s was ACCEPTED, it must be rejected", c.name) } }) } } // TestOversizeIsRejectedBeforeDecoding proves the cheap length check runs first, so a hostile upload // never reaches the decoder. func TestOversizeIsRejectedBeforeDecoding(t *testing.T) { big := make([]byte, MaxLogoBytes+1) if err := validate(big); err != ErrLogoTooLarge { t.Errorf("oversize error = %v, want ErrLogoTooLarge", err) } } // TestDimensionBombIsRejected. DecodeConfig reads only the header, so a small file can declare enormous // dimensions. Without the bound a downstream resize would try to allocate that much memory. func TestDimensionBombIsRejected(t *testing.T) { over := pngOf(t, MaxLogoDimension+1, 4) if err := validate(over); err != ErrLogoTooBig { t.Errorf("oversized dimension error = %v, want ErrLogoTooBig", err) } edge := pngOf(t, MaxLogoDimension, 4) if err := validate(edge); err != nil { t.Errorf("a logo exactly at the dimension bound was rejected: %v", err) } } // TestFormatAllowlistIsTheAuthority pins that adding a format is a deliberate edit to the map, not a side // effect of which decoders happen to be linked into the binary. func TestFormatAllowlistIsTheAuthority(t *testing.T) { if len(formatMIME) != 2 { t.Fatalf("format allowlist has %d entries, want exactly png and jpeg", len(formatMIME)) } for _, want := range []string{"png", "jpeg"} { if _, ok := formatMIME[want]; !ok { t.Errorf("format %q missing from the allowlist", want) } } for _, forbidden := range []string{"gif", "svg", "webp", "bmp", "tiff"} { if _, ok := formatMIME[forbidden]; ok { t.Errorf("format %q is in the allowlist and must not be", forbidden) } } } // TestETagIsContentAddressed. Deriving the validator from the bytes means a rollback to a previous logo // still busts the cache, which a timestamp based validator would not. func TestETagIsContentAddressed(t *testing.T) { a, b := pngOf(t, 8, 8), pngOf(t, 9, 9) if etagFor(a) == etagFor(b) { t.Error("different images produced the same etag") } if etagFor(a) != etagFor(a) { t.Error("the same image produced different etags") } if !strings.HasPrefix(etagFor(a), `"`) || !strings.HasSuffix(etagFor(a), `"`) { t.Errorf("etag %s is not a quoted string as HTTP requires", etagFor(a)) } } // TestMaxLogoBytesMatchesTheMigration guards the two limits drifting apart. The database CHECK in // migration 0046 is the last line of defense and must agree with the application. func TestMaxLogoBytesMatchesTheMigration(t *testing.T) { if MaxLogoBytes != 524288 { t.Errorf("MaxLogoBytes = %d but migration 0046 checks octet_length <= 524288", MaxLogoBytes) } if MaxDisplayName != 120 { t.Errorf("MaxDisplayName = %d but migration 0046 checks length between 1 and 120", MaxDisplayName) } } func mustDecodeB64(t *testing.T, s string) []byte { t.Helper() b, err := base64.StdEncoding.DecodeString(s) if err != nil { t.Fatalf("decode fixture: %v", err) } return b }