Commit ee9fc6db38
Unsigned
Layout: unified · split
app/cache.go +73
| @@ -4,11 +4,14 @@ package app | |||
| 4 | 4 | ||
| 5 | import ( | 5 | import ( |
| 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. |
| 89 | var mediaSubdomain = regexp.MustCompile(`^[a-zA-Z0-9-]+$`) | 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 | // 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 | ||
| 3 | import ( | 3 | import ( |
| 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. | ||
| 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 | // 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. |
| 51 | func TestBuildMediaURLKeepsHostOnWixmp(t *testing.T) { | 128 | func TestBuildMediaURLKeepsHostOnWixmp(t *testing.T) { |