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
3import ( 3import (
4 "encoding/json" 4 "encoding/json"
5 "math/rand" 5 "math/rand"
6 "net/url"
6 "strings" 7 "strings"
7 8
8 "github.com/krazywarez/devianter" 9 "github.com/krazywarez/devianter"
@@ -52,15 +53,23 @@ func (a API) sendMedia(d *devianter.Deviation) {
52 return 53 return
53 } 54 }
54 55
55 if CFG.Proxy { 56 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 {
61 a.main.Writer.Header().Add("Location", mediaURL) 57 a.main.Writer.Header().Add("Location", mediaURL)
62 a.main.Writer.WriteHeader(302) 58 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
63 } 69 }
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"))
64} 73}
65 74
66// fetchDailyDeviations is devianter.GetDailyDeviations behind a variable so 75// fetchDailyDeviations is devianter.GetDailyDeviations behind a variable so
app/api_test.go +37
@@ -1,7 +1,10 @@
1package app 1package app
2 2
3import ( 3import (
4 "net/http"
4 "net/http/httptest" 5 "net/http/httptest"
6 "net/url"
7 "strings"
5 "testing" 8 "testing"
6 9
7 "github.com/krazywarez/devianter" 10 "github.com/krazywarez/devianter"
@@ -108,3 +111,37 @@ func TestRandomHonoursNSFW(t *testing.T) {
108 t.Errorf("status %d, Location %q; want 404 and no media", w.Code, w.Header().Get("Location")) 111 t.Errorf("status %d, Location %q; want 404 and no media", w.Code, w.Header().Get("Location"))
109 } 112 }
110} 113}
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) {
194// client, serving it from the on-disk or in-memory cache when enabled. It 194// client, serving it from the on-disk or in-memory cache when enabled. It
195// responds 403 when proxying is turned off for this instance. 195// responds 403 when proxying is turned off for this instance.
196func (s skunkyart) DownloadAndSendMedia(subdomain, path string) { 196func (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)
198 if !ok { 205 if !ok {
199 s.ReturnHTTPError(400) 206 s.ReturnHTTPError(400)
200 return 207 return
@@ -225,7 +232,7 @@ func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
225 memPut(key, response) 232 memPut(key, response)
226 } 233 }
227 case CFG.Proxy: 234 case CFG.Proxy:
228 dwnld := Download(mediaURL) 235 dwnld := fetchMedia(mediaURL)
229 if dwnld.Status != 200 { 236 if dwnld.Status != 200 {
230 s.ReturnHTTPError(dwnld.Status) 237 s.ReturnHTTPError(dwnld.Status)
231 return 238 return
@@ -258,7 +265,7 @@ func (s skunkyart) loadOrFetchMedia(filePath, mediaURL string) ([]byte, bool) {
258 } 265 }
259 } 266 }
260 267
261 dwnld := Download(mediaURL) 268 dwnld := fetchMedia(mediaURL)
262 if dwnld.Status != 200 || !strings.HasPrefix(dwnld.Headers.Get("Content-Type"), "image") { 269 if dwnld.Status != 200 || !strings.HasPrefix(dwnld.Headers.Get("Content-Type"), "image") {
263 s.ReturnHTTPError(dwnld.Status) 270 s.ReturnHTTPError(dwnld.Status)
264 return nil, false 271 return nil, false
main.go +8 −3
@@ -39,13 +39,18 @@ func main() {
39 // and let the request escape the throttle and the configured User-Agent. 39 // and let the request escape the throttle and the configured User-Agent.
40 go app.RefreshInstances() 40 go app.RefreshInstances()
41 41
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 }
42 go func() { 48 go func() {
43 for { 49 for {
44 err := devianter.UpdateCSRF() 50 time.Sleep(12 * time.Hour)
45 if err != nil { 51 if err := devianter.UpdateCSRF(); err != nil {
46 println(err.Error()) 52 println(err.Error())
47 } 53 }
48 time.Sleep(12 * time.Hour)
49 } 54 }
50 }() 55 }()
51 56