krz/skunky-art

Alternative privacy frontend for DeviantArt.

clone: git clone https://gitbay.org/krz/skunky-art.git

v1.3.8: app/cache_test.go · raw

  1package app
  2
  3import (
  4	"bytes"
  5	"net/http/httptest"
  6	"net/url"
  7	"sync"
  8	"testing"
  9)
 10
 11// resetMemCache empties the in-memory cache so each test starts clean.
 12func resetMemCache() {
 13	mx.Lock()
 14	defer mx.Unlock()
 15	tempFS = make(map[[20]byte]*file)
 16}
 17
 18func key(b byte) [20]byte {
 19	var k [20]byte
 20	k[0] = b
 21	return k
 22}
 23
 24// TestBuildMediaURLRejectsForgedSubdomain is the regression test for the SSRF in
 25// the media proxy: subdomain reaches us percent-decoded from the request path,
 26// so it can carry "@", "#", "?" and "/" — every character that ends a host. When
 27// the URL was built by concatenation, each of these reparsed as a host the
 28// caller chose. The label is the host, so it has to be rejected, not escaped.
 29func TestBuildMediaURLRejectsForgedSubdomain(t *testing.T) {
 30	// The path a request for /media/file/<subdomain>/f.jpg would decode to.
 31	for _, subdomain := range []string{
 32		"x@attacker.example#",     // userinfo + fragment: host is attacker.example
 33		"x@attacker.example/",     // userinfo, host terminated by the slash
 34		"x@127.0.0.1:8080/",       // the same, aimed inside the instance's network
 35		"x@[::1]:8080/",           // IPv6 loopback
 36		"attacker.example#",       // fragment alone truncates to images-wixmp-attacker.example
 37		"attacker.example?",       // query does the same
 38		"a/../../secret",          // slashes escape the label entirely
 39		"a\\attacker.example",     // backslash, which some parsers fold to "/"
 40		"a.wixmp.com.attacker.eu", // dots: a label may not contain them
 41		"",                        // empty label
 42	} {
 43		if got, ok := buildMediaURL(subdomain, "f/x.jpg", ""); ok {
 44			t.Errorf("subdomain %q: accepted and built %q, want rejected", subdomain, got)
 45		}
 46	}
 47}
 48
 49// TestBuildMediaURLKeepsHostOnWixmp is the property that actually matters: for
 50// anything accepted, the host the client ends up talking to is the CDN.
 51func TestBuildMediaURLKeepsHostOnWixmp(t *testing.T) {
 52	got, ok := buildMediaURL("ed30a86b-8c4c-a887", "f/x.jpg", "abc")
 53	if !ok {
 54		t.Fatal("a plain hex-and-dash label was rejected, want accepted")
 55	}
 56
 57	u, err := url.Parse(got)
 58	if err != nil {
 59		t.Fatalf("built an unparseable URL %q: %v", got, err)
 60	}
 61	if u.Host != "images-wixmp-ed30a86b-8c4c-a887.wixmp.com" {
 62		t.Errorf("host is %q, want the wixmp CDN", u.Host)
 63	}
 64	if u.User != nil {
 65		t.Errorf("URL carries userinfo %v, want none", u.User)
 66	}
 67	if u.Query().Get("token") != "abc" {
 68		t.Errorf("token is %q, want abc", u.Query().Get("token"))
 69	}
 70}
 71
 72// TestBuildMediaURLEscapesPath checks that the path cannot end the URL early and
 73// smuggle in a query or fragment of the caller's choosing.
 74func TestBuildMediaURLEscapesPath(t *testing.T) {
 75	got, ok := buildMediaURL("ed30a86b", "f/x.jpg#frag?q=1", "")
 76	if !ok {
 77		t.Fatal("a plain label was rejected, want accepted")
 78	}
 79
 80	u, err := url.Parse(got)
 81	if err != nil {
 82		t.Fatalf("built an unparseable URL %q: %v", got, err)
 83	}
 84	if u.Fragment != "" {
 85		t.Errorf("path opened a fragment %q, want it escaped into the path", u.Fragment)
 86	}
 87	if u.RawQuery != "" {
 88		t.Errorf("path opened a query %q, want it escaped into the path", u.RawQuery)
 89	}
 90	if u.Path != "/f/x.jpg#frag?q=1" {
 91		t.Errorf("path is %q, want it preserved verbatim", u.Path)
 92	}
 93}
 94
 95// TestDownloadAndSendMediaRejectsForgedSubdomain drives the handler itself, to
 96// pin down that a forged label is refused before any fetch is attempted rather
 97// than merely being rejected by the helper. Proxying is enabled here, so the
 98// pre-fix handler would have reached the network on this input.
 99func TestDownloadAndSendMediaRejectsForgedSubdomain(t *testing.T) {
100	proxy := CFG.Proxy
101	CFG.Proxy = true
102	defer func() { CFG.Proxy = proxy }()
103
104	w := httptest.NewRecorder()
105	s := skunkyart{Writer: w, Host: "http://localhost", Args: url.Values{}}
106	s.DownloadAndSendMedia("x@127.0.0.1:8080/", "f/x.jpg")
107
108	if w.Code != 400 {
109		t.Errorf("status is %d, want 400 for a forged subdomain", w.Code)
110	}
111}
112
113// TestMemCacheConcurrentAccess hammers the in-memory cache from many goroutines
114// while the janitor ages it, which is what a media flood does on an instance
115// with memcache enabled.
116//
117// This is the regression test for the readers that touched tempFS without
118// holding mx: concurrently with the janitor's delete that is a concurrent map
119// read and map write, which the runtime reports as a fatal error that no
120// recover can catch. Run under -race to also catch the unsynchronised field
121// access that does not happen to trip the map check.
122func TestMemCacheConcurrentAccess(t *testing.T) {
123	resetMemCache()
124	defer resetMemCache()
125
126	const workers, rounds = 24, 200
127	body := []byte("not-really-an-image")
128
129	var wg sync.WaitGroup
130	for w := range workers {
131		wg.Go(func() {
132			for i := range rounds {
133				// Overlapping keys, so goroutines contend for the same entries.
134				k := key(byte((w + i) % 8)) //nolint:gosec // G115: (w+i)%8 is 0-7
135				memPut(k, body)
136				memGet(k)
137			}
138		})
139	}
140
141	// Age the cache underneath the readers and writers: this is the delete that
142	// the old per-entry goroutines raced against.
143	wg.Go(func() {
144		for range rounds {
145			ageMemCache()
146		}
147	})
148
149	wg.Wait()
150}
151
152// TestMemGetReturnsStoredBody covers the plain hit and miss paths.
153func TestMemGetReturnsStoredBody(t *testing.T) {
154	resetMemCache()
155	defer resetMemCache()
156
157	k := key(1)
158	if got := memGet(k); got != nil {
159		t.Fatalf("empty cache: got %q, want nil", got)
160	}
161
162	want := []byte("body")
163	memPut(k, want)
164
165	got := memGet(k)
166	if !bytes.Equal(got, want) {
167		t.Fatalf("after put: got %q, want %q", got, want)
168	}
169}
170
171// TestMemPutIgnoresEmptyBody stops a failed fetch from caching a zero-length
172// image that would then be served to everyone until it aged out.
173func TestMemPutIgnoresEmptyBody(t *testing.T) {
174	resetMemCache()
175	defer resetMemCache()
176
177	k := key(2)
178	memPut(k, nil)
179	memPut(k, []byte{})
180
181	if got := memGet(k); got != nil {
182		t.Fatalf("empty body was cached: got %q, want nil", got)
183	}
184}
185
186// TestAgeMemCacheEvicts checks that a cold entry is dropped while a hot one
187// survives, since that scoring is the only bound on the cache's memory use.
188func TestAgeMemCacheEvicts(t *testing.T) {
189	resetMemCache()
190	defer resetMemCache()
191
192	cold, hot := key(3), key(4)
193	memPut(cold, []byte("cold"))
194	memPut(hot, []byte("hot"))
195
196	// A hit raises the hot entry's score above zero.
197	memGet(hot)
198
199	ageMemCache()
200
201	if got := memGet(cold); got != nil {
202		t.Errorf("cold entry survived aging: got %q, want nil", got)
203	}
204	if got := memGet(hot); got == nil {
205		t.Error("hot entry was evicted after a hit, want it kept")
206	}
207}