krz/skunky-art
Alternative privacy frontend for DeviantArt.
clone: git clone https://gitbay.org/krz/skunky-art.git
v1.3.7: 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}