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 |
| 3 | import ( |
3 | import ( |
| 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 @@ |
| 1 | package app |
1 | package app |
| 2 | |
2 | |
| 3 | import ( |
3 | import ( |
| |
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. |
| |
118 | func 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. |
| 196 | func (s skunkyart) DownloadAndSendMedia(subdomain, path string) { |
196 | func (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. |
| |
201 | var fetchMedia = Download |
| |
202 | |
| |
203 | func (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 | |