detached df3b2f3

fix(graphics): harden harness image validation

commit
df3b2f325cea94ea8e345a80260439b278b18b6e
author
brice · · 2026-08-24 10:30 UTC
parent
6ac336a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +35 -4
M cmd/graphics-harness/main.go +11 -2
@@ -58,10 +58,19 @@ func scaleFrame(source *image.RGBA, scale int) (*image.RGBA, error) {
5858 return nil, fmt.Errorf("scale must be positive")
5959 }
6060 width, height := source.Bounds().Dx(), source.Bounds().Dy()
61- if uint64(width)*uint64(height)*uint64(scale)*uint64(scale) > 64<<20 {
61+ maxInt := int(^uint(0) >> 1)
62+ if width > 0 && scale > maxInt/width {
6263 return nil, fmt.Errorf("scaled frame exceeds 67108864 pixels")
6364 }
64- result := image.NewRGBA(image.Rect(0, 0, width*scale, height*scale))
65+ if height > 0 && scale > maxInt/height {
66+ return nil, fmt.Errorf("scaled frame exceeds 67108864 pixels")
67+ }
68+ scaledWidth, scaledHeight := width*scale, height*scale
69+ const maxScaledPixels = uint64(64 << 20)
70+ if scaledWidth > 0 && uint64(scaledHeight) > maxScaledPixels/uint64(scaledWidth) {
71+ return nil, fmt.Errorf("scaled frame exceeds 67108864 pixels")
72+ }
73+ result := image.NewRGBA(image.Rect(0, 0, scaledWidth, scaledHeight))
6574 for y := range height {
6675 for x := range width {
6776 pixel := source.RGBAAt(x, y)
@@ -58,10 +58,19 @@ func scaleFrame(source *image.RGBA, scale int) (*image.RGBA, error) {
58 return nil, fmt.Errorf("scale must be positive")58 return nil, fmt.Errorf("scale must be positive")
59 }59 }
60 width, height := source.Bounds().Dx(), source.Bounds().Dy()60 width, height := source.Bounds().Dx(), source.Bounds().Dy()
61- if uint64(width)*uint64(height)*uint64(scale)*uint64(scale) > 64<<20 {61+ maxInt := int(^uint(0) >> 1)
62+ if width > 0 && scale > maxInt/width {
62 return nil, fmt.Errorf("scaled frame exceeds 67108864 pixels")63 return nil, fmt.Errorf("scaled frame exceeds 67108864 pixels")
63 }64 }
64- result := image.NewRGBA(image.Rect(0, 0, width*scale, height*scale))65+ if height > 0 && scale > maxInt/height {
66+ return nil, fmt.Errorf("scaled frame exceeds 67108864 pixels")
67+ }
68+ scaledWidth, scaledHeight := width*scale, height*scale
69+ const maxScaledPixels = uint64(64 << 20)
70+ if scaledWidth > 0 && uint64(scaledHeight) > maxScaledPixels/uint64(scaledWidth) {
71+ return nil, fmt.Errorf("scaled frame exceeds 67108864 pixels")
72+ }
73+ result := image.NewRGBA(image.Rect(0, 0, scaledWidth, scaledHeight))
65 for y := range height {74 for y := range height {
66 for x := range width {75 for x := range width {
67 pixel := source.RGBAAt(x, y)76 pixel := source.RGBAAt(x, y)
M cmd/graphics-harness/main_test.go +5 -0
@@ -26,3 +26,8 @@ func TestScaleFrameRejectsInvalidScale(t *testing.T) {
2626 _, err := scaleFrame(image.NewRGBA(image.Rect(0, 0, 1, 1)), 0)
2727 require.Error(t, err)
2828 }
29+
30+func TestScaleFrameRejectsMaximumIntScale(t *testing.T) {
31+ _, err := scaleFrame(image.NewRGBA(image.Rect(0, 0, 1, 1)), int(^uint(0)>>1))
32+ require.EqualError(t, err, "scaled frame exceeds 67108864 pixels")
33+}
@@ -26,3 +26,8 @@ func TestScaleFrameRejectsInvalidScale(t *testing.T) {
26 _, err := scaleFrame(image.NewRGBA(image.Rect(0, 0, 1, 1)), 0)26 _, err := scaleFrame(image.NewRGBA(image.Rect(0, 0, 1, 1)), 0)
27 require.Error(t, err)27 require.Error(t, err)
28 }28 }
29+
30+func TestScaleFrameRejectsMaximumIntScale(t *testing.T) {
31+ _, err := scaleFrame(image.NewRGBA(image.Rect(0, 0, 1, 1)), int(^uint(0)>>1))
32+ require.EqualError(t, err, "scaled frame exceeds 67108864 pixels")
33+}
M internal/graphicsharness/render.go +6 -2
@@ -75,13 +75,17 @@ func decodeAsset(asset graphics.AssetView) (*image.RGBA, error) {
7575 encoded := asset.Encoded()
7676 switch asset.Format() {
7777 case graphics.AssetFormatPNG:
78- decoded, err := png.Decode(bytes.NewReader(encoded))
78+ config, err := png.DecodeConfig(bytes.NewReader(encoded))
7979 if err != nil {
8080 return nil, fmt.Errorf("decode asset %s: %w", asset.ID(), err)
8181 }
82- if decoded.Bounds().Dx() != width || decoded.Bounds().Dy() != height {
82+ if config.Width != width || config.Height != height {
8383 return nil, fmt.Errorf("decode asset %s: PNG dimensions do not match snapshot", asset.ID())
8484 }
85+ decoded, err := png.Decode(bytes.NewReader(encoded))
86+ if err != nil {
87+ return nil, fmt.Errorf("decode asset %s: %w", asset.ID(), err)
88+ }
8589 result := image.NewRGBA(image.Rect(0, 0, width, height))
8690 for y := range height {
8791 for x := range width {
@@ -75,13 +75,17 @@ func decodeAsset(asset graphics.AssetView) (*image.RGBA, error) {
75 encoded := asset.Encoded()75 encoded := asset.Encoded()
76 switch asset.Format() {76 switch asset.Format() {
77 case graphics.AssetFormatPNG:77 case graphics.AssetFormatPNG:
78- decoded, err := png.Decode(bytes.NewReader(encoded))78+ config, err := png.DecodeConfig(bytes.NewReader(encoded))
79 if err != nil {79 if err != nil {
80 return nil, fmt.Errorf("decode asset %s: %w", asset.ID(), err)80 return nil, fmt.Errorf("decode asset %s: %w", asset.ID(), err)
81 }81 }
82- if decoded.Bounds().Dx() != width || decoded.Bounds().Dy() != height {82+ if config.Width != width || config.Height != height {
83 return nil, fmt.Errorf("decode asset %s: PNG dimensions do not match snapshot", asset.ID())83 return nil, fmt.Errorf("decode asset %s: PNG dimensions do not match snapshot", asset.ID())
84 }84 }
85+ decoded, err := png.Decode(bytes.NewReader(encoded))
86+ if err != nil {
87+ return nil, fmt.Errorf("decode asset %s: %w", asset.ID(), err)
88+ }
85 result := image.NewRGBA(image.Rect(0, 0, width, height))89 result := image.NewRGBA(image.Rect(0, 0, width, height))
86 for y := range height {90 for y := range height {
87 for x := range width {91 for x := range width {
M internal/graphicsharness/render_test.go +13 -0
@@ -56,6 +56,19 @@ func TestRenderDecodesMetadataBearingPNG(t *testing.T) {
5656 require.Equal(t, 32, frame.Bounds().Dy())
5757 }
5858
59+func TestRenderRejectsPNGDimensionsDifferentFromSnapshot(t *testing.T) {
60+ encoded, err := base64.StdEncoding.DecodeString(pngSuiteEXIFBase64)
61+ require.NoError(t, err)
62+ scene := graphics.NewScene(graphics.Limits{})
63+ assetID, err := scene.AddAsset(graphics.AssetBlob{Format: graphics.AssetFormatPNG, Width: 31, Height: 32, Encoded: encoded})
64+ require.NoError(t, err)
65+ _, err = scene.Place(graphics.PlacementSpec{Asset: assetID, Destination: graphics.PixelRect{Width: 31, Height: 32}})
66+ require.NoError(t, err)
67+
68+ _, err = Render(scene.Snapshot(), graphics.PixelRect{Width: 31, Height: 32})
69+ require.EqualError(t, err, "decode asset a1: PNG dimensions do not match snapshot")
70+}
71+
5972 func TestRenderScalesCropsAndClips(t *testing.T) {
6073 scene := graphics.NewScene(graphics.Limits{})
6174 assetID, err := scene.AddAsset(graphics.AssetBlob{
@@ -56,6 +56,19 @@ func TestRenderDecodesMetadataBearingPNG(t *testing.T) {
56 require.Equal(t, 32, frame.Bounds().Dy())56 require.Equal(t, 32, frame.Bounds().Dy())
57 }57 }
58 58
59+func TestRenderRejectsPNGDimensionsDifferentFromSnapshot(t *testing.T) {
60+ encoded, err := base64.StdEncoding.DecodeString(pngSuiteEXIFBase64)
61+ require.NoError(t, err)
62+ scene := graphics.NewScene(graphics.Limits{})
63+ assetID, err := scene.AddAsset(graphics.AssetBlob{Format: graphics.AssetFormatPNG, Width: 31, Height: 32, Encoded: encoded})
64+ require.NoError(t, err)
65+ _, err = scene.Place(graphics.PlacementSpec{Asset: assetID, Destination: graphics.PixelRect{Width: 31, Height: 32}})
66+ require.NoError(t, err)
67+
68+ _, err = Render(scene.Snapshot(), graphics.PixelRect{Width: 31, Height: 32})
69+ require.EqualError(t, err, "decode asset a1: PNG dimensions do not match snapshot")
70+}
71+
59 func TestRenderScalesCropsAndClips(t *testing.T) {72 func TestRenderScalesCropsAndClips(t *testing.T) {
60 scene := graphics.NewScene(graphics.Limits{})73 scene := graphics.NewScene(graphics.Limits{})
61 assetID, err := scene.AddAsset(graphics.AssetBlob{74 assetID, err := scene.AddAsset(graphics.AssetBlob{

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close