Commit ee9fc6db38

ee9fc6db38dbbee7418f8a3be2ce25eaa76f90ec

parent: ddb8aa2979

Unsigned

cmc <hello@cleberg.net> · 2026-08-07 23:34 UTC

fix: add blur op for mature deviations with blur-constrained tokens

Mature deviations are signed with a watermark-service token whose obj
carries a "blur": ">=N" constraint. The composed wixmp /v1/fit
transform had no blur operation, so wixmp rejected it with 403, which
the proxy passed through as a broken image.

buildMediaURL now decodes the token and, when it demands a blur, appends
a matching blur_N op to the transform (e.g. w_1280,h_1920,blur_10).
Unconstrained media and tokens that don't parse are left untouched, so
only media that requires it is affected.

Closes #14

Layout: unified · split

app/cache.go +73
@@ -4,11 +4,14 @@ package app
4 4
5import ( 5import (
6 "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive 6 "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive
7 "encoding/base64"
7 "encoding/hex" 8 "encoding/hex"
9 "encoding/json"
8 "io" 10 "io"
9 "net/url" 11 "net/url"
10 "os" 12 "os"
11 "regexp" 13 "regexp"
14 "strconv"
12 "strings" 15 "strings"
13 "sync" 16 "sync"
14 "syscall" 17 "syscall"
@@ -88,6 +91,69 @@ func ageMemCache() {
88// than escaped, because this label is what selects the host to fetch from. 91// than escaped, because this label is what selects the host to fetch from.
89var mediaSubdomain = regexp.MustCompile(`^[a-zA-Z0-9-]+$`) 92var mediaSubdomain = regexp.MustCompile(`^[a-zA-Z0-9-]+$`)
90 93
94// blurConstraint reports the minimum blur radius a wixmp media token demands, or
95// 0 if it demands none.
96//
97// DeviantArt signs mature-content media with a watermark-service token whose obj
98// carries a "blur": ">=N" constraint. wixmp then rejects a plain /v1/fit
99// transform with 403 unless it includes a matching blur_N operation, so this is
100// what tells buildMediaURL when to add one. A token it cannot parse yields 0,
101// leaving the URL untouched — the same behaviour as before this check existed.
102func blurConstraint(token string) int {
103 // A JWT is header.payload.signature; the claims are the middle segment,
104 // base64url-encoded without padding.
105 parts := strings.SplitN(token, ".", 3)
106 if len(parts) < 2 {
107 return 0
108 }
109 payload, err := base64.RawURLEncoding.DecodeString(parts[1])
110 if err != nil {
111 return 0
112 }
113
114 var claims struct {
115 Obj [][]struct {
116 Blur string `json:"blur"`
117 } `json:"obj"`
118 }
119 if json.Unmarshal(payload, &claims) != nil ||
120 len(claims.Obj) == 0 || len(claims.Obj[0]) == 0 {
121 return 0
122 }
123
124 // The constraint reads like ">=10"; take its digits as the radius, which is
125 // the minimum the token accepts.
126 n := 0
127 for _, c := range claims.Obj[0][0].Blur {
128 if c >= '0' && c <= '9' {
129 n = n*10 + int(c-'0')
130 }
131 }
132 return n
133}
134
135// addBlurToTransform inserts a blur_n operation into a wixmp /v1/fit transform,
136// turning e.g. w_1280,h_1920 into w_1280,h_1920,blur_n. It returns path
137// unchanged when it carries no /v1/fit transform (GIFs and oversized originals
138// are served without one) or already blurs.
139func addBlurToTransform(path string, n int) string {
140 const marker = "/v1/fit/"
141 start := strings.Index(path, marker)
142 if start < 0 {
143 return path
144 }
145 ops := start + len(marker)
146 end := strings.IndexByte(path[ops:], '/')
147 if end < 0 {
148 return path
149 }
150 end += ops
151 if strings.Contains(path[ops:end], "blur_") {
152 return path
153 }
154 return path[:end] + ",blur_" + strconv.Itoa(n) + path[end:]
155}
156
91// buildMediaURL returns the wixmp CDN URL for one media item, reporting false 157// buildMediaURL returns the wixmp CDN URL for one media item, reporting false
92// when subdomain is not a bare hostname label. 158// when subdomain is not a bare hostname label.
93// 159//
@@ -102,6 +168,13 @@ func buildMediaURL(subdomain, path, token string) (string, bool) {
102 return "", false 168 return "", false
103 } 169 }
104 170
171 // Mature media is signed with a token that only authorizes a blurred render;
172 // without a matching blur op in the transform wixmp answers 403. Add the op
173 // the token demands, and only then, so unconstrained media is left as-is.
174 if n := blurConstraint(token); n > 0 {
175 path = addBlurToTransform(path, n)
176 }
177
105 // Fields rather than concatenation: String escapes the path, so a decoded 178 // Fields rather than concatenation: String escapes the path, so a decoded
106 // "#" or "?" in it stays part of the path instead of ending it. The host is 179 // "#" or "?" in it stays part of the path instead of ending it. The host is
107 // checked above rather than escaped, because url.URL passes it through 180 // checked above rather than escaped, because url.URL passes it through
app/cache_test.go +77
@@ -2,8 +2,11 @@ package app
2 2
3import ( 3import (
4 "bytes" 4 "bytes"
5 "encoding/base64"
6 "encoding/json"
5 "net/http/httptest" 7 "net/http/httptest"
6 "net/url" 8 "net/url"
9 "strings"
7 "sync" 10 "sync"
8 "testing" 11 "testing"
9) 12)
@@ -46,6 +49,80 @@ func TestBuildMediaURLRejectsForgedSubdomain(t *testing.T) {
46 } 49 }
47} 50}
48 51
52// makeMediaToken builds a JWT-shaped token whose obj carries the given blur
53// value, mirroring the wixmp media tokens DeviantArt signs. Pass a ">=N" string
54// for a blur-constrained (mature) token, or nil for an unconstrained one.
55func makeMediaToken(t *testing.T, blur any) string {
56 t.Helper()
57 claims := map[string]any{
58 "obj": [][]map[string]any{{{"path": "/f/x.png", "blur": blur}}},
59 }
60 payload, err := json.Marshal(claims)
61 if err != nil {
62 t.Fatal(err)
63 }
64 enc := base64.RawURLEncoding.EncodeToString
65 return enc([]byte(`{"alg":"none"}`)) + "." + enc(payload) + ".sig"
66}
67
68// TestBlurConstraint is the regression test for the mature-media 403: a token
69// whose obj demands a blur must yield that radius, and anything else must yield
70// 0 so the transform is left untouched.
71func TestBlurConstraint(t *testing.T) {
72 if got := blurConstraint(makeMediaToken(t, ">=10")); got != 10 {
73 t.Errorf("blur-constrained token: got %d, want 10", got)
74 }
75 if got := blurConstraint(makeMediaToken(t, nil)); got != 0 {
76 t.Errorf("null-blur token: got %d, want 0", got)
77 }
78 // Nothing parseable as a claims payload: fail open, leaving the URL alone.
79 for _, tok := range []string{"", "not-a-jwt", "a.b", "a.!!!.c"} {
80 if got := blurConstraint(tok); got != 0 {
81 t.Errorf("unparseable token %q: got %d, want 0", tok, got)
82 }
83 }
84}
85
86// TestAddBlurToTransform checks the string surgery: a blur op is inserted into a
87// /v1/fit transform, paths without one are untouched, and an existing op is not
88// doubled.
89func TestAddBlurToTransform(t *testing.T) {
90 got := addBlurToTransform("f/u/x.png/v1/fit/w_1280,h_1920/x.png", 10)
91 if want := "f/u/x.png/v1/fit/w_1280,h_1920,blur_10/x.png"; got != want {
92 t.Errorf("got %q, want %q", got, want)
93 }
94 if got := addBlurToTransform("f/u/x.gif", 10); got != "f/u/x.gif" {
95 t.Errorf("path without a transform was modified: %q", got)
96 }
97 blurred := "f/u/x.png/v1/fit/w_1280,h_1920,blur_10/x.png"
98 if got := addBlurToTransform(blurred, 10); got != blurred {
99 t.Errorf("existing blur op was doubled: %q", got)
100 }
101}
102
103// TestBuildMediaURLAddsBlurWhenTokenDemandsIt drives the whole path: a
104// blur-constrained token gains a matching blur op in the composed URL, and an
105// unconstrained one does not.
106func TestBuildMediaURLAddsBlurWhenTokenDemandsIt(t *testing.T) {
107 path := "f/u/x.png/v1/fit/w_1280,h_1920/x.png"
108
109 got, ok := buildMediaURL("ed30a86b", path, makeMediaToken(t, ">=10"))
110 if !ok {
111 t.Fatal("a plain label was rejected, want accepted")
112 }
113 u, err := url.Parse(got)
114 if err != nil {
115 t.Fatalf("built an unparseable URL %q: %v", got, err)
116 }
117 if !strings.Contains(u.Path, "w_1280,h_1920,blur_10") {
118 t.Errorf("transform is %q, want a blur_10 op added", u.Path)
119 }
120
121 if got, _ := buildMediaURL("ed30a86b", path, makeMediaToken(t, nil)); strings.Contains(got, "blur") {
122 t.Errorf("unconstrained media gained a blur op: %q", got)
123 }
124}
125
49// TestBuildMediaURLKeepsHostOnWixmp is the property that actually matters: for 126// TestBuildMediaURLKeepsHostOnWixmp is the property that actually matters: for
50// anything accepted, the host the client ends up talking to is the CDN. 127// anything accepted, the host the client ends up talking to is the CDN.
51func TestBuildMediaURLKeepsHostOnWixmp(t *testing.T) { 128func TestBuildMediaURLKeepsHostOnWixmp(t *testing.T) {