1 Commits
Author SHA1 Message Date
sneak 08028e4560 Take the signature, the expiry and the URL scheme off the image request (closes #314)
check / check (push) Canceled after 0s
The image request now holds only its source and its transform, exactly what
the variant key covers. The /v1/image/ handler checks the allowlist and
verifies the signature itself with Signer.Verify(req, sig, expires). Both
image handlers keep the URL's expiry as a local value for Cache-Control. The
image service holds no signer, allowlist or AllowHTTP, and ValidateRequest is
gone. The fetcher takes the source and builds its URL with Source.URL from its
own AllowHTTP, so no URL string is built and parsed again. chooseAutoFormat
returns the format it chooses instead of writing into the request. The signed
URL format and every route's answers are unchanged.

Model: opus-5-5
2026-10-26 11:54:15 +00:00
2 changed files with 21 additions and 23 deletions

No files matched your search

@@ -38,44 +38,40 @@ func getImage(t *testing.T, fix *testFixtures, target string) int {
} }
// generateSignedURL returns the /v1/image/ URL of req, signed with key and // generateSignedURL returns the /v1/image/ URL of req, signed with key and
// expiring after ttl. It signs a copy of req, so req itself is not changed. // expiring after ttl. An unset quality or fit of req is first set to 85 or
// An unset quality or fit in the copy is set to 85 or cover, the values the // cover, the values the route uses when a URL has no q or fit. The URL carries
// route uses when a URL has no q or fit. The URL carries q and fit next to sig // q and fit next to sig and exp, so the route verifies it for the quality and
// and exp, so the route verifies it for the quality and fit it was signed // fit it was signed with.
// with.
func generateSignedURL( func generateSignedURL(
key string, req *imagerequest.Request, ttl time.Duration, key string, req *imagerequest.Request, ttl time.Duration,
) string { ) string {
signed := *req if req.Transform.Quality == 0 {
req.Transform.Quality = 85
if signed.Transform.Quality == 0 {
signed.Transform.Quality = 85
} }
if signed.Transform.FitMode == "" { if req.Transform.FitMode == "" {
signed.Transform.FitMode = imagerequest.FitCover req.Transform.FitMode = imagerequest.FitCover
} }
expires := time.Now().Add(ttl) expires := time.Now().Add(ttl)
sig := signature.New(key).Sign(&signed, expires) sig := signature.New(key).Sign(req, expires)
// A source query follows the source path after a "?". The image route // A source query follows the source path after a "?". The image route
// reads the path decoded once, so the whole path is written in Go's own // reads the path decoded once, so the whole path is written in Go's own
// percent-encoding of a URL path, which writes "?" as %3F and "%" as %25, // percent-encoding of a URL path, which writes "?" as %3F and "%" as %25,
// and leaves "/", ";", ",", "=" and "&" as they are. // and leaves "/", ";", ",", "=" and "&" as they are.
sourcePathAndQuery := signed.Source.Path sourcePathAndQuery := req.Source.Path
if signed.Source.Query != "" { if req.Source.Query != "" {
sourcePathAndQuery += "?" + signed.Source.Query sourcePathAndQuery += "?" + req.Source.Query
} }
unescapedPath := fmt.Sprintf("/v1/image/%s%s/%dx%d.%s", unescapedPath := fmt.Sprintf("/v1/image/%s%s/%dx%d.%s",
signed.Source.Host, sourcePathAndQuery, req.Source.Host, sourcePathAndQuery,
signed.Transform.Size.Width, signed.Transform.Size.Height, req.Transform.Size.Width, req.Transform.Size.Height, req.Transform.Format)
signed.Transform.Format)
return fmt.Sprintf("%s?sig=%s&exp=%d&q=%d&fit=%s", return fmt.Sprintf("%s?sig=%s&exp=%d&q=%d&fit=%s",
(&url.URL{Path: unescapedPath}).EscapedPath(), sig, expires.Unix(), (&url.URL{Path: unescapedPath}).EscapedPath(), sig, expires.Unix(),
signed.Transform.Quality, signed.Transform.FitMode) req.Transform.Quality, req.Transform.FitMode)
} }
// TestHandleImage_SignatureCoversQualityAndFit signs a URL for quality 85 // TestHandleImage_SignatureCoversQualityAndFit signs a URL for quality 85
+6 -4
View File
@@ -126,8 +126,9 @@ func parseImageComponents(remainder string) (*Request, error) {
return nil, ErrMissingHost return nil, ErrMissingHost
} }
// Later steps put the host unparsed into the allowlist check, the cache key // Only the forms hostRegex describes are accepted. A "?", "#" or "@" in
// and the upstream URL, so only the forms hostRegex describes are accepted // the host would make Go's URL parser, which the allowlist check and the
// fetcher use, read a different host from the one allowlisted and cached
hostMatch := hostRegex.FindStringSubmatch(host) hostMatch := hostRegex.FindStringSubmatch(host)
if hostMatch == nil { if hostMatch == nil {
return nil, fmt.Errorf("%w: %q", ErrInvalidHost, host) return nil, fmt.Errorf("%w: %q", ErrInvalidHost, host)
@@ -146,8 +147,9 @@ func parseImageComponents(remainder string) (*Request, error) {
// host // host
host = strings.ToLower(host) host = strings.ToLower(host)
// A "#" starts a URL's fragment, so neither the source path nor the source // Go's URL parser, which the fetcher uses, reads a "#" as the start of a
// query may hold one // fragment and drops the rest, so each text after it would be another
// spelling, cached under its own keys, of one upstream request
if strings.Contains(path, "#") { if strings.Contains(path, "#") {
return nil, fmt.Errorf( return nil, fmt.Errorf(
`%w: the source path or query holds a "#"`, ErrInvalidPath) `%w: the source path or query holds a "#"`, ErrInvalidPath)