diff options
| author | Christian Cleberg <[email protected]> | 2026-07-15 11:41:18 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-07-15 11:41:18 -0500 |
| commit | 184ce2c6dcdd047d6888fd243b9189c797f58d38 (patch) | |
| tree | 9ff7cf8d36007a319393c533abf92c3f23b3da5e /app/cache_test.go | |
| parent | 8e8c03891a17798067a8da0a034725bf44ece8d0 (diff) | |
| download | skunky-art-184ce2c6dcdd047d6888fd243b9189c797f58d38.tar.gz skunky-art-184ce2c6dcdd047d6888fd243b9189c797f58d38.tar.bz2 skunky-art-184ce2c6dcdd047d6888fd243b9189c797f58d38.zip | |
fix: reject forged subdomains in the media proxy (#9)
* fix: make memcache concurrency-safe and stamp the version at link time
memcache was a crash waiting for traffic. Readers touched tempFS without
holding mx, while a per-entry goroutine deleted from it under the lock: a
concurrent map read and map write, which the runtime treats as a fatal error
that recover cannot catch. The option ships in config.example.json and was the
one cache key SETUP.md never documented, so it read like a free win to enable.
Put every map and field access behind the mutex, and age the whole map from one
janitor instead of a goroutine per cached file, each of which looped forever
holding its entry alive. mx is now a plain Mutex: every operation here mutates
something, and the old code took an RLock to write. Document the option, and
cover it with tests that run the readers, writers and janitor concurrently.
Split the disk/origin fetch out of DownloadAndSendMedia while there, so the
error path returns instead of falling through to write an empty body after the
error page.
Release.Version was hardcoded to 1.3.2, so images tagged v1.3.6 reported 1.3.2
from --help and /api/instance, and --help linked to the wrong release. Take it
from a main.version string the release workflow links in from the git tag.
* fix: reject forged subdomains in the media proxy
DownloadAndSendMedia built its upstream URL by concatenation, pasting the
subdomain segment of the request path straight into the host position. That
segment reaches the handler already percent-decoded, so it can carry "@", "#",
"?" and "/" — the characters that end a host. A request for
/media/file/[email protected]:8080%2F/f/x.jpg
built a URL whose host parsed as 127.0.0.1:8080, with images-wixmp-x demoted to
userinfo, letting any caller aim the instance's fetcher at any address it could
reach, including services behind the firewall.
Validate the label against ^[a-zA-Z0-9-]+$ and refuse anything else with a 400.
Rejecting rather than escaping is what closes this: the label is the host, and
url.URL passes a host through verbatim, so building the URL structurally is not
sufficient on its own. DeviantArt's own media URLs use a hex-and-dash label, and
ParseMedia already splits on the first dot, so a legitimate label cannot contain
one.
Build the URL from url.URL fields as well, which escapes the path, and encode
the token argument, which reached the request unescaped.
Reported by CodeQL as go/request-forgery (CWE-918).
Diffstat (limited to 'app/cache_test.go')
| -rw-r--r-- | app/cache_test.go | 207 |
1 files changed, 207 insertions, 0 deletions
diff --git a/app/cache_test.go b/app/cache_test.go new file mode 100644 index 0000000..eafd8b1 --- /dev/null +++ b/app/cache_test.go @@ -0,0 +1,207 @@ +package app + +import ( + "bytes" + "net/http/httptest" + "net/url" + "sync" + "testing" +) + +// resetMemCache empties the in-memory cache so each test starts clean. +func resetMemCache() { + mx.Lock() + defer mx.Unlock() + tempFS = make(map[[20]byte]*file) +} + +func key(b byte) [20]byte { + var k [20]byte + k[0] = b + return k +} + +// TestBuildMediaURLRejectsForgedSubdomain is the regression test for the SSRF in +// the media proxy: subdomain reaches us percent-decoded from the request path, +// so it can carry "@", "#", "?" and "/" — every character that ends a host. When +// the URL was built by concatenation, each of these reparsed as a host the +// caller chose. The label is the host, so it has to be rejected, not escaped. +func TestBuildMediaURLRejectsForgedSubdomain(t *testing.T) { + // The path a request for /media/file/<subdomain>/f.jpg would decode to. + for _, subdomain := range []string{ + "[email protected]#", // userinfo + fragment: host is attacker.example + "[email protected]/", // userinfo, host terminated by the slash + "[email protected]:8080/", // the same, aimed inside the instance's network + "x@[::1]:8080/", // IPv6 loopback + "attacker.example#", // fragment alone truncates to images-wixmp-attacker.example + "attacker.example?", // query does the same + "a/../../secret", // slashes escape the label entirely + "a\\attacker.example", // backslash, which some parsers fold to "/" + "a.wixmp.com.attacker.eu", // dots: a label may not contain them + "", // empty label + } { + if got, ok := buildMediaURL(subdomain, "f/x.jpg", ""); ok { + t.Errorf("subdomain %q: accepted and built %q, want rejected", subdomain, got) + } + } +} + +// TestBuildMediaURLKeepsHostOnWixmp is the property that actually matters: for +// anything accepted, the host the client ends up talking to is the CDN. +func TestBuildMediaURLKeepsHostOnWixmp(t *testing.T) { + got, ok := buildMediaURL("ed30a86b-8c4c-a887", "f/x.jpg", "abc") + if !ok { + t.Fatal("a plain hex-and-dash label was rejected, want accepted") + } + + u, err := url.Parse(got) + if err != nil { + t.Fatalf("built an unparseable URL %q: %v", got, err) + } + if u.Host != "images-wixmp-ed30a86b-8c4c-a887.wixmp.com" { + t.Errorf("host is %q, want the wixmp CDN", u.Host) + } + if u.User != nil { + t.Errorf("URL carries userinfo %v, want none", u.User) + } + if u.Query().Get("token") != "abc" { + t.Errorf("token is %q, want abc", u.Query().Get("token")) + } +} + +// TestBuildMediaURLEscapesPath checks that the path cannot end the URL early and +// smuggle in a query or fragment of the caller's choosing. +func TestBuildMediaURLEscapesPath(t *testing.T) { + got, ok := buildMediaURL("ed30a86b", "f/x.jpg#frag?q=1", "") + if !ok { + t.Fatal("a plain label was rejected, want accepted") + } + + u, err := url.Parse(got) + if err != nil { + t.Fatalf("built an unparseable URL %q: %v", got, err) + } + if u.Fragment != "" { + t.Errorf("path opened a fragment %q, want it escaped into the path", u.Fragment) + } + if u.RawQuery != "" { + t.Errorf("path opened a query %q, want it escaped into the path", u.RawQuery) + } + if u.Path != "/f/x.jpg#frag?q=1" { + t.Errorf("path is %q, want it preserved verbatim", u.Path) + } +} + +// TestDownloadAndSendMediaRejectsForgedSubdomain drives the handler itself, to +// pin down that a forged label is refused before any fetch is attempted rather +// than merely being rejected by the helper. Proxying is enabled here, so the +// pre-fix handler would have reached the network on this input. +func TestDownloadAndSendMediaRejectsForgedSubdomain(t *testing.T) { + proxy := CFG.Proxy + CFG.Proxy = true + defer func() { CFG.Proxy = proxy }() + + w := httptest.NewRecorder() + s := skunkyart{Writer: w, Host: "http://localhost", Args: url.Values{}} + s.DownloadAndSendMedia("[email protected]:8080/", "f/x.jpg") + + if w.Code != 400 { + t.Errorf("status is %d, want 400 for a forged subdomain", w.Code) + } +} + +// TestMemCacheConcurrentAccess hammers the in-memory cache from many goroutines +// while the janitor ages it, which is what a media flood does on an instance +// with memcache enabled. +// +// This is the regression test for the readers that touched tempFS without +// holding mx: concurrently with the janitor's delete that is a concurrent map +// read and map write, which the runtime reports as a fatal error that no +// recover can catch. Run under -race to also catch the unsynchronised field +// access that does not happen to trip the map check. +func TestMemCacheConcurrentAccess(t *testing.T) { + resetMemCache() + defer resetMemCache() + + const workers, rounds = 24, 200 + body := []byte("not-really-an-image") + + var wg sync.WaitGroup + for w := range workers { + wg.Go(func() { + for i := range rounds { + // Overlapping keys, so goroutines contend for the same entries. + k := key(byte((w + i) % 8)) //nolint:gosec // G115: (w+i)%8 is 0-7 + memPut(k, body) + memGet(k) + } + }) + } + + // Age the cache underneath the readers and writers: this is the delete that + // the old per-entry goroutines raced against. + wg.Go(func() { + for range rounds { + ageMemCache() + } + }) + + wg.Wait() +} + +// TestMemGetReturnsStoredBody covers the plain hit and miss paths. +func TestMemGetReturnsStoredBody(t *testing.T) { + resetMemCache() + defer resetMemCache() + + k := key(1) + if got := memGet(k); got != nil { + t.Fatalf("empty cache: got %q, want nil", got) + } + + want := []byte("body") + memPut(k, want) + + got := memGet(k) + if !bytes.Equal(got, want) { + t.Fatalf("after put: got %q, want %q", got, want) + } +} + +// TestMemPutIgnoresEmptyBody stops a failed fetch from caching a zero-length +// image that would then be served to everyone until it aged out. +func TestMemPutIgnoresEmptyBody(t *testing.T) { + resetMemCache() + defer resetMemCache() + + k := key(2) + memPut(k, nil) + memPut(k, []byte{}) + + if got := memGet(k); got != nil { + t.Fatalf("empty body was cached: got %q, want nil", got) + } +} + +// TestAgeMemCacheEvicts checks that a cold entry is dropped while a hot one +// survives, since that scoring is the only bound on the cache's memory use. +func TestAgeMemCacheEvicts(t *testing.T) { + resetMemCache() + defer resetMemCache() + + cold, hot := key(3), key(4) + memPut(cold, []byte("cold")) + memPut(hot, []byte("hot")) + + // A hit raises the hot entry's score above zero. + memGet(hot) + + ageMemCache() + + if got := memGet(cold); got != nil { + t.Errorf("cold entry survived aging: got %q, want nil", got) + } + if got := memGet(hot); got == nil { + t.Error("hot entry was evicted after a hit, want it kept") + } +} |
