krz/skunky-art
Alternative privacy frontend for DeviantArt.
clone: git clone https://gitbay.org/krz/skunky-art.git
v1.4.0: 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 memGet(hot) == nil {
282 t.Error("hot entry was evicted after a hit, want it kept")
283 }
284}