Skip to content
quarry Artifact [2dbcbb4359]
Login

Artifact [2dbcbb4359]

Artifact 2dbcbb4359318f076a1ad5176610e0434ef90a23d99e13088e1adbb667487ebe:


package imgproxy

import (
	"bytes"
	"encoding/json"
	"image"
	"image/color"
	"image/png"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"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")
	}
}