Files
remark42/backend/app/store/image/image_test.go
T
Umputun aca0cff399 fix: IPv6 address truncation and image proxy SSRF vulnerabilities
Replace strings.Split(RemoteAddr, ":") with net.SplitHostPort for correct
IPv6 address extraction in vote deduplication and comment IP tracking.

Harden image proxy: add SSRF-safe transport blocking private/reserved IPs
at connection time with DNS rebinding protection, sanitize error messages
to prevent information leakage, add response size limit via io.LimitReader.

Fix shadowed error variables in BlockedUsers, SetTitle, and Delete methods.
Exclude gosec taint analysis false positives at linter config level.
2026-02-28 04:13:07 -06:00

287 lines
9.9 KiB
Go

package image
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"image"
"io"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestService_SaveAndLoad(t *testing.T) {
store := StoreMock{
SaveFunc: func(string, []byte) error {
return nil
},
LoadFunc: func(string) ([]byte, error) {
return nil, nil
},
}
svc := NewService(&store, ServiceParams{MaxSize: 1500, MaxWidth: 32, MaxHeight: 32})
err := svc.SaveWithID("test_id", gopherPNG())
assert.NoError(t, err)
assert.Equal(t, 1, len(store.SaveCalls()))
assert.Equal(t, "test_id", store.SaveCalls()[0].ID)
img, err := svc.Load("test_id")
assert.NoError(t, err)
assert.Nil(t, img)
assert.Equal(t, 1, len(store.LoadCalls()))
assert.Equal(t, "test_id", store.LoadCalls()[0].ID)
}
func TestService_Resize(t *testing.T) {
img, err := readAndValidateImage(gopherPNG(), 1500)
assert.NoError(t, err)
assert.Equal(t, 1462, len(img))
img = resize(img, 32, 32)
assert.Equal(t, 1135, len(img))
}
func TestService_ResizeJpeg(t *testing.T) {
fh, err := os.Open("testdata/circles.jpg")
defer func() { assert.NoError(t, fh.Close()) }()
assert.NoError(t, err)
img, err := readAndValidateImage(fh, 32000)
assert.NoError(t, err)
assert.InDelta(t, 16756, len(img), 100)
img = resize(img, 400, 300)
assert.InDelta(t, 10913, len(img), 100)
}
func TestService_SaveTooLarge(t *testing.T) {
svc := Service{ServiceParams: ServiceParams{ImageAPI: "/blah/"}}
svc.MaxSize = 2000
_, err := svc.Save("user2", io.MultiReader(gopherPNG(), gopherPNG()))
assert.Error(t, err)
assert.Contains(t, err.Error(), "is too large")
err = svc.SaveWithID("test_id", io.MultiReader(gopherPNG(), gopherPNG()))
assert.Error(t, err)
assert.Contains(t, err.Error(), "is too large")
}
func TestService_WrongFormat(t *testing.T) {
svc := Service{ServiceParams: ServiceParams{ImageAPI: "/blah/"}}
_, err := svc.Save("user1", strings.NewReader("blah blah bad image"))
assert.Error(t, err)
}
func TestService_ExtractPictures(t *testing.T) {
svc := Service{ServiceParams: ServiceParams{ImageAPI: "/blah/", ProxyAPI: "/non_existent"}}
html := `blah <img src="/blah/user1/pic1.png"/> foo
<img src="/blah/user2/pic3.png"/> xyz <p>123</p> <img src="/pic3.png"/> <img src="https://i.ibb.co/0cqqqnD/ezgif-5-3b07b6b97610.png" alt="">`
ids := svc.ExtractPictures(html)
require.Equal(t, 2, len(ids), "two images")
assert.Equal(t, "user1/pic1.png", ids[0])
assert.Equal(t, "user2/pic3.png", ids[1])
svc = Service{ServiceParams: ServiceParams{ImageAPI: "https://remark42.radio-t.com/api/v1/picture/", ProxyAPI: "https://remark42.radio-t.com/api/v1/img"}}
html = `<p>TLDR: такое в go пока правильно посчитать трудно. То, что они считают это общее количество go packages в коде.
</p>\n\n<p>Пакеты в го это средство организации кода, они могут быть связанны друг с другом в рамках одной библиотеки (модуля).
Например одна из моих вот так выглядит на libraries.io:</p>\n\n
<p><img src="https://remark42.radio-t.com/api/v1/picture/github_ef0f706a79cc24b17bbbb374cd234a691d034128/bjttt8ahajfmrhsula10.png" alt="bjtr0-201906-08110846-i324c.png"/></p>\n\n<p>
По форме все верно, это все packages, но по сути это все одна библиотека организованная таким образом. При ее импорте, например посредством go mod, она выглядит как один модуль, т.е.
<code>github.com/go-pkgz/auth v0.5.2</code>.</p>\n`
ids = svc.ExtractPictures(html)
require.Equal(t, 1, len(ids), "one image in")
assert.Equal(t, "github_ef0f706a79cc24b17bbbb374cd234a691d034128/bjttt8ahajfmrhsula10.png", ids[0])
// proxied image
html = `<img src="https://remark42.radio-t.com/api/v1/img?src=aHR0cHM6Ly9ob21lcGFnZXMuY2FlLndpc2MuZWR1L35lY2U1MzMvaW1hZ2VzL2JvYXQucG5n" alt="cat.png">`
ids = svc.ExtractPictures(html)
require.Equal(t, 1, len(ids), "one image in")
assert.Equal(t, "cached_images/12318fbd4c55e9d177b8b5ae197bc89c5afd8e07-a41fcb00643f28d700504256ec81cbf2e1aac53e", ids[0])
require.Empty(t, svc.ExtractNonProxiedPictures(html), "no non-proxied images expected to be found")
// bad url
html = `<img src=" https://remark42.radio-t.com/api/v1/img">`
ids = svc.ExtractPictures(html)
require.Empty(t, ids)
// bad src
html = `<img src="https://remark42.radio-t.com/api/v1/img?src=bad">`
ids = svc.ExtractPictures(html)
require.Empty(t, ids)
// good src with bad content
badURL := base64.URLEncoding.EncodeToString([]byte(" http://foo.bar"))
html = fmt.Sprintf(`<img src="https://remark42.radio-t.com/api/v1/img?src=%s">`, badURL)
ids = svc.ExtractPictures(html)
require.Empty(t, ids)
}
func TestService_Cleanup(t *testing.T) {
store := StoreMock{
CleanupFunc: func(context.Context, time.Duration) error {
return nil
},
}
svc := NewService(&store, ServiceParams{EditDuration: 20 * time.Millisecond})
// cancel context after 2.1 cleanup TTLs
ctx, cancel := context.WithTimeout(context.Background(), svc.EditDuration/100*15*21)
defer cancel()
svc.Cleanup(ctx)
assert.Equal(t, 2, len(store.CleanupCalls()))
}
func TestService_Submit(t *testing.T) {
store := StoreMock{
CommitFunc: func(string) error { return nil },
ResetCleanupTimerFunc: func(string) error { return nil },
}
svc := NewService(&store, ServiceParams{ImageAPI: "/blah/", EditDuration: time.Millisecond * 100})
svc.Submit(func() []string { return []string{"id1", "id2", "id3"} })
assert.Equal(t, 3, len(store.ResetCleanupTimerCalls()))
err := svc.Commit(func() []string { return []string{"id4", "id5"} })
assert.NoError(t, err)
svc.Submit(func() []string { return []string{"id6", "id7"} })
assert.Equal(t, 5, len(store.ResetCleanupTimerCalls()))
svc.Submit(nil)
assert.Equal(t, 2, len(store.CommitCalls()))
time.Sleep(time.Millisecond * 175)
assert.Equal(t, 7, len(store.CommitCalls()))
svc.Close(context.TODO())
}
func TestService_Close(t *testing.T) {
store := StoreMock{
CommitFunc: func(string) error { return nil },
ResetCleanupTimerFunc: func(string) error { return nil },
}
svc := Service{store: &store, ServiceParams: ServiceParams{ImageAPI: "/blah/", EditDuration: time.Hour * 24}}
svc.Submit(func() []string { return []string{"id1", "id2", "id3"} })
svc.Submit(func() []string { return []string{"id4", "id5"} })
svc.Submit(nil)
assert.Equal(t, 5, len(store.ResetCleanupTimerCalls()))
svc.Close(context.TODO())
assert.Equal(t, 5, len(store.CommitCalls()))
}
func TestService_SubmitDelay(t *testing.T) {
store := StoreMock{
CommitFunc: func(string) error { return nil },
ResetCleanupTimerFunc: func(string) error {
return nil
},
}
svc := NewService(&store, ServiceParams{EditDuration: 20 * time.Millisecond})
svc.Submit(func() []string { return []string{"id1", "id2", "id3"} })
time.Sleep(150 * time.Millisecond) // let first batch to pass TTL
svc.Submit(func() []string { return []string{"id4", "id5"} })
svc.Submit(nil)
assert.Equal(t, 5, len(store.ResetCleanupTimerCalls()))
assert.Equal(t, 3, len(store.CommitCalls()))
svc.Close(context.TODO())
assert.Equal(t, 5, len(store.CommitCalls()))
}
func TestService_Info(t *testing.T) {
store := StoreMock{InfoFunc: func() (StoreInfo, error) {
return StoreInfo{}, nil
}}
svc := Service{store: &store, ServiceParams: ServiceParams{}}
info, err := svc.Info()
assert.NoError(t, err)
assert.True(t, info.FirstStagingImageTS.IsZero())
assert.Equal(t, 1, len(store.InfoCalls()))
}
func TestService_resize(t *testing.T) {
// reader is nil
resized := resize(nil, 100, 100)
assert.Nil(t, resized)
// negative limit error
resized = resize([]byte("some picture bin data"), -1, -1)
require.NotNil(t, resized)
assert.Equal(t, resized, []byte("some picture bin data"))
// decode error
resized = resize([]byte("invalid image content"), 100, 100)
assert.NotNil(t, resized)
assert.Equal(t, resized, []byte("invalid image content"))
cases := []struct {
file string
wr, hr int
}{
{"testdata/circles.png", 400, 300}, // full size: 800x600 px
{"testdata/circles.jpg", 300, 400}, // full size: 600x800 px
}
for _, c := range cases {
img, err := os.ReadFile(c.file)
require.NoError(t, err, "can't open test file %s", c.file)
// no need for resize, image dimensions are smaller than resize limit
resized = resize(img, 800, 800)
assert.NotNil(t, resized, "file %s", c.file)
assert.Equal(t, resized, img)
// resizing to half of width
resized = resize(img, 400, 400)
assert.NotNil(t, resized, "file %s", c.file)
imgRz, format, err := image.Decode(bytes.NewBuffer(resized))
assert.NoError(t, err, "file %s", c.file)
assert.Equal(t, "png", format, "file %s", c.file)
bounds := imgRz.Bounds()
assert.Equal(t, c.wr, bounds.Dx(), "file %s", c.file)
assert.Equal(t, c.hr, bounds.Dy(), "file %s", c.file)
}
}
func TestGetProportionalSizes(t *testing.T) {
tbl := []struct {
inpW, inpH int
limitW, limitH int
resW, resH int
}{
{10, 20, 50, 25, 10, 20},
{400, 200, 50, 25, 50, 25},
{100, 100, 50, 25, 25, 25},
{100, 200, 50, 25, 12, 25},
}
for i, tt := range tbl {
t.Run(strconv.Itoa(i), func(t *testing.T) {
resW, resH := getProportionalSizes(tt.inpW, tt.inpH, tt.limitW, tt.limitH)
assert.Equal(t, tt.resW, resW, "width")
assert.Equal(t, tt.resH, resH, "height")
})
}
}
func TestCachedImgID(t *testing.T) {
img, err := CachedImgID(" http://foo.com")
assert.Error(t, err)
assert.Empty(t, img)
imgURL := "http://example.org/img/1.png"
img, err = CachedImgID(imgURL)
assert.NoError(t, err)
assert.Equal(t, "cached_images/"+Sha1Str("example.org")+"-"+Sha1Str(imgURL), img)
}
func TestService_DoubleClose(*testing.T) {
store := StoreMock{}
svc := NewService(&store, ServiceParams{EditDuration: 20 * time.Millisecond})
svc.Close(context.TODO())
// second call should not result in panic
svc.Close(context.TODO())
}