Skip to content
quarry Artifact [dc3a656722]
Login

Artifact [dc3a656722]

Artifact dc3a656722ce297af3300c437f67c332883e4a5f0010254c14d7c64562c600ea:


package imgproxy

import (
	"bytes"
	"image"
	"image/color"
	"image/png"
	"net/http"
	"net/http/httptest"
	"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")
	}
}

// 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")
	}
}