From b3e59e78555b942d9c710ac211e9bac62ef15946 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 28 Sep 2026 23:49:39 +0000 Subject: [PATCH 1/2] Test that processed images carry no metadata (closes #82) Failing tests first: GPS EXIF must not survive into any output format, an image with EXIF orientation 6 must come out upright (also when only a width is asked for), and a Display P3 image must come out as sRGB with no ICC profile. Three JPEG fixtures of about 1 KB each are added under internal/imageprocessor/testdata/. Model: opus-5-5 --- .../imageprocessor_internal_test.go | 151 ++++++++++++++++++ .../imageprocessor/testdata/display-p3.jpg | Bin 0 -> 1302 bytes internal/imageprocessor/testdata/gps-exif.jpg | Bin 0 -> 1032 bytes .../imageprocessor/testdata/orientation-6.jpg | Bin 0 -> 811 bytes 4 files changed, 151 insertions(+) create mode 100644 internal/imageprocessor/testdata/display-p3.jpg create mode 100644 internal/imageprocessor/testdata/gps-exif.jpg create mode 100644 internal/imageprocessor/testdata/orientation-6.jpg diff --git a/internal/imageprocessor/imageprocessor_internal_test.go b/internal/imageprocessor/imageprocessor_internal_test.go index 21de6c5..73decd0 100644 --- a/internal/imageprocessor/imageprocessor_internal_test.go +++ b/internal/imageprocessor/imageprocessor_internal_test.go @@ -9,7 +9,9 @@ import ( "image/jpeg" "image/png" "io" + "math" "os" + "slices" "testing" "github.com/davidbyttow/govips/v2/vips" @@ -561,3 +563,152 @@ func TestImageProcessor_EncodeAVIF(t *testing.T) { encodeAndCheck(t, FormatAVIF, 85, mimeAVIF) } + +// processAndDecode runs input through Process and decodes the output with +// vips, so a test can inspect the image a client would receive. +func processAndDecode(t *testing.T, input []byte, req *Request) *vips.ImageRef { + t.Helper() + + result, err := New(Params{}).Process( + context.Background(), bytes.NewReader(input), req, + ) + if err != nil { + t.Fatalf("Process() error = %v", err) + } + + defer func() { _ = result.Content.Close() }() + + data, err := io.ReadAll(result.Content) + if err != nil { + t.Fatalf("failed to read result: %v", err) + } + + output, err := vips.NewImageFromBuffer(data) + if err != nil { + t.Fatalf("failed to decode output: %v", err) + } + + t.Cleanup(output.Close) + + return output +} + +func TestImageProcessor_StripsEXIF(t *testing.T) { + t.Parallel() + + // gps-exif.jpg carries GPS coordinates, a camera make, model and serial + // number, and a capture time. + input, err := os.ReadFile("testdata/gps-exif.jpg") + if err != nil { + t.Fatalf("failed to read test JPEG: %v", err) + } + + fixture, err := vips.NewImageFromBuffer(input) + if err != nil { + t.Fatalf("failed to decode test JPEG: %v", err) + } + + t.Cleanup(fixture.Close) + + if !slices.Contains(fixture.GetFields(), "exif-ifd3-GPSLatitude") { + t.Fatal("testdata/gps-exif.jpg has no GPS latitude") + } + + formats := []Format{ + FormatJPEG, FormatPNG, FormatWebP, FormatAVIF, FormatGIF, FormatOriginal, + } + + for _, format := range formats { + t.Run(string(format), func(t *testing.T) { + t.Parallel() + + output := processAndDecode(t, input, &Request{Format: format}) + + if output.HasExif() { + t.Errorf("output has EXIF: %v", output.GetExif()) + } + }) + } +} + +func TestImageProcessor_AppliesEXIFOrientation(t *testing.T) { + t.Parallel() + + // orientation-6.jpg is stored 16x8, red on the left and blue on the + // right, with EXIF orientation 6 (turn 90 degrees clockwise to view). + // Upright it is 8x16, red on top and blue below. + input, err := os.ReadFile("testdata/orientation-6.jpg") + if err != nil { + t.Fatalf("failed to read test JPEG: %v", err) + } + + tests := []struct { + name string + size Size + wantW int + wantH int + }{ + {name: "original size", size: Size{}, wantW: 8, wantH: 16}, + {name: "width only", size: Size{Width: 4}, wantW: 4, wantH: 8}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + output := processAndDecode(t, input, &Request{ + Size: tt.size, + Format: FormatPNG, + }) + + if output.Width() != tt.wantW || output.Height() != tt.wantH { + t.Fatalf("output is %dx%d, want %dx%d", + output.Width(), output.Height(), tt.wantW, tt.wantH) + } + + top, err := output.GetPoint(tt.wantW/2, 0) + if err != nil { + t.Fatalf("GetPoint() error = %v", err) + } + + bottom, err := output.GetPoint(tt.wantW/2, tt.wantH-1) + if err != nil { + t.Fatalf("GetPoint() error = %v", err) + } + + if top[0] <= top[2] || bottom[2] <= bottom[0] { + t.Errorf("top pixel = %v, bottom pixel = %v, want red above blue", + top, bottom) + } + }) + } +} + +func TestImageProcessor_ConvertsWideGamutToSRGB(t *testing.T) { + t.Parallel() + + // display-p3.jpg is a flat 8x8 image with the Display P3 profile + // embedded, filled with Display P3 (234, 51, 35), which is sRGB red. + input, err := os.ReadFile("testdata/display-p3.jpg") + if err != nil { + t.Fatalf("failed to read test JPEG: %v", err) + } + + output := processAndDecode(t, input, &Request{Format: FormatPNG}) + + if output.HasICCProfile() { + t.Error("output has an ICC profile") + } + + pixel, err := output.GetPoint(4, 4) + if err != nil { + t.Fatalf("GetPoint() error = %v", err) + } + + want := []float64{255, 0, 0} + for i := range want { + if math.Abs(pixel[i]-want[i]) > 5 { + t.Fatalf("pixel = %v, want within 5 of %v", pixel, want) + } + } +} diff --git a/internal/imageprocessor/testdata/display-p3.jpg b/internal/imageprocessor/testdata/display-p3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c2c49a645c4e4ddbbd40ff8577a4b300ddac751b GIT binary patch literal 1302 zcmbV~Sxl2b5XWb~vn|zFD5$8hawrl_3ROW9B~T7)JfPs6XiB+ifZ*36XyOBhqWA)M z`vhK~F(#;}7!$!;pF|Wq5*57A2Lqy@sC>?TMau&w>NLCi+wJUp^PgGyk=y~7;|d)a z02WIqr~p6!Z^8mWqQjr?%DMu1!Vs@R4jJ-ckbM}BzoU#({4tMC7U0(^091&; z;eW>PFe9mSGr`ViE1+T%AOY0}c2s2aZG2(sr%F3ib*98loOqa2H@w`bYX zkXIqkkT5~yQhJ{0<^z;d-F%9&lwvibPZP2sa~P)%roU!nIrXNz!O9|53g@SKH20_ZdVw+r)Zl1=#zJ_ukk8~`6{0Yy+xGS;3~(xWX<3?Dbg3nHfBg$@m4FnyNAqcG z&*^yYDPhrqPm)ax*r*RLs;p53#?&|aT3fng2K&PN=mnFFWa%RylVHJz5DhbIC$vr zk)y|s*VdgldFJf7hVvILHeR}V?fQ+Iw{G9Ld%yL;!?yNEkDqjOzI^ri&D*Yb?|VM> ze)`o0X7hwu;usV*P5+-;@CyBO=J^|rHDt%04O?|UE z&}8lQO_f?SenGQ3f_tbmMcLm8EB-HKzlC{pJpnCCuz9QwqM&DB&>q!pmLLBF7DY6o literal 0 HcmV?d00001 diff --git a/internal/imageprocessor/testdata/gps-exif.jpg b/internal/imageprocessor/testdata/gps-exif.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8deb6afce83a9a145b874f4e08829ebb197bfe60 GIT binary patch literal 1032 zcmbVKNlX+`5Uu}z_e{?Q-7`JIh%w44l9==?D9ptGqGBSEMNtnX!zN=ihDAXV6F>#@ zV88{JgBcXp8!B!mQCx4h0-nGPJ%PJk+Ul7OcrY-tsoez!WTj*)O)W2g!s zKR+D=05Cx;p(N;XFph%NmyipLQf;AexOjo*IgU3QjRwJLwpuM_i^UdW7i}?8jKw0lM9CpLola|P zoZBV4?XpwWiV%i-INrqbCfR1O$^UHXEsz8dL1aV%DiJ0Tbx_kmcoH$3JYx7MINo3s zOlAuv^ou|VW0Ygs)R?_X`&697+Y>z521jAgm{=owTe^CLq@2D1XVK7PvahVR)nsUB1$P_1g6tH*en=ynFBdgNKhEKY9A>`HSI^ z(XsJYuiw0VH}U?%)W=VszkL1n{l`yD7XcQPh4(+|!b2dG;}~bubP?Ks&Loac@EGjb zg~niwBhlL;$T?j-eFLT>U(uwqtaixkO7@SXOli_0$|C>OUxl^)m$GSLGrC5>%J8q1 Pm;_584Z^h94C=@)rQ+sd literal 0 HcmV?d00001 diff --git a/internal/imageprocessor/testdata/orientation-6.jpg b/internal/imageprocessor/testdata/orientation-6.jpg new file mode 100644 index 0000000000000000000000000000000000000000..da8dd4813cdae8595412591122f819a5c50cdfe8 GIT binary patch literal 811 zcmex=~kX3=9lx3_^^|42)nF1A`PJE0`SyW^iU;W?}*%AYcI^Rt{EH78X`6c6K%n9xfgp zZZ2+aUOpiKUOquSZf*fF0YPCAQBhGIesKvg5eXp?Q4x?KjLbkiEUcWYtehge+`J+r zgZ~E@1UZ1=C&0`o$iO7X$SlbC{|JLT0|O%~BN#v&4CFHb-N?qy!O6u9RIpWmfr*is znTdrNCobercG`GQH0a_772~9$CQdFfaS2H& zRW)@DO)V2sGjj_|D`yv1H+K(Dui%i-u<(e;sN|H?wDgS3tm2Z=vhs?`s^*r~w)T$B zu1S-pOr17;#>`oZ7B5-4Z25|nt2S-kvUS_`9Xod&I(+2lvEwIBp1O4T%GGPvZ`{1~ z@X_NZPoF)1@$%KjPoKYh{r3IG&tD*aF)}biyagm7K11^tFc27-SXh`@*g^hcWGV-F zT#$uT(U48ZF_1m6P*};Rkwe63;=+vwIhBorK8Pk2UE~r|F?p!^5#%+n&xrF_6Inik mdko>PTMRtRjKIia7G$tz_<3nRkNs8S1FQcYvp??q|0V#7eAk-* literal 0 HcmV?d00001 -- 2.54.0 From f5902f4241a0d9fecd310df32a0bd72a16565904 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 28 Sep 2026 23:56:21 +0000 Subject: [PATCH 2/2] Strip metadata from processed images (closes #82) Every output is exported with govips' StripMetadata, so it carries no EXIF, XMP, IPTC or ICC profile, the orig format included: it is always re-encoded, and pixa never serves the source bytes. The image is turned upright with AutoRotate right after decoding, so dropping the orientation tag does not leave it rotated, and a requested size applies to the upright image. An image with an ICC profile is converted to sRGB before export, since clients show an image with no profile as sRGB. No setting turns this off. README.md documents it. Model: opus-5-5 --- README.md | 15 +++++++++++++++ TODO.md | 7 ++++++- internal/imageprocessor/imageprocessor.go | 22 ++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 85af3f2..4765703 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,21 @@ with a private address can choose the address it is counted by through its own its own address is trusted too. Setting `trusted_proxies` to the proxy's own address closes this. +### Image Metadata + +pixa decodes and re-encodes every image it serves, and removes all metadata from +the output: EXIF (GPS position, camera make, model and serial number, capture +time, embedded thumbnail), XMP, IPTC and the ICC colour profile. This cannot be +turned off. + +- The `orig` format means the source's own format, not the source's bytes: an + `orig` image is re-encoded and stripped like any other. +- An image with an EXIF orientation is turned upright first, so it displays the + same without the tag; a requested size applies to the upright image. +- An image with an ICC profile is converted to sRGB first, since clients show an + image with no profile as sRGB. Colours outside sRGB, such as the most + saturated ones in a Display P3 photo, are clipped. + ### Source Hosts Source hosts may be allowlisted in the configuration. Non-allowlisted diff --git a/TODO.md b/TODO.md index 486c8eb..56d0745 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,12 @@ exhaustion # Completed Steps +- 2026-09-28 strip metadata from processed images (closes #82): every output is + exported with govips' `StripMetadata`, so it carries no EXIF, XMP, IPTC or ICC + profile; the image is first turned upright with `AutoRotate` (before sizes are + worked out) and, when it has an ICC profile, converted to sRGB; the `orig` + format is re-encoded and stripped like any other, as pixa never serves the + source bytes; there is no setting to keep metadata; documented in `README.md`. - 2026-09-28 rate limit the login form (closes #66): `POST /` is limited to 5 attempts per minute per client address, and an attempt over the limit is refused with 429 and a `Retry-After` header; the address is the one @@ -251,7 +257,6 @@ exhaustion # Future Steps -- P1: strip EXIF and other metadata from processed images (privacy) - P2: security - referer blacklist - per-IP rate limiting on the image routes diff --git a/internal/imageprocessor/imageprocessor.go b/internal/imageprocessor/imageprocessor.go index 32e7b7a..dd77ac1 100644 --- a/internal/imageprocessor/imageprocessor.go +++ b/internal/imageprocessor/imageprocessor.go @@ -161,6 +161,13 @@ func (p *ImageProcessor) Process( } defer img.Close() + // Turn the image upright now: encode strips the EXIF orientation tag, + // and sizes below must be worked out on the upright image. + err = img.AutoRotate() + if err != nil { + return nil, fmt.Errorf("failed to auto-rotate: %w", err) + } + // Get original dimensions origWidth := img.Width() origHeight := img.Height() @@ -404,6 +411,21 @@ func (p *ImageProcessor) encode( return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format) } + // Stripping drops the ICC profile as well, and clients show an image + // with no profile as sRGB, so convert to sRGB first. "srgb" names + // libvips' built-in profile; govips' own sRGB path variable is set on + // first use but read without a lock, so concurrent requests race on it. + if img.HasICCProfile() { + err := img.TransformICCProfileWithFallback("srgb", "srgb") + if err != nil { + return nil, fmt.Errorf("failed to convert to sRGB: %w", err) + } + } + + // Drop EXIF, XMP, IPTC and the ICC profile. govips ignores this for + // GIF, which carries none of them. + params.StripMetadata = true + output, _, err := img.Export(¶ms) if err != nil { return nil, err -- 2.54.0