diff options
Diffstat (limited to 'app')
| -rwxr-xr-x | app/cache.go | 73 | ||||
| -rw-r--r-- | app/cache_test.go | 77 |
2 files changed, 150 insertions, 0 deletions
diff --git a/app/cache.go b/app/cache.go index e4822db..6142178 100755 --- a/app/cache.go +++ b/app/cache.go @@ -4,11 +4,14 @@ package app import ( "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive + "encoding/base64" "encoding/hex" + "encoding/json" "io" "net/url" "os" "regexp" + "strconv" "strings" "sync" "syscall" @@ -88,6 +91,69 @@ func ageMemCache() { // than escaped, because this label is what selects the host to fetch from. var mediaSubdomain = regexp.MustCompile(`^[a-zA-Z0-9-]+$`) +// blurConstraint reports the minimum blur radius a wixmp media token demands, or +// 0 if it demands none. +// +// DeviantArt signs mature-content media with a watermark-service token whose obj +// carries a "blur": ">=N" constraint. wixmp then rejects a plain /v1/fit +// transform with 403 unless it includes a matching blur_N operation, so this is +// what tells buildMediaURL when to add one. A token it cannot parse yields 0, +// leaving the URL untouched — the same behaviour as before this check existed. +func blurConstraint(token string) int { + // A JWT is header.payload.signature; the claims are the middle segment, + // base64url-encoded without padding. + parts := strings.SplitN(token, ".", 3) + if len(parts) < 2 { + return 0 + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return 0 + } + + var claims struct { + Obj [][]struct { + Blur string `json:"blur"` + } `json:"obj"` + } + if json.Unmarshal(payload, &claims) != nil || + len(claims.Obj) == 0 || len(claims.Obj[0]) == 0 { + return 0 + } + + // The constraint reads like ">=10"; take its digits as the radius, which is + // the minimum the token accepts. + n := 0 + for _, c := range claims.Obj[0][0].Blur { + if c >= '0' && c <= '9' { + n = n*10 + int(c-'0') + } + } + return n +} + +// addBlurToTransform inserts a blur_n operation into a wixmp /v1/fit transform, +// turning e.g. w_1280,h_1920 into w_1280,h_1920,blur_n. It returns path +// unchanged when it carries no /v1/fit transform (GIFs and oversized originals +// are served without one) or already blurs. +func addBlurToTransform(path string, n int) string { + const marker = "/v1/fit/" + start := strings.Index(path, marker) + if start < 0 { + return path + } + ops := start + len(marker) + end := strings.IndexByte(path[ops:], '/') + if end < 0 { + return path + } + end += ops + if strings.Contains(path[ops:end], "blur_") { + return path + } + return path[:end] + ",blur_" + strconv.Itoa(n) + path[end:] +} + // buildMediaURL returns the wixmp CDN URL for one media item, reporting false // when subdomain is not a bare hostname label. // @@ -102,6 +168,13 @@ func buildMediaURL(subdomain, path, token string) (string, bool) { return "", false } + // Mature media is signed with a token that only authorizes a blurred render; + // without a matching blur op in the transform wixmp answers 403. Add the op + // the token demands, and only then, so unconstrained media is left as-is. + if n := blurConstraint(token); n > 0 { + path = addBlurToTransform(path, n) + } + // Fields rather than concatenation: String escapes the path, so a decoded // "#" or "?" in it stays part of the path instead of ending it. The host is // checked above rather than escaped, because url.URL passes it through diff --git a/app/cache_test.go b/app/cache_test.go index eafd8b1..4fcae94 100644 --- a/app/cache_test.go +++ b/app/cache_test.go @@ -2,8 +2,11 @@ package app import ( "bytes" + "encoding/base64" + "encoding/json" "net/http/httptest" "net/url" + "strings" "sync" "testing" ) @@ -46,6 +49,80 @@ func TestBuildMediaURLRejectsForgedSubdomain(t *testing.T) { } } +// makeMediaToken builds a JWT-shaped token whose obj carries the given blur +// value, mirroring the wixmp media tokens DeviantArt signs. Pass a ">=N" string +// for a blur-constrained (mature) token, or nil for an unconstrained one. +func makeMediaToken(t *testing.T, blur any) string { + t.Helper() + claims := map[string]any{ + "obj": [][]map[string]any{{{"path": "/f/x.png", "blur": blur}}}, + } + payload, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + enc := base64.RawURLEncoding.EncodeToString + return enc([]byte(`{"alg":"none"}`)) + "." + enc(payload) + ".sig" +} + +// TestBlurConstraint is the regression test for the mature-media 403: a token +// whose obj demands a blur must yield that radius, and anything else must yield +// 0 so the transform is left untouched. +func TestBlurConstraint(t *testing.T) { + if got := blurConstraint(makeMediaToken(t, ">=10")); got != 10 { + t.Errorf("blur-constrained token: got %d, want 10", got) + } + if got := blurConstraint(makeMediaToken(t, nil)); got != 0 { + t.Errorf("null-blur token: got %d, want 0", got) + } + // Nothing parseable as a claims payload: fail open, leaving the URL alone. + for _, tok := range []string{"", "not-a-jwt", "a.b", "a.!!!.c"} { + if got := blurConstraint(tok); got != 0 { + t.Errorf("unparseable token %q: got %d, want 0", tok, got) + } + } +} + +// TestAddBlurToTransform checks the string surgery: a blur op is inserted into a +// /v1/fit transform, paths without one are untouched, and an existing op is not +// doubled. +func TestAddBlurToTransform(t *testing.T) { + got := addBlurToTransform("f/u/x.png/v1/fit/w_1280,h_1920/x.png", 10) + if want := "f/u/x.png/v1/fit/w_1280,h_1920,blur_10/x.png"; got != want { + t.Errorf("got %q, want %q", got, want) + } + if got := addBlurToTransform("f/u/x.gif", 10); got != "f/u/x.gif" { + t.Errorf("path without a transform was modified: %q", got) + } + blurred := "f/u/x.png/v1/fit/w_1280,h_1920,blur_10/x.png" + if got := addBlurToTransform(blurred, 10); got != blurred { + t.Errorf("existing blur op was doubled: %q", got) + } +} + +// TestBuildMediaURLAddsBlurWhenTokenDemandsIt drives the whole path: a +// blur-constrained token gains a matching blur op in the composed URL, and an +// unconstrained one does not. +func TestBuildMediaURLAddsBlurWhenTokenDemandsIt(t *testing.T) { + path := "f/u/x.png/v1/fit/w_1280,h_1920/x.png" + + got, ok := buildMediaURL("ed30a86b", path, makeMediaToken(t, ">=10")) + if !ok { + t.Fatal("a plain label was rejected, want accepted") + } + u, err := url.Parse(got) + if err != nil { + t.Fatalf("built an unparseable URL %q: %v", got, err) + } + if !strings.Contains(u.Path, "w_1280,h_1920,blur_10") { + t.Errorf("transform is %q, want a blur_10 op added", u.Path) + } + + if got, _ := buildMediaURL("ed30a86b", path, makeMediaToken(t, nil)); strings.Contains(got, "blur") { + t.Errorf("unconstrained media gained a blur op: %q", got) + } +} + // TestBuildMediaURLKeepsHostOnWixmp is the property that actually matters: for // anything accepted, the host the client ends up talking to is the CDN. func TestBuildMediaURLKeepsHostOnWixmp(t *testing.T) { |
