package imgproxy
import (
"bytes"
"encoding/json"
"image"
"image/color"
"image/png"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func pngOf(t *testing.T, w, h int) []byte {
t.Helper()
var buf bytes.Buffer
img := image.NewRGBA(image.Rect(0, 0, w, h))
img.Set(0, 0, color.RGBA{1, 2, 3, 255})
if err := png.Encode(&buf, img); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func upstream(t *testing.T, ctype string, body []byte) (*httptest.Server, *int) {
t.Helper()
hits := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
w.Header().Set("Content-Type", ctype)
w.Write(body)
}))
t.Cleanup(srv.Close)
return srv, &hits
}
func newProxy(t *testing.T, allowPrivate bool) *Proxy {
t.Helper()
p, err := New(t.TempDir(), allowPrivate)
if err != nil {
t.Fatal(err)
}
return p
}
func get(t *testing.T, p *Proxy, path string, hdr ...string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("GET", path, nil)
for i := 0; i+1 < len(hdr); i += 2 {
req.Header.Set(hdr[i], hdr[i+1])
}
rec := httptest.NewRecorder()
p.ServeHTTP(rec, req)
return rec
}
// The proxied URL is a path ending in the image's own extension: a CDN
// caches what looks like a file and treats a query string as dynamic.
func TestProxiedURLIsACacheablePath(t *testing.T) {
p := newProxy(t, false)
u, _, _ := p.URL("https://ci.example/badge.svg?branch=main")
if !strings.HasPrefix(u, "/img/") || strings.Contains(u, "?") || !strings.HasSuffix(u, ".svg") {
t.Fatalf("proxied URL = %q", u)
}
if other, _, _ := p.URL("https://ci.example/photo.tiff"); !strings.HasSuffix(other, ".img") {
t.Errorf("an unknown extension = %q", other)
}
if bad, _, _ := p.URL("javascript:alert(1)"); bad != "" {
t.Errorf("a non-http target was signed: %q", bad)
}
}
// Only what the renderer signed is served, and a signature for one image
// does not carry another.
func TestUnsignedImagesAreRefused(t *testing.T) {
p := newProxy(t, true)
src, hits := upstream(t, "image/png", pngOf(t, 2, 2))
good, _, _ := p.URL(src.URL + "/a.png")
parts := strings.SplitN(strings.TrimPrefix(good, "/img/"), "/", 2)
other, _, _ := p.URL(src.URL + "/b.png")
swapped := "/img/" + parts[0] + "/" + strings.SplitN(strings.TrimPrefix(other, "/img/"), "/", 2)[1]
for _, path := range []string{
"/img/", "/img/forged/" + parts[1], swapped, "/img/" + parts[0] + "/not-base64!.png",
} {
if rec := get(t, p, path); rec.Code != http.StatusForbidden {
t.Errorf("%s: %d, want 403", path, rec.Code)
}
}
if *hits != 0 {
t.Fatalf("a refused request reached the upstream %d times", *hits)
}
}
// The guard runs at connect time: a correctly signed URL that points at a
// loopback address is still not fetched.
func TestPrivateAddressesAreNotFetched(t *testing.T) {
p := newProxy(t, false)
src, hits := upstream(t, "image/png", pngOf(t, 2, 2))
u, _, _ := p.URL(src.URL + "/a.png")
if rec := get(t, p, u); rec.Code != http.StatusBadGateway {
t.Fatalf("a loopback image answered %d", rec.Code)
}
if *hits != 0 {
t.Fatal("the guard let a loopback fetch through")
}
}
// An image is served with its headers, cached, and answered as an empty
// 304 to a reader that already has it.
func TestImagesAreCachedAndRevalidated(t *testing.T) {
p := newProxy(t, true)
body := pngOf(t, 8, 4)
src, hits := upstream(t, "image/png", body)
u, _, _ := p.URL(src.URL + "/badge.png")
rec := get(t, p, u)
if rec.Code != http.StatusOK || !bytes.Equal(rec.Body.Bytes(), body) {
t.Fatalf("first request: %d, %d bytes", rec.Code, rec.Body.Len())
}
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("headers: %v", rec.Header())
}
// The first response streams, so it cannot carry a hash of bytes it
// has not sent yet; the cached response every later reader gets does.
second := get(t, p, u)
etag := second.Header().Get("ETag")
if etag == "" {
t.Fatalf("a cached response carries no ETag: %v", second.Header())
}
cc := second.Header().Get("Cache-Control")
for _, want := range []string{"public", "max-age=", "s-maxage=", "stale-while-revalidate="} {
if !strings.Contains(cc, want) {
t.Errorf("Cache-Control %q lacks %q", cc, want)
}
}
if second.Code != http.StatusOK || *hits != 1 {
t.Fatalf("second request: %d after %d upstream hits", second.Code, *hits)
}
rec = get(t, p, u, "If-None-Match", etag)
if rec.Code != http.StatusNotModified || rec.Body.Len() != 0 {
t.Fatalf("a reader holding the image got %d with %d bytes", rec.Code, rec.Body.Len())
}
}
// Past its hour the cached copy is served at once and refreshed behind
// the reader, so nobody waits on the upstream after the first fetch.
func TestStaleIsServedWhileItRefreshes(t *testing.T) {
p := newProxy(t, true)
first := pngOf(t, 8, 4)
second := pngOf(t, 16, 8)
serving := first
hits := 0
src := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
w.Header().Set("Content-Type", "image/png")
w.Write(serving)
}))
defer src.Close()
u, _, _ := p.URL(src.URL + "/badge.png")
if rec := get(t, p, u); rec.Code != http.StatusOK {
t.Fatal(rec.Code)
}
serving = second
p.now = func() time.Time { return time.Now().Add(TTL + time.Minute) }
rec := get(t, p, u)
if rec.Code != http.StatusOK || !bytes.Equal(rec.Body.Bytes(), first) {
t.Fatalf("a stale read waited for the refresh or failed: %d", rec.Code)
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if m, ok := p.meta(p.base(src.URL + "/badge.png")); ok && m.Width == 16 {
break
}
time.Sleep(20 * time.Millisecond)
}
if m, _ := p.meta(p.base(src.URL + "/badge.png")); m.Width != 16 {
t.Fatalf("the background refresh did not land: %+v", m)
}
if hits < 2 {
t.Fatalf("no refresh was fetched (%d hits)", hits)
}
}
// The pixel size is recorded as the bytes go past, so the renderer can
// stop the page reflowing. PNG comes from the decoder, SVG from the
// badge's own width and height.
func TestSizesAreRecordedForTheRenderer(t *testing.T) {
p := newProxy(t, true)
src, _ := upstream(t, "image/png", pngOf(t, 120, 20))
u, w, h := p.URL(src.URL + "/badge.png")
if w != 0 || h != 0 {
t.Fatalf("a size before any fetch: %dx%d", w, h)
}
if rec := get(t, p, u); rec.Code != http.StatusOK {
t.Fatal(rec.Code)
}
if _, w, h = p.URL(src.URL + "/badge.png"); w != 120 || h != 20 {
t.Fatalf("png size = %dx%d, want 120x20", w, h)
}
svg, _ := upstream(t, "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="88" height="20"><rect/></svg>`))
su, _, _ := p.URL(svg.URL + "/b.svg")
if rec := get(t, p, su); rec.Code != http.StatusOK {
t.Fatal(rec.Code)
}
if _, w, h = p.URL(svg.URL + "/b.svg"); w != 88 || h != 20 {
t.Fatalf("svg size = %dx%d, want 88x20", w, h)
}
box, _ := upstream(t, "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 150"/>`))
bu, _, _ := p.URL(box.URL + "/c.svg")
if rec := get(t, p, bu); rec.Code != http.StatusOK {
t.Fatal(rec.Code)
}
if _, w, h = p.URL(box.URL + "/c.svg"); w != 300 || h != 150 {
t.Fatalf("viewBox size = %dx%d, want 300x150", w, h)
}
}
// Anything that is not an image is refused, and so is one past the cap.
func TestNonImagesAndOversizeAreRefused(t *testing.T) {
p := newProxy(t, true)
html, _ := upstream(t, "text/html", []byte("<script>alert(1)</script>"))
hu, _, _ := p.URL(html.URL + "/x.png")
if rec := get(t, p, hu); rec.Code != http.StatusBadGateway {
t.Errorf("html answered %d", rec.Code)
}
big, _ := upstream(t, "image/png", make([]byte, MaxBytes+1))
bu, _, _ := p.URL(big.URL + "/big.png")
rec := get(t, p, bu)
if rec.Body.Len() > MaxBytes {
t.Errorf("an oversize image was served whole (%d bytes)", rec.Body.Len())
}
if bytes.Contains(rec.Body.Bytes(), []byte("image unavailable")) {
t.Error("an error page was appended to a partly sent image")
}
if _, ok := p.meta(p.base(big.URL + "/big.png")); ok {
t.Error("an oversize image was cached")
}
}
// SVG can carry script; served here it is sandboxed.
func TestSVGIsSandboxed(t *testing.T) {
p := newProxy(t, true)
svg, _ := upstream(t, "image/svg+xml; charset=utf-8", []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"/>`))
u, _, _ := p.URL(svg.URL + "/badge.svg")
rec := get(t, p, u)
if rec.Code != http.StatusOK {
t.Fatalf("svg: %d", rec.Code)
}
if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "sandbox") || !strings.Contains(csp, "default-src 'none'") {
t.Errorf("svg csp = %q", csp)
}
}
// The key survives a restart, so a page rendered before it still loads.
func TestTheKeyPersists(t *testing.T) {
dir := t.TempDir()
a, err := New(dir, false)
if err != nil {
t.Fatal(err)
}
b, err := New(dir, false)
if err != nil {
t.Fatal(err)
}
au, _, _ := a.URL("https://example.org/x.png")
bu, _, _ := b.URL("https://example.org/x.png")
if au != bu {
t.Fatal("a second start signs differently")
}
}
// writeEntry puts one cache entry on disk: size bytes, fetched at when.
func writeEntry(t *testing.T, p *Proxy, name string, size int, when time.Time) string {
t.Helper()
base := filepath.Join(p.dir, name)
if err := os.WriteFile(base+".bin", make([]byte, size), 0o640); err != nil {
t.Fatal(err)
}
blob, err := json.Marshal(meta{Type: "image/png", ETag: `"x"`,
Fetched: when.UTC().Format(time.RFC3339)})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(base+".meta", blob, 0o640); err != nil {
t.Fatal(err)
}
return base
}
// The sweep drops what the cache cannot serve: the first version's
// metadata and the bytes beside it, bytes with no metadata, metadata with
// no bytes, an entry too old for even a stale read, and a temp file a
// dead fetch left behind. A temp file a live fetch is writing stays.
func TestSweepRemovesWhatCannotBeServed(t *testing.T) {
p := newProxy(t, false)
loose := func(name string, age time.Duration) string {
full := filepath.Join(p.dir, name)
if err := os.WriteFile(full, []byte("x"), 0o640); err != nil {
t.Fatal(err)
}
at := time.Now().Add(-age)
if err := os.Chtimes(full, at, at); err != nil {
t.Fatal(err)
}
return full
}
oldBin := loose("aaaa.bin", 0) // the first version's pair:
oldType := loose("aaaa.type", 0) // bytes and a .type, no .meta
orphanBin := loose("bbbb.bin", 0)
strayMeta := loose("cccc.meta", 0)
deadTemp := loose("fetch-dead", 2*tempAge)
liveTemp := loose("fetch-live", time.Minute)
expired := writeEntry(t, p, "dddd", 10, time.Now().Add(-(TTL + StaleFor + time.Hour)))
fresh := writeEntry(t, p, "eeee", 10, time.Now())
p.sweep()
for _, gone := range []string{oldBin, oldType, orphanBin, strayMeta, deadTemp,
expired + ".bin", expired + ".meta"} {
if _, err := os.Stat(gone); !os.IsNotExist(err) {
t.Errorf("%s survived the sweep", filepath.Base(gone))
}
}
for _, kept := range []string{liveTemp, fresh + ".bin", fresh + ".meta"} {
if _, err := os.Stat(kept); err != nil {
t.Errorf("%s was swept: %v", filepath.Base(kept), err)
}
}
}
// Past the budget the images fetched longest ago go first — a refresh
// only happens when somebody asks, so the oldest fetch is what nobody has
// wanted for longest — and the cache ends under its budget.
func TestSweepKeepsTheCacheWithinItsBudget(t *testing.T) {
p := newProxy(t, false)
p.budget = 300
now := time.Now()
writeEntry(t, p, "oldest", 200, now.Add(-3*time.Minute))
writeEntry(t, p, "middle", 200, now.Add(-2*time.Minute))
writeEntry(t, p, "newest", 200, now.Add(-1*time.Minute))
p.sweep()
if _, err := os.Stat(filepath.Join(p.dir, "newest.bin")); err != nil {
t.Errorf("the newest entry was evicted: %v", err)
}
if _, err := os.Stat(filepath.Join(p.dir, "oldest.bin")); !os.IsNotExist(err) {
t.Error("the oldest entry survived a full cache")
}
var total int64
entries, err := os.ReadDir(p.dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".bin") {
info, err := e.Info()
if err != nil {
t.Fatal(err)
}
total += info.Size()
}
}
if total > p.budget {
t.Errorf("the cache holds %d bytes, over its %d budget", total, p.budget)
}
}
// Sweeps are rate limited, so a busy forge does not walk the cache on
// every fetch.
func TestSweepIsRateLimited(t *testing.T) {
p := newProxy(t, false)
p.budget = 0 // everything is over budget
writeEntry(t, p, "aaaa", 10, time.Now())
p.maybeSweep()
if _, err := os.Stat(filepath.Join(p.dir, "aaaa.bin")); !os.IsNotExist(err) {
t.Fatal("the first sweep did not run")
}
writeEntry(t, p, "bbbb", 10, time.Now())
p.maybeSweep()
if _, err := os.Stat(filepath.Join(p.dir, "bbbb.bin")); err != nil {
t.Error("a second sweep ran inside the interval")
}
p.now = func() time.Time { return time.Now().Add(2 * sweepEvery) }
p.maybeSweep()
if _, err := os.Stat(filepath.Join(p.dir, "bbbb.bin")); !os.IsNotExist(err) {
t.Error("no sweep ran after the interval passed")
}
}
// The streamed first response carries no ETag, so no shared cache may
// keep it: an edge that did would serve everyone a copy their browser
// could not revalidate. Everything after it is public and tagged.
func TestTheStreamedResponseIsNotSharedCacheable(t *testing.T) {
p := newProxy(t, true)
src, _ := upstream(t, "image/png", pngOf(t, 4, 4))
u, _, _ := p.URL(src.URL + "/a.png")
first := get(t, p, u)
if first.Code != http.StatusOK {
t.Fatalf("first: %d", first.Code)
}
if cc := first.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") || strings.Contains(cc, "s-maxage") {
t.Errorf("a shared cache may keep the streamed response: %q", cc)
}
if first.Header().Get("ETag") != "" {
t.Error("the streamed response claims an ETag it cannot have")
}
second := get(t, p, u)
cc := second.Header().Get("Cache-Control")
if !strings.Contains(cc, "s-maxage=") || strings.Contains(cc, "no-store") {
t.Errorf("the cached response is not shared-cacheable: %q", cc)
}
if second.Header().Get("ETag") == "" {
t.Error("the cached response carries no ETag")
}
}
// A per-image lock exists only while somebody holds or waits on it. The
// map was keyed by every image ever fetched and never shrank, so a
// long-running forge carried one mutex per badge URL it had ever seen.
func TestImageLocksAreDroppedWhenReleased(t *testing.T) {
p := newProxy(t, true)
src, _ := upstream(t, "image/png", pngOf(t, 2, 2))
const n = 20
for i := 0; i < n; i++ {
u, _, _ := p.URL(src.URL + "/badge" + strings.Repeat("x", i) + ".png")
if rec := get(t, p, u); rec.Code != http.StatusOK {
t.Fatalf("FAIL: image %d answered %d", i, rec.Code)
}
}
p.mu.Lock()
left := len(p.locks)
p.mu.Unlock()
if left != 0 {
t.Fatalf("FAIL: %d locks remain after %d distinct images were served and released", left, n)
}
t.Logf("ok: no locks remain after %d distinct images", n)
}
// Dropping a lock on release must not weaken it: fetchers of one image
// still take turns, and the entry goes only when the last has left.
func TestAnImageLockStillExcludesWhileItIsShared(t *testing.T) {
p := newProxy(t, false)
const n = 50
var inside, most int
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
unlock := p.lock("same")
mu.Lock()
inside++
if inside > most {
most = inside
}
mu.Unlock()
time.Sleep(time.Millisecond)
mu.Lock()
inside--
mu.Unlock()
unlock()
}()
}
wg.Wait()
if most != 1 {
t.Fatalf("FAIL: %d fetchers held one image's lock at once", most)
}
t.Log("ok: one holder at a time")
p.mu.Lock()
left := len(p.locks)
p.mu.Unlock()
if left != 0 {
t.Fatalf("FAIL: %d locks remain after every holder released", left)
}
t.Log("ok: the lock is gone once the last holder released it")
}