Commit ee9fc6db38
Unsigned
Layout: unified · split
app/cache.go +73
| @@ -4,11 +4,14 @@ package app | ||
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | 6 | "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive |
| 7 | "encoding/base64" | |
| 7 | 8 | "encoding/hex" |
| 9 | "encoding/json" | |
| 8 | 10 | "io" |
| 9 | 11 | "net/url" |
| 10 | 12 | "os" |
| 11 | 13 | "regexp" |
| 14 | "strconv" | |
| 12 | 15 | "strings" |
| 13 | 16 | "sync" |
| 14 | 17 | "syscall" |
| @@ -88,6 +91,69 @@ func ageMemCache() { | ||
| 88 | 91 | // than escaped, because this label is what selects the host to fetch from. |
| 89 | 92 | var 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. | |
| 102 | func 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. | |
| 139 | func 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 | 157 | // buildMediaURL returns the wixmp CDN URL for one media item, reporting false |
| 92 | 158 | // when subdomain is not a bare hostname label. |
| 93 | 159 | // |
| @@ -102,6 +168,13 @@ func buildMediaURL(subdomain, path, token string) (string, bool) { | ||
| 102 | 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 | 178 | // Fields rather than concatenation: String escapes the path, so a decoded |
| 106 | 179 | // "#" or "?" in it stays part of the path instead of ending it. The host is |
| 107 | 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 | |
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | "encoding/base64" | |
| 6 | "encoding/json" | |
| 5 | 7 | "net/http/httptest" |
| 6 | 8 | "net/url" |
| 9 | "strings" | |
| 7 | 10 | "sync" |
| 8 | 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. | |
| 55 | func 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. | |
| 71 | func 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. | |
| 89 | func 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. | |
| 106 | func 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 | 126 | // TestBuildMediaURLKeepsHostOnWixmp is the property that actually matters: for |
| 50 | 127 | // anything accepted, the host the client ends up talking to is the CDN. |
| 51 | 128 | func TestBuildMediaURLKeepsHostOnWixmp(t *testing.T) { |