krz/skunky-art

Alternative privacy frontend for DeviantArt.

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

v1.3.9: app/cache_test.go · raw

  1package app
  2
  3import (
  4	"bytes"
  5	"encoding/base64"
  6	"encoding/json"
  7	"net/http/httptest"
  8	"net/url"
  9	"strings"
 10	"sync"
 11	"testing"
 12)
 13
 14// resetMemCache empties the in-memory cache so each test starts clean.
 15func resetMemCache() {
 16	mx.Lock()
 17	defer mx.Unlock()
 18	tempFS = make(map[[20]byte]*file)
 19}
 20
 21func key(b byte) [20]byte {
 22	var k [20]byte
 23	k[0] = b
 24	return k
 25}
 26
 27// TestBuildMediaURLRejectsForgedSubdomain is the regression test for the SSRF in
 28// the media proxy: subdomain reaches us percent-decoded from the request path,
 29// so it can carry "@", "#", "?" and "/" — every character that ends a host. When
 30// the URL was built by concatenation, each of these reparsed as a host the
 31// caller chose. The label is the host, so it has to be rejected, not escaped.
 32func TestBuildMediaURLRejectsForgedSubdomain(t *testing.T) {
 33	// The path a request for /media/file/<subdomain>/f.jpg would decode to.
 34	for _, subdomain := range []string{
 35		"x@attacker.example#",     // userinfo + fragment: host is attacker.example
 36		"x@attacker.example/",     // userinfo, host terminated by the slash
 37		"x@127.0.0.1:8080/",       // the same, aimed inside the instance's network
 38		"x@[::1]:8080/",           // IPv6 loopback
 39		"attacker.example#",       // fragment alone truncates to images-wixmp-attacker.example
 40		"attacker.example?",       // query does the same
 41		"a/../../secret",          // slashes escape the label entirely
 42		"a\\attacker.example",     // backslash, which some parsers fold to "/"
 43		"a.wixmp.com.attacker.eu", // dots: a label may not contain them
 44		"",                        // empty label
 45	} {
 46		if got, ok := buildMediaURL(subdomain, "f/x.jpg", ""); ok {
 47			t.Errorf("subdomain %q: accepted and built %q, want rejected", subdomain, got)
 48		}
 49	}
 50}
 51
 52// makeMediaToken builds a JWT-shaped token whose obj carries the given blur
 53// value, mirroring the wixmp media tokens DeviantArt signs. Pass a ">=N" string
 54// for a blur-constrained (mature) token, or nil for an unconstrained one.
 55func makeMediaToken(t *testing.T, blur any) string {
 56	t.Helper()
 57	claims := map[string]any{
 58		"obj": [][]map[string]any{{{"path": "/f/x.png", "blur": blur}}},
 59	}
 60	payload, err := json.Marshal(claims)
 61	if err != nil {
 62		t.Fatal(err)
 63	}
 64	enc := base64.RawURLEncoding.EncodeToString
 65	return enc([]byte(`{"alg":"none"}`)) + "." + enc(payload) + ".sig"
 66}
 67
 68// TestBlurConstraint is the regression test for the mature-media 403: a token
 69// whose obj demands a blur must yield that radius, and anything else must yield
 70// 0 so the transform is left untouched.
 71func TestBlurConstraint(t *testing.T) {
 72	if got := blurConstraint(makeMediaToken(t, ">=10")); got != 10 {
 73		t.Errorf("blur-constrained token: got %d, want 10", got)
 74	}
 75	if got := blurConstraint(makeMediaToken(t, nil)); got != 0 {
 76		t.Errorf("null-blur token: got %d, want 0", got)
 77	}
 78	// Nothing parseable as a claims payload: fail open, leaving the URL alone.
 79	for _, tok := range []string{"", "not-a-jwt", "a.b", "a.!!!.c"} {
 80		if got := blurConstraint(tok); got != 0 {
 81			t.Errorf("unparseable token %q: got %d, want 0", tok, got)
 82		}
 83	}
 84}
 85
 86// TestAddBlurToTransform checks the string surgery: a blur op is inserted into a
 87// /v1/fit transform, paths without one are untouched, and an existing op is not
 88// doubled.
 89func TestAddBlurToTransform(t *testing.T) {
 90	got := addBlurToTransform("f/u/x.png/v1/fit/w_1280,h_1920/x.png", 10)
 91	if want := "f/u/x.png/v1/fit/w_1280,h_1920,blur_10/x.png"; got != want {
 92		t.Errorf("got %q, want %q", got, want)
 93	}
 94	if got := addBlurToTransform("f/u/x.gif", 10); got != "f/u/x.gif" {
 95		t.Errorf("path without a transform was modified: %q", got)
 96	}
 97	blurred := "f/u/x.png/v1/fit/w_1280,h_1920,blur_10/x.png"
 98	if got := addBlurToTransform(blurred, 10); got != blurred {
 99		t.Errorf("existing blur op was doubled: %q", got)
100	}
101}
102
103// TestBuildMediaURLAddsBlurWhenTokenDemandsIt drives the whole path: a
104// blur-constrained token gains a matching blur op in the composed URL, and an
105// unconstrained one does not.
106func TestBuildMediaURLAddsBlurWhenTokenDemandsIt(t *testing.T) {
107	path := "f/u/x.png/v1/fit/w_1280,h_1920/x.png"
108
109	got, ok := buildMediaURL("ed30a86b", path, makeMediaToken(t, ">=10"))
110	if !ok {
111		t.Fatal("a plain label was rejected, want accepted")
112	}
113	u, err := url.Parse(got)
114	if err != nil {
115		t.Fatalf("built an unparseable URL %q: %v", got, err)
116	}
117	if !strings.Contains(u.Path, "w_1280,h_1920,blur_10") {
118		t.Errorf("transform is %q, want a blur_10 op added", u.Path)
119	}
120
121	if got, _ := buildMediaURL("ed30a86b", path, makeMediaToken(t, nil)); strings.Contains(got, "blur") {
122		t.Errorf("unconstrained media gained a blur op: %q", got)
123	}
124}
125
126// TestBuildMediaURLKeepsHostOnWixmp is the property that actually matters: for
127// anything accepted, the host the client ends up talking to is the CDN.
128func TestBuildMediaURLKeepsHostOnWixmp(t *testing.T) {
129	got, ok := buildMediaURL("ed30a86b-8c4c-a887", "f/x.jpg", "abc")
130	if !ok {
131		t.Fatal("a plain hex-and-dash label was rejected, want accepted")
132	}
133
134	u, err := url.Parse(got)
135	if err != nil {
136		t.Fatalf("built an unparseable URL %q: %v", got, err)
137	}
138	if u.Host != "images-wixmp-ed30a86b-8c4c-a887.wixmp.com" {
139		t.Errorf("host is %q, want the wixmp CDN", u.Host)
140	}
141	if u.User != nil {
142		t.Errorf("URL carries userinfo %v, want none", u.User)
143	}
144	if u.Query().Get("token") != "abc" {
145		t.Errorf("token is %q, want abc", u.Query().Get("token"))
146	}
147}
148
149// TestBuildMediaURLEscapesPath checks that the path cannot end the URL early and
150// smuggle in a query or fragment of the caller's choosing.
151func TestBuildMediaURLEscapesPath(t *testing.T) {
152	got, ok := buildMediaURL("ed30a86b", "f/x.jpg#frag?q=1", "")
153	if !ok {
154		t.Fatal("a plain label was rejected, want accepted")
155	}
156
157	u, err := url.Parse(got)
158	if err != nil {
159		t.Fatalf("built an unparseable URL %q: %v", got, err)
160	}
161	if u.Fragment != "" {
162		t.Errorf("path opened a fragment %q, want it escaped into the path", u.Fragment)
163	}
164	if u.RawQuery != "" {
165		t.Errorf("path opened a query %q, want it escaped into the path", u.RawQuery)
166	}
167	if u.Path != "/f/x.jpg#frag?q=1" {
168		t.Errorf("path is %q, want it preserved verbatim", u.Path)
169	}
170}
171
172// TestDownloadAndSendMediaRejectsForgedSubdomain drives the handler itself, to
173// pin down that a forged label is refused before any fetch is attempted rather
174// than merely being rejected by the helper. Proxying is enabled here, so the
175// pre-fix handler would have reached the network on this input.
176func TestDownloadAndSendMediaRejectsForgedSubdomain(t *testing.T) {
177	proxy := CFG.Proxy
178	CFG.Proxy = true
179	defer func() { CFG.Proxy = proxy }()
180
181	w := httptest.NewRecorder()
182	s := skunkyart{Writer: w, Host: "http://localhost", Args: url.Values{}}
183	s.DownloadAndSendMedia("x@127.0.0.1:8080/", "f/x.jpg")
184
185	if w.Code != 400 {
186		t.Errorf("status is %d, want 400 for a forged subdomain", w.Code)
187	}
188}
189
190// TestMemCacheConcurrentAccess hammers the in-memory cache from many goroutines
191// while the janitor ages it, which is what a media flood does on an instance
192// with memcache enabled.
193//
194// This is the regression test for the readers that touched tempFS without
195// holding mx: concurrently with the janitor's delete that is a concurrent map
196// read and map write, which the runtime reports as a fatal error that no
197// recover can catch. Run under -race to also catch the unsynchronised field
198// access that does not happen to trip the map check.
199func TestMemCacheConcurrentAccess(t *testing.T) {
200	resetMemCache()
201	defer resetMemCache()
202
203	const workers, rounds = 24, 200
204	body := []byte("not-really-an-image")
205
206	var wg sync.WaitGroup
207	for w := range workers {
208		wg.Go(func() {
209			for i := range rounds {
210				// Overlapping keys, so goroutines contend for the same entries.
211				k := key(byte((w + i) % 8)) //nolint:gosec // G115: (w+i)%8 is 0-7
212				memPut(k, body)
213				memGet(k)
214			}
215		})
216	}
217
218	// Age the cache underneath the readers and writers: this is the delete that
219	// the old per-entry goroutines raced against.
220	wg.Go(func() {
221		for range rounds {
222			ageMemCache()
223		}
224	})
225
226	wg.Wait()
227}
228
229// TestMemGetReturnsStoredBody covers the plain hit and miss paths.
230func TestMemGetReturnsStoredBody(t *testing.T) {
231	resetMemCache()
232	defer resetMemCache()
233
234	k := key(1)
235	if got := memGet(k); got != nil {
236		t.Fatalf("empty cache: got %q, want nil", got)
237	}
238
239	want := []byte("body")
240	memPut(k, want)
241
242	got := memGet(k)
243	if !bytes.Equal(got, want) {
244		t.Fatalf("after put: got %q, want %q", got, want)
245	}
246}
247
248// TestMemPutIgnoresEmptyBody stops a failed fetch from caching a zero-length
249// image that would then be served to everyone until it aged out.
250func TestMemPutIgnoresEmptyBody(t *testing.T) {
251	resetMemCache()
252	defer resetMemCache()
253
254	k := key(2)
255	memPut(k, nil)
256	memPut(k, []byte{})
257
258	if got := memGet(k); got != nil {
259		t.Fatalf("empty body was cached: got %q, want nil", got)
260	}
261}
262
263// TestAgeMemCacheEvicts checks that a cold entry is dropped while a hot one
264// survives, since that scoring is the only bound on the cache's memory use.
265func TestAgeMemCacheEvicts(t *testing.T) {
266	resetMemCache()
267	defer resetMemCache()
268
269	cold, hot := key(3), key(4)
270	memPut(cold, []byte("cold"))
271	memPut(hot, []byte("hot"))
272
273	// A hit raises the hot entry's score above zero.
274	memGet(hot)
275
276	ageMemCache()
277
278	if got := memGet(cold); got != nil {
279		t.Errorf("cold entry survived aging: got %q, want nil", got)
280	}
281	if got := memGet(hot); got == nil {
282		t.Error("hot entry was evicted after a hit, want it kept")
283	}
284}