Commit 48a4e98ae7

48a4e98ae70cd06f7c347db2a14599888a589525

parent: c01ae6a45d

Verified · cmc ci/build: success ci/lint: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-11 18:48 UTC

Proxy random media with the token in the query, and bootstrap before listening

/api/random answered 401 with proxying on: sendMedia sliced the wixmp
URL and passed its query tail as the path, so the signing token never
reached wixmp as a parameter. It now parses the URL and hands the
subdomain, path and token to the same code the media route uses.

The first CSRF bootstrap runs before the listener opens, so requests in
the first seconds after a restart no longer go upstream without a
session and fail.

Layout: unified · split

app/api.go +15 −6
@@ -3,6 +3,7 @@ package app
33import (
44 "encoding/json"
55 "math/rand"
6 "net/url"
67 "strings"
78
89 "github.com/krazywarez/devianter"
@@ -52,15 +53,23 @@ func (a API) sendMedia(d *devianter.Deviation) {
5253 return
5354 }
5455
55 if CFG.Proxy {
56 mediaURL = mediaURL[21:]
57 dot := strings.Index(mediaURL, ".")
58 a.main.Writer.Header().Del("Content-Type")
59 a.main.DownloadAndSendMedia(mediaURL[:dot], mediaURL[dot+11:])
60 } else {
56 if !CFG.Proxy {
6157 a.main.Writer.Header().Add("Location", mediaURL)
6258 a.main.Writer.WriteHeader(302)
59 return
60 }
61
62 // Parsed, not sliced: the signing token has to reach wixmp as a query
63 // parameter. Passing the raw tail as the path put "?token=..." inside
64 // the path, which wixmp answers with 401.
65 u, err := url.Parse(mediaURL)
66 if err != nil {
67 a.Error("bad media url", 502)
68 return
6369 }
70 subdomain := strings.TrimSuffix(strings.TrimPrefix(u.Host, "images-wixmp-"), ".wixmp.com")
71 a.main.Writer.Header().Del("Content-Type")
72 a.main.downloadAndSendMedia(subdomain, strings.TrimPrefix(u.Path, "/"), u.Query().Get("token"))
6473}
6574
6675// fetchDailyDeviations is devianter.GetDailyDeviations behind a variable so
app/api_test.go +37
@@ -1,7 +1,10 @@
11package app
22
33import (
4 "net/http"
45 "net/http/httptest"
6 "net/url"
7 "strings"
58 "testing"
69
710 "github.com/krazywarez/devianter"
@@ -108,3 +111,37 @@ func TestRandomHonoursNSFW(t *testing.T) {
108111 t.Errorf("status %d, Location %q; want 404 and no media", w.Code, w.Header().Get("Location"))
109112 }
110113}
114
115// TestSendMediaProxiesWithTheTokenInTheQuery is the regression test for
116// /api/random answering 401 with proxying on: the signing token was passed
117// inside the path, so wixmp never saw it as a parameter.
118func TestSendMediaProxiesWithTheTokenInTheQuery(t *testing.T) {
119 proxy, cache := CFG.Proxy, CFG.Cache.Enabled
120 CFG.Proxy, CFG.Cache.Enabled = true, false
121 defer func() { CFG.Proxy, CFG.Cache.Enabled = proxy, cache }()
122
123 var fetched string
124 orig := fetchMedia
125 fetchMedia = func(u string) Downloaded {
126 fetched = u
127 return Downloaded{Status: 200, Body: []byte("png"), Headers: http.Header{"Content-Type": {"image/png"}}}
128 }
129 defer func() { fetchMedia = orig }()
130
131 d := fullviewDeviation()
132 d.Media.Token = []string{"tok.en.sig"}
133
134 w := httptest.NewRecorder()
135 API{main: &skunkyart{Writer: w, Args: url.Values{}}}.sendMedia(d)
136
137 u, err := url.Parse(fetched)
138 if err != nil || u.Host != "images-wixmp-abc.wixmp.com" {
139 t.Fatalf("fetched %q, want a wixmp URL on the deviation's subdomain", fetched)
140 }
141 if strings.Contains(u.Path, "token") || u.Query().Get("token") == "" {
142 t.Errorf("token not passed as a query parameter: path %q query %q", u.Path, u.RawQuery)
143 }
144 if w.Code != 200 || w.Body.String() != "png" {
145 t.Errorf("response %d %q, want the proxied image", w.Code, w.Body.String())
146 }
147}
app/cache.go +10 −3
@@ -194,7 +194,14 @@ func buildMediaURL(subdomain, path, token string) (string, bool) {
194194// client, serving it from the on-disk or in-memory cache when enabled. It
195195// responds 403 when proxying is turned off for this instance.
196196func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
197 mediaURL, ok := buildMediaURL(subdomain, path, s.Args.Get("token"))
197 s.downloadAndSendMedia(subdomain, path, s.Args.Get("token"))
198}
199
200// fetchMedia is Download behind a variable so tests can script the CDN.
201var fetchMedia = Download
202
203func (s skunkyart) downloadAndSendMedia(subdomain, path, token string) {
204 mediaURL, ok := buildMediaURL(subdomain, path, token)
198205 if !ok {
199206 s.ReturnHTTPError(400)
200207 return
@@ -225,7 +232,7 @@ func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
225232 memPut(key, response)
226233 }
227234 case CFG.Proxy:
228 dwnld := Download(mediaURL)
235 dwnld := fetchMedia(mediaURL)
229236 if dwnld.Status != 200 {
230237 s.ReturnHTTPError(dwnld.Status)
231238 return
@@ -258,7 +265,7 @@ func (s skunkyart) loadOrFetchMedia(filePath, mediaURL string) ([]byte, bool) {
258265 }
259266 }
260267
261 dwnld := Download(mediaURL)
268 dwnld := fetchMedia(mediaURL)
262269 if dwnld.Status != 200 || !strings.HasPrefix(dwnld.Headers.Get("Content-Type"), "image") {
263270 s.ReturnHTTPError(dwnld.Status)
264271 return nil, false
main.go +8 −3
@@ -39,13 +39,18 @@ func main() {
3939 // and let the request escape the throttle and the configured User-Agent.
4040 go app.RefreshInstances()
4141
42 // The first session bootstrap runs before the listener opens: requests
43 // that arrive before it finishes go to DeviantArt without a token and
44 // fail, which showed up as 502s for the first seconds after a restart.
45 if err := devianter.UpdateCSRF(); err != nil {
46 println(err.Error())
47 }
4248 go func() {
4349 for {
44 err := devianter.UpdateCSRF()
45 if err != nil {
50 time.Sleep(12 * time.Hour)
51 if err := devianter.UpdateCSRF(); err != nil {
4652 println(err.Error())
4753 }
48 time.Sleep(12 * time.Hour)
4954 }
5055 }()
5156